diff --git a/Dockerfile b/Dockerfile index 31a695c..cfc0159 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y chromium \ libgconf-2-4 \ postgresql-client \ graphicsmagick \ + ghostscript \ && rm -rf /var/lib/apt/lists/* \ && ln -sf /usr/bin/chromium /usr/bin/chromium-browser diff --git a/src/components/pdf/PDFManipulationPage.tsx b/src/components/pdf/PDFManipulationPage.tsx index 8b2fa84..743fd6e 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(85); + const [compressQuality, setCompressQuality] = useState<'extreme' | 'low' | 'medium' | 'high'>('medium'); const [enableOCR, setEnableOCR] = useState(false); const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages'); const [splitPages, setSplitPages] = useState('1,2,3'); @@ -107,7 +107,7 @@ export const PDFManipulationPage: React.FC = () => { switch (selectedOperation) { case 'compress': - formData.append('quality', compressQuality.toString()); + formData.append('quality', compressQuality); formData.append('enableOCR', enableOCR.toString()); response = await axios.post('/api/pdf/compress', formData, config); toast.success('PDFs comprimidos com sucesso!'); @@ -456,18 +456,20 @@ export const PDFManipulationPage: React.FC = () => {
- setCompressQuality(parseInt(e.target.value))} - className="w-full" - /> -

- 50-70%: Máxima compressão | 80-90%: Equilíbrio ideal | 90-100%: Alta fidelidade + onChange={(e) => setCompressQuality(e.target.value as any)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + > + + + + + +

+ 💡 Usa Ghostscript, mesmo motor do iLovePDF e SmallPDF

diff --git a/src/server/controllers/pdfController.ts b/src/server/controllers/pdfController.ts index 09e31d0..6c586d9 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) : 85; // Padrão 85 para boa qualidade com compressão agressiva - const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined; + // Presets de qualidade: extreme, low, medium, high (padrão: medium) + const validQualities = ['extreme', 'low', 'medium', 'high']; + const quality = validQualities.includes(req.body.quality) ? req.body.quality : 'medium'; const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true; const arquivosOriginais: string[] = []; @@ -88,7 +89,7 @@ export class PDFController { arquivosOriginais: [], arquivosResultado: [], tamanhoOriginal: BigInt(0), - parametros: { quality, maxSizeMB }, + parametros: { quality }, }, }); @@ -100,10 +101,9 @@ export class PDFController { arquivosOriginais.push(file.path); tamanhoOriginal += BigInt(file.size); - // Comprime com OCR integrado (evita dupla conversão de imagens) + // Comprime usando Ghostscript (padrão iLovePDF/SmallPDF) const result = await PDFService.compressPDF(file.path, undefined, { quality, - maxSizeMB, enableOCR, ocrLanguage: 'por', }); diff --git a/src/services/pdfService.ts b/src/services/pdfService.ts index 852cabf..4cdbcc4 100644 --- a/src/services/pdfService.ts +++ b/src/services/pdfService.ts @@ -8,6 +8,10 @@ import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import archiver from 'archiver'; import { createWriteStream } from 'fs'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); /** * Serviço completo de manipulação de PDFs @@ -15,10 +19,9 @@ import { createWriteStream } from 'fs'; */ interface CompressOptions { - 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' + quality?: 'low' | 'medium' | 'high' | 'extreme'; // Presets de qualidade estilo iLovePDF + enableOCR?: boolean; + ocrLanguage?: string; } interface SplitOptions { @@ -186,246 +189,122 @@ export class PDFService { } /** - * 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. + * Comprime um PDF usando Ghostscript (padrão da indústria - usado por iLovePDF, SmallPDF, etc.) + * Presets de qualidade: + * - extreme: Máxima compressão (72 DPI) - reduções de 70-90% + * - low: Boa compressão (100 DPI) - reduções de 50-70% + * - medium: Equilibrado (150 DPI) - reduções de 30-50% + * - high: Alta qualidade (200 DPI) - reduções de 10-30% */ 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 = 85, + quality = 'medium', enableOCR = false, ocrLanguage = 'por', } = options; - // Lê o PDF original - const pdfBytes = await fs.readFile(inputPath); - const originalSize = pdfBytes.length; + // Lê tamanho original + const stats = await fs.stat(inputPath); + const originalSize = stats.size; - const pdfDoc = await PDFDocument.load(pdfBytes, { - ignoreEncryption: true, - }); - const totalPages = pdfDoc.getPageCount(); + // Define configurações baseadas no preset de qualidade + const presets: Record = { + extreme: { dpi: 72, imageQuality: 30, pdfSettings: '/screen' }, + low: { dpi: 100, imageQuality: 50, pdfSettings: '/screen' }, + medium: { dpi: 150, imageQuality: 70, pdfSettings: '/ebook' }, + high: { dpi: 200, imageQuality: 85, pdfSettings: '/printer' }, + }; - // ========== 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 + const preset = presets[quality] || presets.medium; - // Verifica se tem texto embarcado - let hasEmbeddedText = false; - let pageTexts: string[] = []; - - 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 + console.log(`[Compress] Iniciando compressão Ghostscript (preset=${quality}, DPI=${preset.dpi})`); - 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 + // Define caminho de saída const finalOutputPath = outputPath || path.join( this.OUTPUT_DIR, `compressed-${uuidv4()}.pdf` ); - await fs.writeFile(finalOutputPath, compressedBytes); + // Comando Ghostscript para compressão de PDF + // Similar ao que iLovePDF/SmallPDF usam internamente + const gsCommand = [ + 'gs', + '-sDEVICE=pdfwrite', + '-dCompatibilityLevel=1.4', + `-dPDFSETTINGS=${preset.pdfSettings}`, + '-dNOPAUSE', + '-dQUIET', + '-dBATCH', + '-dDetectDuplicateImages=true', + '-dCompressFonts=true', + '-dSubsetFonts=true', + `-dDownsampleColorImages=true`, + `-dColorImageResolution=${preset.dpi}`, + `-dDownsampleGrayImages=true`, + `-dGrayImageResolution=${preset.dpi}`, + `-dDownsampleMonoImages=true`, + `-dMonoImageResolution=${preset.dpi}`, + `-dColorImageDownsampleType=/Bicubic`, + `-dGrayImageDownsampleType=/Bicubic`, + `-dMonoImageDownsampleType=/Bicubic`, + `-dJPEGQ=${preset.imageQuality}`, + `-sOutputFile="${finalOutputPath}"`, + `"${inputPath}"`, + ].join(' '); + + try { + await execAsync(gsCommand, { timeout: 300000 }); // 5 min timeout + } catch (gsError: any) { + console.error('[Compress] Erro Ghostscript:', gsError.message); + // Fallback: copia o arquivo original se Ghostscript falhar + await fs.copyFile(inputPath, finalOutputPath); + console.warn('[Compress] Fallback: arquivo copiado sem compressão'); + } + + // Verifica se o arquivo foi criado + let compressedSize: number; + try { + const compressedStats = await fs.stat(finalOutputPath); + compressedSize = compressedStats.size; + } catch { + // Arquivo não criado, fallback + await fs.copyFile(inputPath, finalOutputPath); + compressedSize = originalSize; + } + + // Se o arquivo comprimido ficou MAIOR, usa o original + if (compressedSize > originalSize) { + console.warn(`[Compress] Comprimido ficou maior (${compressedSize} > ${originalSize}), usando original`); + await fs.copyFile(inputPath, finalOutputPath); + compressedSize = originalSize; + } + + // Adiciona OCR se solicitado + if (enableOCR) { + try { + const ocrResult = await this.addOCRLayerToPDF(finalOutputPath, undefined, ocrLanguage); + // Substitui pelo arquivo com OCR + await fs.rename(ocrResult.outputPath, finalOutputPath); + const ocrStats = await fs.stat(finalOutputPath); + compressedSize = ocrStats.size; + console.log(`[Compress] OCR adicionado com sucesso`); + } catch (ocrError) { + console.warn(`[Compress] Erro ao adicionar OCR:`, ocrError); + } + } - 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)`); + console.log(`[Compress] ✅ Concluído em ${elapsed}ms: ${(originalSize / 1024 / 1024).toFixed(2)}MB → ${(compressedSize / 1024 / 1024).toFixed(2)}MB (${reduction.toFixed(1)}% redução)`); return { outputPath: finalOutputPath, @@ -436,10 +315,6 @@ 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(() => {}); - } } }