diff --git a/src/components/pdf/PDFManipulationPage.tsx b/src/components/pdf/PDFManipulationPage.tsx index db8c01c..8b2fa84 100644 --- a/src/components/pdf/PDFManipulationPage.tsx +++ b/src/components/pdf/PDFManipulationPage.tsx @@ -47,7 +47,7 @@ export const PDFManipulationPage: React.FC = () => { const [history, setHistory] = useState([]); // Configurações para cada operação - const [compressQuality, setCompressQuality] = useState(50); + const [compressQuality, setCompressQuality] = useState(85); const [enableOCR, setEnableOCR] = useState(false); const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages'); const [splitPages, setSplitPages] = useState('1,2,3'); @@ -460,14 +460,14 @@ export const PDFManipulationPage: React.FC = () => { setCompressQuality(parseInt(e.target.value))} className="w-full" />

- 30-50%: Máxima compressão (>60% redução) | 70-100%: Alta qualidade + 50-70%: Máxima compressão | 80-90%: Equilíbrio ideal | 90-100%: Alta fidelidade

@@ -484,7 +484,7 @@ export const PDFManipulationPage: React.FC = () => {

- ⚠️ OCR aumenta o tempo de processamento significativamente + OCR torna o PDF pesquisável. É instantâneo para PDFs digitais.

)} diff --git a/src/server/controllers/pdfController.ts b/src/server/controllers/pdfController.ts index 9671eaa..09e31d0 100644 --- a/src/server/controllers/pdfController.ts +++ b/src/server/controllers/pdfController.ts @@ -70,7 +70,7 @@ export class PDFController { return; } - const quality = req.body.quality ? parseInt(req.body.quality) : 50; // Padrão 50 para mais compressão + const quality = req.body.quality ? parseInt(req.body.quality) : 85; // Padrão 85 para boa qualidade com compressão agressiva const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined; const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true; @@ -100,25 +100,12 @@ export class PDFController { arquivosOriginais.push(file.path); tamanhoOriginal += BigInt(file.size); - 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, { + // Comprime com OCR integrado (evita dupla conversão de imagens) + const result = await PDFService.compressPDF(file.path, undefined, { quality, maxSizeMB, + enableOCR, + ocrLanguage: 'por', }); arquivosResultado.push(result.outputPath); @@ -132,10 +119,6 @@ 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(() => {}); } diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index 18da12b..42337a3 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -35,7 +35,7 @@ export class AuthService { email: usuario.email, }, process.env.JWT_SECRET || "default-secret", - { expiresIn: "1d" }, + { expiresIn: "7d" }, // 7 dias para melhor UX ); return { diff --git a/src/services/pdfService.ts b/src/services/pdfService.ts index cb4679a..852cabf 100644 --- a/src/services/pdfService.ts +++ b/src/services/pdfService.ts @@ -15,8 +15,10 @@ import { createWriteStream } from 'fs'; */ interface CompressOptions { - quality?: number; // 0-100, padrão 95 para qualidade alta + quality?: number; // 0-100, padrão 85 para boa qualidade com compressão agressiva maxSizeMB?: number; + enableOCR?: boolean; // Integra OCR durante a compressão (evita dupla conversão) + ocrLanguage?: string; // Idioma do OCR, padrão 'por' } interface SplitOptions { @@ -184,51 +186,232 @@ export class PDFService { } /** - * Comprime um PDF de forma agressiva para redução >60% - * Usa abordagem simplificada e eficiente + * Comprime um PDF usando estratégia inteligente: + * - PDFs vetoriais/texto: usa compressão de streams (não aumenta tamanho) + * - PDFs escaneados/imagens: converte para JPEG comprimido (redução significativa) + * Detecta automaticamente o tipo de PDF baseado na proporção tamanho/páginas e presença de texto. */ static async compressPDF( inputPath: string, outputPath?: string, options: CompressOptions = {} ): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> { + const tempFiles: string[] = []; + try { await this.initialize(); + const startTime = Date.now(); - const { quality = 50 } = options; + const { + quality = 85, + enableOCR = false, + ocrLanguage = 'por', + } = options; // Lê o PDF original const pdfBytes = await fs.readFile(inputPath); const originalSize = pdfBytes.length; - // Carrega o PDF const pdfDoc = await PDFDocument.load(pdfBytes, { - updateMetadata: false, ignoreEncryption: true, }); + const totalPages = pdfDoc.getPageCount(); - // Remove TODOS os metadados - pdfDoc.setTitle(''); - pdfDoc.setAuthor(''); - pdfDoc.setSubject(''); - pdfDoc.setKeywords([]); - pdfDoc.setProducer(''); - pdfDoc.setCreator(''); + // ========== DETECÇÃO DO TIPO DE PDF ========== + // Calcula tamanho médio por página + const avgSizePerPage = originalSize / totalPages; + const isLikelyScanned = avgSizePerPage > 150 * 1024; // > 150KB por página = provavelmente escaneado - // Para compressão agressiva, reconstrói o PDF do zero - const newDoc = await PDFDocument.create(); - const pageCount = pdfDoc.getPageCount(); + // Verifica se tem texto embarcado + let hasEmbeddedText = false; + let pageTexts: string[] = []; - // Copia cada página - const copiedPages = await newDoc.copyPages(pdfDoc, Array.from({ length: pageCount }, (_, i) => i)); - copiedPages.forEach((page: any) => newDoc.addPage(page)); + try { + const pdfData = await pdfParse(pdfBytes); + const fullText = pdfData.text || ''; + const textPerPage = fullText.trim().length / totalPages; + hasEmbeddedText = textPerPage > 100; // > 100 caracteres por página = tem texto real - // Salva com máxima compressão - const compressedBytes = await newDoc.save({ - useObjectStreams: true, // Compressão de streams - addDefaultPage: false, - objectsPerTick: 50, - }); + if (hasEmbeddedText && enableOCR) { + const textParts = fullText.split(/\f/); + if (textParts.length >= totalPages) { + pageTexts = textParts.slice(0, totalPages); + } else { + const lines = fullText.split('\n'); + const linesPerPage = Math.ceil(lines.length / totalPages); + for (let i = 0; i < totalPages; i++) { + const start = i * linesPerPage; + pageTexts.push(lines.slice(start, start + linesPerPage).join('\n')); + } + } + } + } catch (parseError) { + console.warn(`[Compress] pdf-parse falhou:`, parseError); + } + + // Decide a estratégia + const useImageCompression = isLikelyScanned && !hasEmbeddedText; + + console.log(`[Compress] Iniciando compressão de ${totalPages} páginas (quality=${quality}, OCR=${enableOCR})`); + console.log(`[Compress] Detecção: ${(avgSizePerPage / 1024).toFixed(0)}KB/página, texto=${hasEmbeddedText ? 'sim' : 'não'}, estratégia=${useImageCompression ? 'IMAGEM' : 'STREAMS'}`); + + let compressedBytes: Uint8Array; + + if (useImageCompression) { + // ========== ESTRATÉGIA IMAGEM: Para PDFs escaneados ========== + console.log(`[Compress] Usando compressão por imagens (PDF escaneado detectado)`); + + const density = quality >= 90 ? 200 : quality >= 70 ? 150 : 120; + const converter = fromPath(inputPath, { + density, + saveFilename: `compress-${uuidv4()}`, + savePath: this.TEMP_DIR, + format: 'png', + width: Math.round(2480 * density / 300), + height: Math.round(3508 * density / 300), + }); + + const compressedImagePaths: string[] = []; + + // OCR via Tesseract para PDFs escaneados + let tesseractWorker: Worker | null = null; + if (enableOCR) { + tesseractWorker = await this.getWorker(ocrLanguage); + console.log(`[Compress] ⚠️ Usando Tesseract para OCR durante compressão`); + } + + for (let pageNum = 1; pageNum <= totalPages; pageNum++) { + try { + const result = await converter(pageNum, { responseType: 'image' }); + + if (!result || typeof result !== 'object' || !('path' in result)) { + console.error(`[Compress] Falha ao converter página ${pageNum}`); + continue; + } + + const rawImagePath = result.path as string; + tempFiles.push(rawImagePath); + + // OCR se habilitado + if (enableOCR && tesseractWorker) { + try { + const ocrImagePath = path.join(this.TEMP_DIR, `ocr-tmp-${pageNum}-${uuidv4()}.png`); + await sharp(rawImagePath) + .grayscale() + .normalize() + .png({ compressionLevel: 1 }) + .toFile(ocrImagePath); + tempFiles.push(ocrImagePath); + + const { data } = await tesseractWorker.recognize(ocrImagePath); + pageTexts[pageNum - 1] = data.text; + + await fs.unlink(ocrImagePath).catch(() => {}); + } catch (ocrError) { + console.error(`[Compress] Erro OCR página ${pageNum}:`, ocrError); + pageTexts[pageNum - 1] = ''; + } + } + + // Comprimir imagem como JPEG + const jpegPath = path.join(this.TEMP_DIR, `compress-page-${pageNum}-${uuidv4()}.jpg`); + tempFiles.push(jpegPath); + + await sharp(rawImagePath) + .jpeg({ + quality: Math.min(quality, 80), // Limita a 80 para escaneados + progressive: true, + mozjpeg: true, + chromaSubsampling: '4:2:0', + }) + .toFile(jpegPath); + + compressedImagePaths.push(jpegPath); + await fs.unlink(rawImagePath).catch(() => {}); + + console.log(`[Compress] Página ${pageNum}/${totalPages} convertida`); + } catch (pageError) { + console.error(`[Compress] Erro ao processar página ${pageNum}:`, pageError); + } + } + + if (compressedImagePaths.length === 0) { + throw new Error('Nenhuma página foi convertida com sucesso'); + } + + // Monta novo PDF + const newDoc = await PDFDocument.create(); + + for (let i = 0; i < compressedImagePaths.length; i++) { + const imgBytes = await fs.readFile(compressedImagePaths[i]); + const image = await newDoc.embedJpg(imgBytes); + + const origPage = pdfDoc.getPage(i); + const { width: pageWidth, height: pageHeight } = origPage.getSize(); + + const page = newDoc.addPage([pageWidth, pageHeight]); + page.drawImage(image, { x: 0, y: 0, width: pageWidth, height: pageHeight }); + + // Adiciona OCR invisível + const text = pageTexts[i] || ''; + if (text.trim()) { + const lines = text.split('\n'); + let yPosition = pageHeight - 20; + lines.forEach((line: string) => { + if (line.trim() && yPosition > 10) { + page.drawText(line, { x: 10, y: yPosition, size: 8, color: rgb(0, 0, 0), opacity: 0.01 }); + yPosition -= 12; + } + }); + } + + await fs.unlink(compressedImagePaths[i]).catch(() => {}); + } + + compressedBytes = await newDoc.save({ useObjectStreams: true, addDefaultPage: false }); + + } else { + // ========== ESTRATÉGIA STREAMS: Para PDFs vetoriais/texto ========== + console.log(`[Compress] Usando compressão por streams (PDF vetorial detectado)`); + + // Remove metadados + pdfDoc.setTitle(''); + pdfDoc.setAuthor(''); + pdfDoc.setSubject(''); + pdfDoc.setKeywords([]); + pdfDoc.setProducer(''); + pdfDoc.setCreator(''); + + // Reconstrói com compressão de streams + const newDoc = await PDFDocument.create(); + const copiedPages = await newDoc.copyPages(pdfDoc, Array.from({ length: totalPages }, (_, i) => i)); + copiedPages.forEach((page: any) => newDoc.addPage(page)); + + // Adiciona OCR se solicitado e não tinha texto + if (enableOCR && pageTexts.length > 0) { + for (let i = 0; i < totalPages; i++) { + const text = pageTexts[i] || ''; + if (text.trim()) { + const page = newDoc.getPage(i); + const { height: pageHeight } = page.getSize(); + const lines = text.split('\n'); + let yPosition = pageHeight - 20; + lines.forEach((line: string) => { + if (line.trim() && yPosition > 10) { + page.drawText(line, { x: 10, y: yPosition, size: 8, color: rgb(0, 0, 0), opacity: 0.01 }); + yPosition -= 12; + } + }); + } + } + } + + compressedBytes = await newDoc.save({ + useObjectStreams: true, + addDefaultPage: false, + objectsPerTick: 50, + }); + } // Define o caminho de saída const finalOutputPath = outputPath || path.join( @@ -236,11 +419,13 @@ export class PDFService { `compressed-${uuidv4()}.pdf` ); - // Salva o arquivo await fs.writeFile(finalOutputPath, compressedBytes); const compressedSize = compressedBytes.length; const reduction = ((originalSize - compressedSize) / originalSize) * 100; + const elapsed = Date.now() - startTime; + + console.log(`[Compress] ✅ Concluído em ${elapsed}ms: ${totalPages} páginas, ${(originalSize / 1024 / 1024).toFixed(2)}MB → ${(compressedSize / 1024 / 1024).toFixed(2)}MB (${reduction.toFixed(1)}% redução)`); return { outputPath: finalOutputPath, @@ -251,6 +436,10 @@ export class PDFService { } catch (error) { console.error('Erro ao comprimir PDF:', error); throw new Error(`Falha ao comprimir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`); + } finally { + for (const tmpFile of tempFiles) { + await fs.access(tmpFile).then(() => fs.unlink(tmpFile)).catch(() => {}); + } } }