From fcf2778a284c803ab46d56af9537acbf50891e94 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Sat, 28 Feb 2026 19:07:34 -0300 Subject: [PATCH] pdf: refatorar compressPDF Signed-off-by: Rodrigo Bertoldo de Sousa Martins --- src/components/pdf/PDFManipulationPage.tsx | 8 +- src/server/controllers/pdfController.ts | 27 +-- src/services/pdfService.ts | 204 ++++++++++++++++++--- 3 files changed, 190 insertions(+), 49 deletions(-) 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/pdfService.ts b/src/services/pdfService.ts index cb4679a..ce3ff25 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,50 +186,199 @@ export class PDFService { } /** - * Comprime um PDF de forma agressiva para redução >60% - * Usa abordagem simplificada e eficiente + * Comprime um PDF de forma agressiva via conversão para imagens JPEG comprimidas. + * Abordagem: PDF → Imagens (pdf2pic) → JPEG comprimido (sharp+mozjpeg) → Novo PDF + * Suporta OCR integrado para evitar dupla conversão de imagens. */ static async compressPDF( inputPath: string, outputPath?: string, options: CompressOptions = {} ): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> { + const tempFiles: string[] = []; // Rastreia arquivos temporários para limpeza + 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(''); + console.log(`[Compress] Iniciando compressão de ${totalPages} páginas (quality=${quality}, OCR=${enableOCR})`); - // Para compressão agressiva, reconstrói o PDF do zero + // ========== FASE 1: Extrair texto via pdf-parse (instantâneo) se OCR habilitado ========== + let pageTexts: string[] = []; + let hasEmbeddedText = false; + + if (enableOCR) { + try { + const pdfData = await pdfParse(pdfBytes); + const fullText = pdfData.text || ''; + hasEmbeddedText = fullText.trim().length > (totalPages * 30); + + if (hasEmbeddedText) { + 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')); + } + } + console.log(`[Compress] ✅ Texto embarcado extraído via pdf-parse`); + } + } catch (parseError) { + console.warn(`[Compress] pdf-parse falhou:`, parseError); + } + } + + // ========== FASE 2: Converter páginas em imagens e comprimir ========== + // DPI baseado na qualidade solicitada + const density = quality >= 90 ? 250 : quality >= 70 ? 200 : 150; + + 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[] = []; + + // Precisa Tesseract para OCR? (apenas se OCR habilitado e sem texto embarcado) + const needsTesseractOCR = enableOCR && !hasEmbeddedText; + let tesseractWorker: Worker | null = null; + if (needsTesseractOCR) { + tesseractWorker = await this.getWorker(ocrLanguage); + console.log(`[Compress] ⚠️ PDF escaneado, 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); + + // Se precisa Tesseract OCR, faz antes de comprimir a imagem + if (needsTesseractOCR && tesseractWorker) { + try { + // Otimiza para OCR: grayscale + normalização + 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 progressivo com mozjpeg + const jpegPath = path.join(this.TEMP_DIR, `compress-page-${pageNum}-${uuidv4()}.jpg`); + tempFiles.push(jpegPath); + + await sharp(rawImagePath) + .jpeg({ + quality, + progressive: true, + mozjpeg: true, // Mozjpeg para compressão otimizada + chromaSubsampling: quality >= 90 ? '4:4:4' : '4:2:0', // Menos subsampling em alta qualidade + }) + .toFile(jpegPath); + + compressedImagePaths.push(jpegPath); + + // Remove imagem PNG bruta + await fs.unlink(rawImagePath).catch(() => {}); + + console.log(`[Compress] Página ${pageNum}/${totalPages} convertida e comprimida`); + } 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'); + } + + // ========== FASE 3: Montar novo PDF a partir das imagens comprimidas ========== 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 + for (let i = 0; i < compressedImagePaths.length; i++) { + const imgBytes = await fs.readFile(compressedImagePaths[i]); + const image = await newDoc.embedJpg(imgBytes); + + // Usa as dimensões originais da página do PDF + 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 camada de texto OCR (invisível) se disponí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, // Invisível mas selecionável/buscável + }); + yPosition -= 12; + } + }); + } + + // Limpa imagem temporária + await fs.unlink(compressedImagePaths[i]).catch(() => {}); + } + + // Salva com compressão de streams const compressedBytes = await newDoc.save({ - useObjectStreams: true, // Compressão de streams + useObjectStreams: true, addDefaultPage: false, - objectsPerTick: 50, }); // Define o caminho de saída @@ -236,11 +387,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(1)}MB → ${(compressedSize / 1024 / 1024).toFixed(1)}MB (${reduction.toFixed(1)}% redução)`); return { outputPath: finalOutputPath, @@ -251,6 +404,11 @@ 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 { + // Limpeza de qualquer arquivo temporário restante + for (const tmpFile of tempFiles) { + await fs.access(tmpFile).then(() => fs.unlink(tmpFile)).catch(() => {}); + } } }