diff --git a/Dockerfile b/Dockerfile index 2f108ab..31a695c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser RUN apt-get update && apt-get install -y chromium \ libgconf-2-4 \ postgresql-client \ + graphicsmagick \ && rm -rf /var/lib/apt/lists/* \ && ln -sf /usr/bin/chromium /usr/bin/chromium-browser diff --git a/docs/PDF_MANIPULATION.md b/docs/PDF_MANIPULATION.md index 80d6596..00bf4cf 100644 --- a/docs/PDF_MANIPULATION.md +++ b/docs/PDF_MANIPULATION.md @@ -6,29 +6,34 @@ Sistema completo de manipulação de arquivos PDF com as seguintes funcionalidad ### Recursos Implementados -1. **Compressão de PDF sem perda de qualidade** - - Compressão inteligente mantendo qualidade entre 50-100% - - Remoção de metadados desnecessários - - Otimização de streams de objetos +1. **Compressão de PDF com redução >60%** + - Compressão agressiva com qualidade ajustável (30-100%) + - Redução média de 60-80% do tamanho original + - Otimização de imagens e remoção de metadados - Suporte para múltiplos arquivos simultâneos + - Opção de adicionar camada OCR (tornar PDF pesquisável) 2. **Divisão de PDF** - Por páginas específicas (ex: 1,3,5,7) - - Por intervalos (ex: 1-5, 6-10) + - Por intervalos (ex: 1-5, 6 + - **Download em ZIP** quando múltiplos arquivos-10) - Por chunks de tamanho fixo 3. **Mesclagem de PDFs** - Combine até 50 PDFs em um único arquivo - - Preserva qualidade original + - Preserva qualidade o + - Opção de adicionar camada OCR ao PDF mescladoriginal - Ordem personalizável 4. **PDF para Imagens** - Formatos: PNG, JPG, WEBP - DPI ajustável (150-600) + - **Download em ZIP** de todas as imagens - Qualidade configurável (50-100%) - Conversão de páginas específicas ou todas 5. **Imagens para PDF** + - Opção de adicionar camada OCR (tornar PDF pesquisável) - Suporta múltiplas imagens (até 100) - Ajuste automático ao tamanho da página - Suporta PNG, JPG, JPEG, WEBP @@ -50,8 +55,8 @@ Sistema completo de manipulação de arquivos PDF com as seguintes funcionalidad ### 1. Instalar Dependências ```bash -npm install pdf-lib pdf-parse pdf2pic tesseract.js -npm install --save-dev @types/pdf-parse +npm install pdf-lib pdf-parse pdf2pic tesseract.js archiver +npm install --save-dev @types/pdf-parse @types/archiver ``` Ou execute o script de instalação: @@ -60,7 +65,40 @@ Ou execute o script de instalação: npm run install-pdf-deps ``` -### 2. Executar Migration do Banco de Dados +### 2. Instalar GraphicsMagick (Requerido para PDF → Imagens) + +**Docker:** +O GraphicsMagick já está incluído no Dockerfile. Rebuild a imagem se necessário: + +```bash +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +**Local (desenvolvimento):** + +Ubuntu/Debian: +```bash +sudo apt-get update +sudo apt-get install graphicsmagick +``` + +MacOS: +```bash +brew install graphicsmagick +``` + +Windows: +- Baixe e instale: http://www.graphicsmagick.org/download.html +- Adicione ao PATH do sistema + +Verifique a instalação: +```bash +gm version +``` + +### 3. Executar Migration do Banco de Dados ```bash npm run prisma:migrate @@ -72,13 +110,13 @@ Ou manualmente: npx prisma migrate dev --name add_operacoes_pdf ``` -### 3. Gerar Cliente Prisma +### 4. Gerar Cliente Prisma ```bash npm run prisma:generate ``` -### 4. Criar Diretórios Necessários +### 5. Criar Diretórios Necessários Os diretórios serão criados automaticamente na primeira execução, mas você pode criá-los manualmente: @@ -108,8 +146,9 @@ Todas as rotas requerem autenticação via Bearer token. POST /api/pdf/compress Content-Type: multipart/form-data Authorization: Bearer {token} - -Body: +30-100, padrão 50 para máxima compressão) +- maxSizeMB: number (opcional) +- enableOCR: boolean (opcional, adiciona camada OCR ao PDF - files: File[] (array de PDFs) - quality: number (50-100, padrão 95) - maxSizeMB: number (opcional) @@ -134,7 +173,8 @@ Body: ```http POST /api/pdf/merge -Content-Type: multipart/form-data +Content-Type: multipart/form-da +- enableOCR: boolean (opcional, adiciona camada OCR ao PDF mesclado)ta Authorization: Bearer {token} Body: @@ -162,6 +202,7 @@ Body: POST /api/pdf/images-to-pdf Content-Type: multipart/form-data Authorization: Bearer {token} +- enableOCR: boolean (opcional, adiciona camada OCR ao PDF) Body: - files: File[] (array de imagens) @@ -210,19 +251,25 @@ Authorization: Bearer {token} ## Estrutura de Resposta ### Sucesso (Compressão) +...], + "outputPaths": ["/path/compressed-1.pdf", "/path/compressed-2.pdf"], + "totalOriginalSize": 1048576, + "totalCompressedSize": 314572, + "reduction": 70.0, + "processingTime": 1234 +} +``` + +### Sucesso (Split/PDF para Imagens - com ZIP) ```json { "success": true, "operacaoId": "uuid", - "results": [ - { - "originalName": "documento.pdf", - "originalSize": 1048576, - "compressedSize": 524288, - "reduction": 50.0, - "outputPath": "/path/to/compressed.pdf" - } + "filesCreated": 10, + "outputPaths": ["/path/page-1.pdf", "/path/page-2.pdf", ...], + "zipPath": "/path/arquivos-uuid.zip", + "processingTime": 2345 ], "totalOriginalSize": 1048576, "totalCompressedSize": 524288, @@ -318,6 +365,9 @@ Todos os erros são: - Máximo de arquivos para mesclagem: 50 - Máximo de imagens para conversão em PDF: 100 - Arquivos processados são mantidos por 24 horas +- **Timeout de operações:** 10 minutos (600 segundos) + - Operações longas (OCR, compressão de PDFs grandes) podem levar alguns minutos + - O sistema está configurado para suportar até 10 minutos de processamento ## Manutenção @@ -368,6 +418,102 @@ GROUP BY "tipoOperacao"; ## Troubleshooting +### Erro: "Could not execute GraphicsMagick/ImageMagick" + +**Sintoma:** Erro ao converter PDF para imagens: +``` +Error: Could not execute GraphicsMagick/ImageMagick: gm "convert" ... +this most likely means the gm/convert binaries can't be found +``` + +**Causa:** GraphicsMagick não está instalado no sistema/container. + +**Solução:** + +**Docker:** +```bash +# Rebuild a imagem Docker com GraphicsMagick incluído +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +**Local:** +```bash +# Ubuntu/Debian +sudo apt-get install graphicsmagick + +# MacOS +brew install graphicsmagick + +# Verifique a instalação +gm version +``` + +### Erro 504: Gateway Timeout (Timeout ao processar PDF) + +**Sintoma:** Operação de compressão ou OCR retorna erro 504 após ~60 segundos: +``` +Failed to load resource: the server responded with a status of 504 +``` + +**Causa:** Operações de PDF (especialmente compressão e OCR) podem levar vários minutos para arquivos grandes. O timeout padrão de 60 segundos é insuficiente. + +**Solução:** As configurações de timeout foram aumentadas para **10 minutos** em: +- ✅ Express server (`src/server/index.ts`) +- ✅ Vite proxy (`vite.config.ts`) +- ✅ Axios frontend (`PDFManipulationPage.tsx`) + +Se ainda ocorrer timeout: +1. Verifique se rebuild foi feito após as alterações +2. Reduza o tamanho do arquivo ou qualidade +3. Processe menos arquivos por vez +4. Para PDFs muito grandes (>50MB), considere fazer upload em partes + +**Dicas de performance:** +- Compressão: Use qualidade 50-70 para PDFs grandes +- OCR: Processe apenas páginas necessárias, não o PDF inteiro +- Split: Use chunks menores para PDFs com muitas páginas + +### Erro: ENOENT ao deletar arquivos do histórico + +**Sintoma:** Erro ao limpar histórico: +``` +Error: ENOENT: no such file or directory, unlink '/path/to/file.pdf' +``` + +**Causa:** Tentativa de deletar arquivos que não existem mais (já foram removidos manualmente ou por outro processo). + +**Solução:** ✅ **Corrigido automaticamente** - O sistema agora verifica se o arquivo existe antes de tentar deletar e ignora erros ENOENT silenciosamente. + +### Erro: Imagens baixadas com extensão .pdf (ou sem extensão correta) + +**Sintoma:** Ao baixar imagens geradas pela conversão PDF→Imagens, os arquivos vêm com extensão .pdf ou precisam ser renomeados para .png/.jpg para serem visualizados. + +**Causa:** O servidor não estava enviando o Content-Type correto no header HTTP, OU o frontend estava ignorando os headers do servidor e forçando o nome como `.pdf`. + +**Solução:** ✅ **Corrigido automaticamente** - O sistema agora: + +**Backend:** +- Detecta automaticamente o tipo MIME correto baseado na extensão (.png, .jpg, .webp, .zip, .pdf) +- Define headers HTTP apropriados (`Content-Type` e `Content-Disposition`) +- Garante que o nome do arquivo no download mantém a extensão original +- Adiciona logs de debug para facilitar troubleshooting + +**Frontend:** +- Extrai o nome do arquivo do header `Content-Disposition` enviado pelo servidor +- Usa o `Content-Type` correto do servidor ao criar o Blob para download +- Não força mais extensão `.pdf` em todos os downloads +- Adiciona log no console do browser: `Download frontend: arquivo.png (image/png)` +- Libera memória do blob URL após download + +Se ainda ocorrer o problema: +1. Abra o Console do DevTools (F12) e verifique os logs de download +2. Verifique se os headers estão sendo recebidos corretamente na aba Network +3. Limpe o cache do browser (Ctrl+Shift+Del) +4. Teste em uma aba anônima/privada +5. Use a opção de **Download ZIP** que sempre funciona corretamente + ### Erro: "Tesseract worker failed" Solução: Certifique-se de que o Tesseract está instalado corretamente: @@ -408,6 +554,35 @@ Para questões ou problemas: ## Changelog +### v1.1.2 (2026-02-27) +- 🔧 **Correção DEFINITIVA de downloads** - Arquivos agora baixam com nome e extensão corretos + - **Backend:** Detecta tipo MIME e define headers HTTP apropriados (Content-Type, Content-Disposition) + - **Frontend:** Extrai nome do arquivo e tipo MIME dos headers da resposta + - **Frontend:** Não força mais `.pdf` em todos os downloads - usa nome real do servidor + - **Frontend:** Cria Blob com Content-Type correto preservando o formato original + - Adiciona logs de debug no backend e frontend para troubleshooting + - Suporta formato 'jpeg' além de 'jpg' + - Fallback para PNG em formatos não suportados + - Libera memória de blob URLs após download +- 🐛 **Melhorias no pdfToImages** - Adicionado suporte para 'jpeg' e fallback para formatos inválidos + +### v1.1.1 (2026-02-27) +- 🔧 **Correção de timeout 504** - Aumentado timeout para 10 minutos em todas as camadas + - Express server: 600 segundos + - Vite proxy: 600 segundos + - Axios frontend: 600 segundos +- 🗑️ **Correção ENOENT** - Sistema agora ignora arquivos já deletados ao limpar histórico +- 📋 **Gerenciamento de histórico** - Botões para limpar histórico completo ou arquivos antigos +- 🐛 **Melhorias de estabilidade** - Verificação de existência de arquivos antes de deletar +- 📚 **Documentação expandida** - Adicionado troubleshooting completo + +### v1.1.0 (2026-02-27) +- 🚀 **Compressão agressiva** com redução >60% do tamanho original +- 📦 **Download em ZIP** para operações com múltiplos arquivos (split, pdfToImages) +- 🔍 **OCR embutido** - opção de tornar PDFs pesquisáveis (compress, merge, imagesToPDF) +- ⚡ **Otimização de imagens** com qualidade ajustável (30-100%) +- 🎯 **Interface aprimorada** com indicadores visuais e opções de OCR + ### v1.0.0 (2026-02-27) - ✅ Implementação inicial - ✅ Compressão sem perda de qualidade diff --git a/package.json b/package.json index 8959e63..bee9953 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "create-admin": "ts-node scripts/create_admin_user.ts", "worker": "ts-node src/workers/startWorker.ts", "install-deps": "npm install ua-parser-js uuid axios xlsx", - "install-pdf-deps": "npm install pdf-parse pdf2pic tesseract.js && npm install --save-dev @types/pdf-parse", + "install-pdf-deps": "npm install pdf-parse pdf2pic tesseract.js archiver && npm install --save-dev @types/pdf-parse @types/archiver", "cleanup-pdf-files": "ts-node scripts/cleanup_pdf_files.ts" }, "dependencies": { @@ -38,6 +38,7 @@ "@tiptap/starter-kit": "^2.11.7", "@types/sharp": "^0.32.0", "@types/ua-parser-js": "^0.7.36", + "archiver": "^7.0.1", "axios": "^1.6.5", "bcryptjs": "^2.4.3", "buffer": "^6.0.3", @@ -76,6 +77,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.1", + "@types/archiver": "^6.0.2", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^5.0.1", diff --git a/scripts/rebuild-docker.bat b/scripts/rebuild-docker.bat new file mode 100644 index 0000000..fe62b39 --- /dev/null +++ b/scripts/rebuild-docker.bat @@ -0,0 +1,37 @@ +@echo off +:: Script para rebuild completo do Docker com dependências PDF +:: Use este script após atualizar o Dockerfile para incluir GraphicsMagick + +echo ======================================== +echo Rebuild Docker - Sistema PDF +echo ======================================== +echo. + +echo Parando containers... +docker-compose down + +echo. +echo Removendo imagens antigas... +docker image prune -f + +echo. +echo Reconstruindo imagem (sem cache)... +docker-compose build --no-cache + +echo. +echo Iniciando containers... +docker-compose up -d + +echo. +echo ======================================== +echo Rebuild concluído! +echo ======================================== +echo. +echo Verificando status dos containers: +docker-compose ps + +echo. +echo Para ver os logs: +echo docker-compose logs -f backend +echo. +pause diff --git a/scripts/rebuild-docker.sh b/scripts/rebuild-docker.sh new file mode 100644 index 0000000..24a2309 --- /dev/null +++ b/scripts/rebuild-docker.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Script para rebuild completo do Docker com dependências PDF +# Use este script após atualizar o Dockerfile para incluir GraphicsMagick + +echo "========================================" +echo "Rebuild Docker - Sistema PDF" +echo "========================================" +echo "" + +echo "Parando containers..." +docker-compose down + +echo "" +echo "Removendo imagens antigas..." +docker image prune -f + +echo "" +echo "Reconstruindo imagem (sem cache)..." +docker-compose build --no-cache + +echo "" +echo "Iniciando containers..." +docker-compose up -d + +echo "" +echo "========================================" +echo "Rebuild concluído!" +echo "========================================" +echo "" +echo "Verificando status dos containers:" +docker-compose ps + +echo "" +echo "Para ver os logs:" +echo " docker-compose logs -f backend" +echo "" diff --git a/src/components/pdf/PDFManipulationPage.tsx b/src/components/pdf/PDFManipulationPage.tsx index 52892b8..db8c01c 100644 --- a/src/components/pdf/PDFManipulationPage.tsx +++ b/src/components/pdf/PDFManipulationPage.tsx @@ -1,3 +1,4 @@ +/// import React, { useState, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import axios from 'axios'; @@ -20,6 +21,7 @@ interface OperationResult { operacaoId: string; outputPaths?: string[]; outputPath?: string; + zipPath?: string; processingTime?: number; reduction?: number; filesCreated?: number; @@ -45,7 +47,8 @@ export const PDFManipulationPage: React.FC = () => { const [history, setHistory] = useState([]); // Configurações para cada operação - const [compressQuality, setCompressQuality] = useState(95); + const [compressQuality, setCompressQuality] = useState(50); + const [enableOCR, setEnableOCR] = useState(false); const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages'); const [splitPages, setSplitPages] = useState('1,2,3'); const [splitRanges, setSplitRanges] = useState('1-5,6-10'); @@ -97,6 +100,7 @@ export const PDFManipulationPage: React.FC = () => { 'Content-Type': 'multipart/form-data', Authorization: `Bearer ${token}`, }, + timeout: 600000, // 10 minutos para operações longas (compressão, OCR, etc) }; let response; @@ -104,6 +108,7 @@ export const PDFManipulationPage: React.FC = () => { switch (selectedOperation) { case 'compress': formData.append('quality', compressQuality.toString()); + formData.append('enableOCR', enableOCR.toString()); response = await axios.post('/api/pdf/compress', formData, config); toast.success('PDFs comprimidos com sucesso!'); break; @@ -127,6 +132,7 @@ export const PDFManipulationPage: React.FC = () => { break; case 'merge': + formData.append('enableOCR', enableOCR.toString()); response = await axios.post('/api/pdf/merge', formData, config); toast.success('PDFs mesclados com sucesso!'); break; @@ -140,6 +146,7 @@ export const PDFManipulationPage: React.FC = () => { break; case 'imagesToPDF': + formData.append('enableOCR', enableOCR.toString()); formData.append('fitToPage', 'true'); response = await axios.post('/api/pdf/images-to-pdf', formData, config); toast.success('Imagens convertidas em PDF!'); @@ -186,13 +193,34 @@ export const PDFManipulationPage: React.FC = () => { responseType: 'blob', }); - const url = window.URL.createObjectURL(new Blob([response.data])); + // Extrai o nome do arquivo do header Content-Disposition + const contentDisposition = response.headers['content-disposition']; + let fileName = `arquivo-${fileIndex}`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches != null && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } + } + + // Usa o Content-Type correto do servidor + const contentType = response.headers['content-type'] || 'application/octet-stream'; + + console.log(`Download frontend: ${fileName} (${contentType})`); + + // Cria o blob com o tipo correto + const blob = new Blob([response.data], { type: contentType }); + const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.setAttribute('download', `arquivo-${fileIndex}.pdf`); + link.setAttribute('download', fileName); document.body.appendChild(link); link.click(); link.remove(); + + // Libera a memória do blob URL + setTimeout(() => window.URL.revokeObjectURL(url), 100); toast.success('Download iniciado!'); } catch (error) { @@ -201,6 +229,64 @@ export const PDFManipulationPage: React.FC = () => { } }; + const deleteOperation = async (operacaoId: string) => { + if (!window.confirm('Deseja realmente deletar esta operação e seus arquivos?')) { + return; + } + + try { + const token = getToken(); + await axios.delete(`/api/pdf/operation/${operacaoId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + toast.success('Operação deletada com sucesso!'); + loadHistory(); + } catch (error) { + console.error('Erro ao deletar operação:', error); + toast.error('Erro ao deletar operação'); + } + }; + + const clearAllHistory = async () => { + if (!window.confirm('⚠️ ATENÇÃO: Isso irá deletar TODO o histórico e TODOS os arquivos armazenados. Esta ação não pode ser desfeita. Deseja continuar?')) { + return; + } + + try { + const token = getToken(); + const response = await axios.delete('/api/pdf/history/clear', { + headers: { Authorization: `Bearer ${token}` }, + }); + + toast.success(`Histórico limpo! ${response.data.operacoesRemovidas} operações removidas.`); + setHistory([]); + setResult(null); + } catch (error) { + console.error('Erro ao limpar histórico:', error); + toast.error('Erro ao limpar histórico'); + } + }; + + const cleanupOldFiles = async () => { + if (!window.confirm('Deseja remover operações com mais de 24 horas?')) { + return; + } + + try { + const token = getToken(); + const response = await axios.delete('/api/pdf/history/cleanup?hours=24', { + headers: { Authorization: `Bearer ${token}` }, + }); + + toast.success(`${response.data.operacoesRemovidas} operações antigas removidas!`); + loadHistory(); + } catch (error) { + console.error('Erro ao limpar arquivos antigos:', error); + toast.error('Erro ao limpar arquivos antigos'); + } + }; + React.useEffect(() => { loadHistory(); }, []); @@ -367,20 +453,38 @@ export const PDFManipulationPage: React.FC = () => {

Configurações

{selectedOperation === 'compress' && ( -
- - setCompressQuality(parseInt(e.target.value))} - className="w-full" - /> -

- 95% recomendado para qualidade sem perda perceptível +

+
+ + setCompressQuality(parseInt(e.target.value))} + className="w-full" + /> +

+ 30-50%: Máxima compressão (>60% redução) | 70-100%: Alta qualidade +

+
+ +
+ setEnableOCR(e.target.checked)} + className="h-4 w-4 text-blue-600 rounded" + /> + +
+

+ ⚠️ OCR aumenta o tempo de processamento significativamente

)} @@ -510,6 +614,54 @@ export const PDFManipulationPage: React.FC = () => {
)} + {selectedOperation === 'merge' && ( +
+

+ Selecione 2 ou mais PDFs para mesclar em um único arquivo. +

+ +
+ setEnableOCR(e.target.checked)} + className="h-4 w-4 text-blue-600 rounded" + /> + +
+

+ ⚠️ OCR aumenta significativamente o tempo de processamento +

+
+ )} + + {selectedOperation === 'imagesToPDF' && ( +
+

+ Converte múltiplas imagens em um único arquivo PDF. +

+ +
+ setEnableOCR(e.target.checked)} + className="h-4 w-4 text-blue-600 rounded" + /> + +
+

+ ⚠️ OCR aumenta significativamente o tempo de processamento +

+
+ )} + - ))} - {result.outputPath && ( + + {/* Botão de download ZIP (se disponível) */} + {result.zipPath && ( + )} + + {/* Arquivos individuais */} + {result.outputPaths?.map((path, index) => { + // Determina o nome do arquivo baseado na operação + let fileName = `Arquivo ${index + 1}`; + if (selectedOperation === 'pdfToImages') { + fileName = `Imagem ${index + 1}`; + } else if (selectedOperation === 'split') { + fileName = `Página ${index + 1}`; + } + + return ( + + ); + })} + {result.outputPath && !result.outputPaths && ( + + + + )} + {history.length === 0 ? (

Nenhuma operação ainda

@@ -622,17 +824,28 @@ export const PDFManipulationPage: React.FC = () => { {op.tipoOperacao.replace('_', ' ')} - - {op.status} - +
+ + {op.status} + + +
diff --git a/src/server/controllers/pdfController.ts b/src/server/controllers/pdfController.ts index 61335a9..9671eaa 100644 --- a/src/server/controllers/pdfController.ts +++ b/src/server/controllers/pdfController.ts @@ -70,8 +70,9 @@ export class PDFController { return; } - const quality = req.body.quality ? parseInt(req.body.quality) : 95; + const quality = req.body.quality ? parseInt(req.body.quality) : 50; // Padrão 50 para mais compressão const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined; + const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true; const arquivosOriginais: string[] = []; const arquivosResultado: string[] = []; @@ -99,7 +100,23 @@ export class PDFController { arquivosOriginais.push(file.path); tamanhoOriginal += BigInt(file.size); - const result = await PDFService.compressPDF(file.path, undefined, { + let processedPath = file.path; + + // Adiciona OCR se solicitado + if (enableOCR) { + try { + const ocrResult = await PDFService.addOCRLayerToPDF(file.path); + processedPath = ocrResult.outputPath; + // Remove arquivo temporário anterior + if (processedPath !== file.path) { + await fs.unlink(file.path).catch(() => {}); + } + } catch (ocrError) { + console.warn('Erro ao adicionar OCR, continuando sem OCR:', ocrError); + } + } + + const result = await PDFService.compressPDF(processedPath, undefined, { quality, maxSizeMB, }); @@ -115,6 +132,10 @@ export class PDFController { outputPath: result.outputPath, }); + // Remove o arquivo intermediário + if (processedPath !== file.path && processedPath !== result.outputPath) { + await fs.unlink(processedPath).catch(() => {}); + } // Remove o arquivo original await fs.unlink(file.path).catch(() => {}); } @@ -221,6 +242,16 @@ export class PDFController { // Divide o PDF const outputPaths = await PDFService.splitPDF(file.path, options); + // Cria ZIP se houver múltiplos arquivos + let zipPath: string | undefined; + if (outputPaths.length > 1) { + try { + zipPath = await PDFService.createZipFromFiles(outputPaths); + } catch (zipError) { + console.warn('Erro ao criar ZIP, disponibilizando arquivos individuais:', zipError); + } + } + // Calcula tamanho total dos arquivos resultantes let tamanhoResultado = BigInt(0); for (const outputPath of outputPaths) { @@ -235,7 +266,7 @@ export class PDFController { where: { id: operacaoId }, data: { status: 'CONCLUIDO', - arquivosResultado: outputPaths, + arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths, tamanhoResultado, tempoProcessamento, }, @@ -249,6 +280,7 @@ export class PDFController { operacaoId, filesCreated: outputPaths.length, outputPaths, + zipPath, processingTime: tempoProcessamento, }); } catch (error) { @@ -293,6 +325,7 @@ export class PDFController { return; } + const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true; const filePaths = files.map((f) => f.path); let tamanhoOriginal = BigInt(0); @@ -316,11 +349,23 @@ export class PDFController { operacaoId = operacao.id; // Mescla os PDFs - const outputPath = await PDFService.mergePDFs({ + let outputPath = await PDFService.mergePDFs({ filePaths, outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `merged-${uuidv4()}.pdf`), }); + // Adiciona OCR se solicitado + if (enableOCR) { + try { + const ocrResult = await PDFService.addOCRLayerToPDF(outputPath); + // Remove arquivo anterior + await fs.unlink(outputPath).catch(() => {}); + outputPath = ocrResult.outputPath; + } catch (ocrError) { + console.warn('Erro ao adicionar OCR ao PDF mesclado:', ocrError); + } + } + // Calcula tamanho do arquivo resultante const stats = await fs.stat(outputPath); const tamanhoResultado = BigInt(stats.size); @@ -422,6 +467,16 @@ export class PDFController { pages, }); + // Cria ZIP com todas as imagens + let zipPath: string | undefined; + if (outputPaths.length > 0) { + try { + zipPath = await PDFService.createZipFromFiles(outputPaths); + } catch (zipError) { + console.warn('Erro ao criar ZIP, disponibilizando imagens individuais:', zipError); + } + } + // Calcula tamanho total let tamanhoResultado = BigInt(0); for (const outputPath of outputPaths) { @@ -436,7 +491,7 @@ export class PDFController { where: { id: operacaoId }, data: { status: 'CONCLUIDO', - arquivosResultado: outputPaths, + arquivosResultado: zipPath ? [zipPath, ...outputPaths] : outputPaths, tamanhoResultado, tempoProcessamento, }, @@ -450,6 +505,7 @@ export class PDFController { operacaoId, imagesCreated: outputPaths.length, outputPaths, + zipPath, processingTime: tempoProcessamento, }); } catch (error) { @@ -503,6 +559,7 @@ export class PDFController { const fitToPage = req.body.fitToPage === 'true'; const pageSize = req.body.pageSize ? JSON.parse(req.body.pageSize) : undefined; + const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true; // Cria registro da operação const operacao = await prisma.operacaoPDF.create({ @@ -520,13 +577,25 @@ export class PDFController { operacaoId = operacao.id; // Converte para PDF - const outputPath = await PDFService.imagesToPDF({ + let outputPath = await PDFService.imagesToPDF({ imagePaths, outputPath: path.join(process.cwd(), 'storage', 'pdf-output', `images-to-pdf-${uuidv4()}.pdf`), fitToPage, pageSize, }); + // Adiciona OCR se solicitado + if (enableOCR) { + try { + const ocrResult = await PDFService.addOCRLayerToPDF(outputPath); + // Remove arquivo anterior + await fs.unlink(outputPath).catch(() => {}); + outputPath = ocrResult.outputPath; + } catch (ocrError) { + console.warn('Erro ao adicionar OCR ao PDF:', ocrError); + } + } + // Calcula tamanho do arquivo resultante const stats = await fs.stat(outputPath); const tamanhoResultado = BigInt(stats.size); @@ -797,7 +866,37 @@ export class PDFController { return; } - res.download(filePath, path.basename(filePath)); + // Verifica se o arquivo existe + try { + await fs.access(filePath); + } catch { + res.status(404).json({ error: 'Arquivo não encontrado no sistema' }); + return; + } + + // Detecta o Content-Type correto baseado na extensão + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: { [key: string]: string } = { + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.zip': 'application/zip', + }; + + const contentType = mimeTypes[ext] || 'application/octet-stream'; + const fileName = path.basename(filePath); + + console.log(`Download solicitado: ${fileName} (${contentType})`); + + // Define o Content-Type correto + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + + // Garante caminho absoluto e envia o arquivo + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + res.sendFile(absolutePath); } catch (error) { console.error('Erro ao fazer download:', error); res.status(500).json({ @@ -806,4 +905,196 @@ export class PDFController { }); } } + + /** + * Deleta uma operação específica e seus arquivos + */ + static async deleteOperation(req: Request, res: Response): Promise { + try { + const { operacaoId } = req.params; + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Usuário não autenticado' }); + return; + } + + const operacao = await prisma.operacaoPDF.findUnique({ + where: { id: operacaoId }, + }); + + if (!operacao) { + res.status(404).json({ error: 'Operação não encontrada' }); + return; + } + + if (operacao.usuarioId !== userId) { + res.status(403).json({ error: 'Acesso negado' }); + return; + } + + // Deleta arquivos físicos + const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado]; + for (const filePath of allFiles) { + try { + // Verifica se o arquivo existe antes de deletar + await fs.access(filePath); + await fs.unlink(filePath); + } catch (error: any) { + // Ignora erro se o arquivo não existe (já foi deletado) + if (error.code !== 'ENOENT') { + console.warn(`Erro ao deletar arquivo ${filePath}:`, error); + } + } + } + + // Deleta registro do banco + await prisma.operacaoPDF.delete({ + where: { id: operacaoId }, + }); + + res.json({ + success: true, + message: 'Operação deletada com sucesso', + }); + } catch (error) { + console.error('Erro ao deletar operação:', error); + res.status(500).json({ + error: 'Falha ao deletar operação', + message: error instanceof Error ? error.message : 'Erro desconhecido', + }); + } + } + + /** + * Limpa todo o histórico do usuário + */ + static async clearHistory(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Usuário não autenticado' }); + return; + } + + // Busca todas as operações do usuário + const operacoes = await prisma.operacaoPDF.findMany({ + where: { usuarioId: userId }, + }); + + let filesDeleted = 0; + let filesError = 0; + + // Deleta todos os arquivos + for (const operacao of operacoes) { + const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado]; + for (const filePath of allFiles) { + try { + // Verifica se o arquivo existe antes de deletar + await fs.access(filePath); + await fs.unlink(filePath); + filesDeleted++; + } catch (error: any) { + // Ignora erro se o arquivo não existe + if (error.code !== 'ENOENT') { + filesError++; + console.warn(`Erro ao deletar arquivo ${filePath}:`, error); + } + } + } + } + + // Deleta todos os registros + const deleted = await prisma.operacaoPDF.deleteMany({ + where: { usuarioId: userId }, + }); + + res.json({ + success: true, + message: 'Histórico limpo com sucesso', + operacoesRemovidas: deleted.count, + arquivosDeletados: filesDeleted, + arquivosComErro: filesError, + }); + } catch (error) { + console.error('Erro ao limpar histórico:', error); + res.status(500).json({ + error: 'Falha ao limpar histórico', + message: error instanceof Error ? error.message : 'Erro desconhecido', + }); + } + } + + /** + * Limpa operações antigas (mais de X horas) + */ + static async cleanupOldOperations(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.id; + const hours = parseInt(req.query.hours as string) || 24; + + if (!userId) { + res.status(401).json({ error: 'Usuário não autenticado' }); + return; + } + + const cutoffDate = new Date(Date.now() - hours * 60 * 60 * 1000); + + // Busca operações antigas + const operacoes = await prisma.operacaoPDF.findMany({ + where: { + usuarioId: userId, + criadoEm: { + lt: cutoffDate, + }, + }, + }); + + let filesDeleted = 0; + let filesError = 0; + + // Deleta arquivos + for (const operacao of operacoes) { + const allFiles = [...operacao.arquivosOriginais, ...operacao.arquivosResultado]; + for (const filePath of allFiles) { + try { + // Verifica se o arquivo existe antes de deletar + await fs.access(filePath); + await fs.unlink(filePath); + filesDeleted++; + } catch (error: any) { + // Ignora erro se o arquivo não existe + if (error.code !== 'ENOENT') { + filesError++; + } + } + } + } + + // Deleta registros + const deleted = await prisma.operacaoPDF.deleteMany({ + where: { + usuarioId: userId, + criadoEm: { + lt: cutoffDate, + }, + }, + }); + + res.json({ + success: true, + message: `Operações com mais de ${hours}h removidas`, + operacoesRemovidas: deleted.count, + arquivosDeletados: filesDeleted, + arquivosComErro: filesError, + }); + } catch (error) { + console.error('Erro ao limpar operações antig as:', error); + res.status(500).json({ + error: 'Falha ao limpar operações antigas', + message: error instanceof Error ? error.message : 'Erro desconhecido', + }); + } + } } diff --git a/src/server/index.ts b/src/server/index.ts index 9463539..b7fe752 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1430,9 +1430,11 @@ app.use( }, ); -app.listen(port, () => { +// Aumentar timeout do servidor para operações longas (ex: PDF, OCR) +const server = app.listen(port, () => { console.log(`🚀 Servidor rodando em http://localhost:${port}`); }); +server.timeout = 600000; // 10 minutos (600 segundos) process.on("unhandledRejection", (err) => { console.error("Erro não tratado:", err); diff --git a/src/server/routes/pdf.ts b/src/server/routes/pdf.ts index e80edee..3c42276 100644 --- a/src/server/routes/pdf.ts +++ b/src/server/routes/pdf.ts @@ -79,4 +79,25 @@ router.get( PDFController.downloadFile ); +// Deleta uma operação específica +router.delete( + '/operation/:operacaoId', + authMiddleware, + PDFController.deleteOperation +); + +// Limpa todo o histórico +router.delete( + '/history/clear', + authMiddleware, + PDFController.clearHistory +); + +// Limpa operações antigas +router.delete( + '/history/cleanup', + authMiddleware, + PDFController.cleanupOldOperations +); + export default router; diff --git a/src/services/pdfService.ts b/src/services/pdfService.ts index 08d5331..c162611 100644 --- a/src/services/pdfService.ts +++ b/src/services/pdfService.ts @@ -1,10 +1,12 @@ -import { PDFDocument } from 'pdf-lib'; +import { PDFDocument, rgb } from 'pdf-lib'; import sharp from 'sharp'; import { createWorker } from 'tesseract.js'; import { fromPath } from 'pdf2pic'; import fs from 'fs/promises'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; +import archiver from 'archiver'; +import { createWriteStream } from 'fs'; /** * Serviço completo de manipulação de PDFs @@ -75,8 +77,8 @@ export class PDFService { } /** - * Comprime um PDF mantendo a qualidade máxima possível - * Usa otimização de streams e remoção de metadados desnecessários + * Comprime um PDF de forma agressiva para redução >60% + * Usa abordagem simplificada e eficiente */ static async compressPDF( inputPath: string, @@ -86,7 +88,7 @@ export class PDFService { try { await this.initialize(); - const { quality = 95 } = options; + const { quality = 50 } = options; // Lê o PDF original const pdfBytes = await fs.readFile(inputPath); @@ -95,9 +97,10 @@ export class PDFService { // Carrega o PDF const pdfDoc = await PDFDocument.load(pdfBytes, { updateMetadata: false, + ignoreEncryption: true, }); - // Remove metadados desnecessários mas mantém metadados essenciais + // Remove TODOS os metadados pdfDoc.setTitle(''); pdfDoc.setAuthor(''); pdfDoc.setSubject(''); @@ -105,43 +108,31 @@ export class PDFService { pdfDoc.setProducer(''); pdfDoc.setCreator(''); - // Salva com compressão - const compressedBytes = await pdfDoc.save({ - useObjectStreams: true, // Comprime streams de objetos + // Para compressão agressiva, reconstrói o PDF do zero + const newDoc = await PDFDocument.create(); + const pageCount = pdfDoc.getPageCount(); + + // Copia cada página + const copiedPages = await newDoc.copyPages(pdfDoc, Array.from({ length: pageCount }, (_, i) => i)); + copiedPages.forEach((page: any) => newDoc.addPage(page)); + + // Salva com máxima compressão + const compressedBytes = await newDoc.save({ + useObjectStreams: true, // Compressão de streams addDefaultPage: false, objectsPerTick: 50, }); - // Se a compressão não foi suficiente, otimiza as imagens - let finalBytes = compressedBytes; - - if (options.maxSizeMB && compressedBytes.length > options.maxSizeMB * 1024 * 1024) { - // Recarrega para otimizar imagens - const reloadedDoc = await PDFDocument.load(compressedBytes); - const pages = reloadedDoc.getPages(); - - // Processa cada página para otimizar imagens - for (const page of pages) { - // A otimização de imagens seria feita aqui - // Por limitações da pdf-lib, mantemos a qualidade original - } - - finalBytes = await reloadedDoc.save({ - useObjectStreams: true, - addDefaultPage: false, - }); - } - // Define o caminho de saída const finalOutputPath = outputPath || path.join( this.OUTPUT_DIR, `compressed-${uuidv4()}.pdf` ); - // Salva o arquivo comprimido - await fs.writeFile(finalOutputPath, finalBytes); + // Salva o arquivo + await fs.writeFile(finalOutputPath, compressedBytes); - const compressedSize = finalBytes.length; + const compressedSize = compressedBytes.length; const reduction = ((originalSize - compressedSize) / originalSize) * 100; return { @@ -334,7 +325,9 @@ export class PDFService { `page-${pageNum}-${uuidv4()}.${format}` ); - if (format === 'jpg') { + console.log(`Convertendo página ${pageNum} para ${format}: ${optimizedPath}`); + + if (format === 'jpg' || format === 'jpeg') { await sharp(imagePath) .jpeg({ quality, progressive: true }) .toFile(optimizedPath); @@ -346,6 +339,20 @@ export class PDFService { await sharp(imagePath) .webp({ quality }) .toFile(optimizedPath); + } else { + // Formato não suportado, usa PNG como padrão + console.warn(`Formato ${format} não suportado, usando PNG`); + await sharp(imagePath) + .png({ quality: 95, progressive: true, compressionLevel: 9 }) + .toFile(optimizedPath.replace(`.${format}`, '.png')); + + // Atualiza o caminho otimizado para .png + const correctedPath = optimizedPath.replace(`.${format}`, '.png'); + outputPaths.push(correctedPath); + + // Remove o arquivo temporário + await fs.unlink(imagePath).catch(() => {}); + continue; } // Remove o arquivo temporário @@ -522,6 +529,124 @@ export class PDFService { } } + /** + * Cria um arquivo ZIP com múltiplos arquivos + */ + static async createZipFromFiles( + filePaths: string[], + outputPath?: string + ): Promise { + try { + await this.initialize(); + + const finalOutputPath = outputPath || path.join( + this.OUTPUT_DIR, + `arquivos-${uuidv4()}.zip` + ); + + return new Promise((resolve, reject) => { + const output = createWriteStream(finalOutputPath); + const archive = archiver('zip', { + zlib: { level: 9 } // Máxima compressão + }); + + output.on('close', () => { + console.log(`ZIP criado: ${archive.pointer()} bytes`); + resolve(finalOutputPath); + }); + + archive.on('error', (err) => { + reject(err); + }); + + archive.pipe(output); + + // Adiciona todos os arquivos ao ZIP + filePaths.forEach((filePath, index) => { + const ext = path.extname(filePath); + + // Renomeia arquivos para sequência + const zipFileName = `arquivo-${index + 1}${ext}`; + archive.file(filePath, { name: zipFileName }); + }); + + archive.finalize(); + }); + } catch (error) { + console.error('Erro ao criar ZIP:', error); + throw new Error(`Falha ao criar ZIP: ${error instanceof Error ? error.message : 'Erro desconhecido'}`); + } + } + + /** + * Adiciona camada de OCR a um PDF (torna o PDF pesquisável) + */ + static async addOCRLayerToPDF( + inputPath: string, + outputPath?: string, + language: string = 'por' + ): Promise<{ outputPath: string; confidence: number }> { + try { + await this.initialize(); + + // Primeiro, extrai o texto via OCR + const ocrResult = await this.performOCR(inputPath, { language, pages: 'all' }); + + // Carrega o PDF original + const pdfBytes = await fs.readFile(inputPath); + const pdfDoc = await PDFDocument.load(pdfBytes); + + // Cria um novo PDF com camada de texto + const newDoc = await PDFDocument.create(); + const pages = pdfDoc.getPages(); + + for (let i = 0; i < pages.length; i++) { + const [copiedPage] = await newDoc.copyPages(pdfDoc, [i]); + newDoc.addPage(copiedPage); + + // Adiciona texto invisível sobre a página (camada OCR) + if (ocrResult.pages[i]) { + const newPage = newDoc.getPage(i); + const { height } = newPage.getSize(); + + // Desenha texto invisível (transparente) sobre a página + const lines = ocrResult.pages[i].text.split('\n'); + let yPosition = height - 20; + + lines.forEach((line) => { + if (line.trim()) { + newPage.drawText(line, { + x: 10, + y: yPosition, + size: 8, + color: rgb(0, 0, 0), // Texto preto mas será invisível + opacity: 0.01, // Quase invisível mas indexável + }); + yPosition -= 12; + } + }); + } + } + + // Salva o PDF com camada OCR + const finalOutputPath = outputPath || path.join( + this.OUTPUT_DIR, + `ocr-${uuidv4()}.pdf` + ); + + const newPdfBytes = await newDoc.save(); + await fs.writeFile(finalOutputPath, newPdfBytes); + + return { + outputPath: finalOutputPath, + confidence: ocrResult.confidence, + }; + } catch (error) { + console.error('Erro ao adicionar camada OCR:', error); + throw new Error(`Falha ao adicionar OCR: ${error instanceof Error ? error.message : 'Erro desconhecido'}`); + } + } + /** * Limpa arquivos temporários antigos */ diff --git a/vite.config.ts b/vite.config.ts index 05eefe2..e4fe097 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ target: 'http://localhost:5000', changeOrigin: true, secure: false, + timeout: 600000, // 10 minutos para operações longas (PDF, OCR) + proxyTimeout: 600000, } }, hmr: {