From 8f826ab223752d1ff7e7495f0ddc06048bbb2462 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Sat, 28 Feb 2026 19:21:47 -0300 Subject: [PATCH] pdf: novo refator do compress Signed-off-by: Rodrigo Bertoldo de Sousa Martins --- Dockerfile | 1 + src/components/pdf/PDFManipulationPage.tsx | 26 +- src/server/controllers/pdfController.ts | 10 +- src/services/pdfService.ts | 597 ++++++++++----------- 4 files changed, 297 insertions(+), 337 deletions(-) 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..3580d71 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 { @@ -66,39 +69,73 @@ export class PDFService { private static readonly TEMP_DIR = path.join(process.cwd(), 'storage', 'temp', 'pdf-ops'); private static readonly OUTPUT_DIR = path.join(process.cwd(), 'storage', 'pdf-output'); - // Cache de workers do Tesseract por idioma - evita reinicialização a cada chamada - private static workerCache: Map = new Map(); - private static workerInitializing: Map> = new Map(); + // Pool de workers do Tesseract - 4 workers para processamento paralelo + private static readonly WORKER_POOL_SIZE = 4; + private static workerPool: Worker[] = []; + private static workerBusy: boolean[] = []; + private static workerLanguage: string = ''; + private static workerInitPromise: Promise | null = null; /** - * Obtém ou cria um worker Tesseract persistente para o idioma especificado - * Worker é inicializado uma única vez e reutilizado em chamadas subsequentes + * Inicializa pool de workers Tesseract para processamento paralelo */ - private static async getWorker(language: string): Promise { - // Se já existe um worker pronto, retorna - if (this.workerCache.has(language)) { - return this.workerCache.get(language)!; + private static async initWorkerPool(language: string): Promise { + // Se já está inicializado com o mesmo idioma, retorna + if (this.workerPool.length === this.WORKER_POOL_SIZE && this.workerLanguage === language) { + return; } // Se está inicializando, aguarda - if (this.workerInitializing.has(language)) { - return this.workerInitializing.get(language)!; + if (this.workerInitPromise) { + await this.workerInitPromise; + if (this.workerLanguage === language) return; } - // Cria novo worker e armazena a promise de inicialização - const initPromise = (async () => { - console.log(`[OCR] Inicializando worker Tesseract para "${language}"...`); + // Encerra workers antigos se mudou idioma + if (this.workerPool.length > 0) { + await this.terminateWorkers(); + } + + this.workerInitPromise = (async () => { + console.log(`[OCR] Inicializando pool de ${this.WORKER_POOL_SIZE} workers Tesseract (${language})...`); const startTime = Date.now(); - const worker = await createWorker(language); - const elapsed = Date.now() - startTime; - console.log(`[OCR] Worker "${language}" pronto em ${elapsed}ms`); - this.workerCache.set(language, worker); - this.workerInitializing.delete(language); - return worker; + + const workerPromises = Array.from({ length: this.WORKER_POOL_SIZE }, async () => { + const worker = await createWorker(language); + return worker; + }); + + this.workerPool = await Promise.all(workerPromises); + this.workerBusy = new Array(this.WORKER_POOL_SIZE).fill(false); + this.workerLanguage = language; + + console.log(`[OCR] Pool de workers pronto em ${Date.now() - startTime}ms`); })(); - this.workerInitializing.set(language, initPromise); - return initPromise; + await this.workerInitPromise; + this.workerInitPromise = null; + } + + /** + * Obtém um worker disponível do pool (aguarda se todos ocupados) + */ + private static async getAvailableWorker(): Promise<{ worker: Worker; index: number }> { + while (true) { + const index = this.workerBusy.findIndex(busy => !busy); + if (index !== -1) { + this.workerBusy[index] = true; + return { worker: this.workerPool[index], index }; + } + // Aguarda 50ms e tenta novamente + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + /** + * Libera um worker de volta ao pool + */ + private static releaseWorker(index: number): void { + this.workerBusy[index] = false; } /** @@ -186,246 +223,163 @@ 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 agressiva de PDF + // Forçamos recompressão de todas as imagens para máxima redução + const gsCommand = [ + 'gs', + '-sDEVICE=pdfwrite', + '-dCompatibilityLevel=1.4', + `-dPDFSETTINGS=${preset.pdfSettings}`, + '-dNOPAUSE', + '-dQUIET', + '-dBATCH', + // Detecção de imagens duplicadas + '-dDetectDuplicateImages=true', + // Compressão de fontes + '-dCompressFonts=true', + '-dSubsetFonts=true', + '-dEmbedAllFonts=true', + // FORÇAR recompressão de TODAS as imagens (essencial para PDFs escaneados) + '-dAutoFilterColorImages=false', + '-dAutoFilterGrayImages=false', + '-dColorImageFilter=/DCTEncode', + '-dGrayImageFilter=/DCTEncode', + // Downsample agressivo + '-dDownsampleColorImages=true', + '-dDownsampleGrayImages=true', + '-dDownsampleMonoImages=true', + `-dColorImageResolution=${preset.dpi}`, + `-dGrayImageResolution=${preset.dpi}`, + `-dMonoImageResolution=${Math.max(preset.dpi, 150)}`, + '-dColorImageDownsampleType=/Bicubic', + '-dGrayImageDownsampleType=/Bicubic', + '-dMonoImageDownsampleType=/Subsample', + // Downsample threshold (força downsample mesmo em imagens próximas da resolução alvo) + '-dColorImageDownsampleThreshold=1.0', + '-dGrayImageDownsampleThreshold=1.0', + '-dMonoImageDownsampleThreshold=1.0', + // Qualidade JPEG + `-dJPEGQ=${preset.imageQuality}`, + // Otimizações adicionais + '-dOptimize=true', + '-dConvertCMYKImagesToRGB=true', + // Output + `-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 apenas se solicitado E se o PDF não tem texto embarcado + if (enableOCR) { + // Verifica se já tem texto embarcado + const pdfBytes = await fs.readFile(finalOutputPath); + let hasText = false; + try { + const pdfData = await pdfParse(pdfBytes); + const pdfDoc = await PDFDocument.load(pdfBytes); + const totalPages = pdfDoc.getPageCount(); + const textPerPage = (pdfData.text || '').trim().length / totalPages; + hasText = textPerPage > 50; // Mais de 50 caracteres por página = já tem texto + } catch { + // Se falhar ao verificar, assume que não tem + } + + if (hasText) { + console.log(`[Compress] PDF já tem texto embarcado, OCR não necessário`); + } else { + // PDF escaneado, precisa de OCR + try { + const ocrResult = await this.addOCRLayerToPDF(finalOutputPath, undefined, ocrLanguage); + // Substitui pelo arquivo com OCR apenas se não aumentou muito + const ocrStats = await fs.stat(ocrResult.outputPath); + if (ocrStats.size <= compressedSize * 1.5) { // Aceita até 50% de aumento pelo OCR + await fs.rename(ocrResult.outputPath, finalOutputPath); + compressedSize = ocrStats.size; + console.log(`[Compress] OCR adicionado com sucesso`); + } else { + console.warn(`[Compress] OCR aumentou muito o tamanho, descartando`); + await fs.unlink(ocrResult.outputPath).catch(() => {}); + } + } 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 +390,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(() => {}); - } } } @@ -851,52 +801,52 @@ export class PDFService { }; } - // ========== FASE 2: Tesseract OCR (fallback para PDFs escaneados) ========== - console.log(`[OCR] ⚠️ PDF escaneado detectado (sem texto embarcado). Usando Tesseract...`); - + // ========== FASE 2: Tesseract OCR PARALELO (fallback para PDFs escaneados) ========== const imagePaths = await this.pdfToImagesForOCR(inputPath, pages); console.log(`[OCR] Conversão PDF→imagens: ${Date.now() - t1}ms (${imagePaths.length} páginas)`); + console.log(`[OCR] ⚠️ PDF escaneado detectado. Usando Tesseract paralelo (${imagePaths.length} páginas)...`); if (imagePaths.length === 0) { return { text: '', confidence: 0, pages: [] }; } - const worker = await this.getWorker(language); + // Inicializa pool de workers + await this.initWorkerPool(language); const pageResults: Array<{ pageNumber: number; text: string; confidence: number; - }> = []; - - let allText = ''; - let totalConfidence = 0; + }> = new Array(imagePaths.length); + // Processa em paralelo const t3 = Date.now(); - for (let i = 0; i < imagePaths.length; i++) { + const ocrPromises = imagePaths.map(async (imagePath, i) => { + const { worker, index: workerIndex } = await this.getAvailableWorker(); try { - const imagePath = imagePaths[i]; const { data } = await worker.recognize(imagePath); - - pageResults.push({ + pageResults[i] = { pageNumber: i + 1, text: data.text, confidence: data.confidence, - }); - - allText += data.text + '\n\n'; - totalConfidence += data.confidence; - - await fs.unlink(imagePath).catch(() => {}); + }; + console.log(`[OCR] Página ${i + 1}/${imagePaths.length} processada`); } catch (error) { - console.error(`[OCR] Erro ao processar página ${i + 1}:`, error); + console.error(`[OCR] Erro página ${i + 1}:`, error); + pageResults[i] = { pageNumber: i + 1, text: '', confidence: 0 }; + } finally { + this.releaseWorker(workerIndex); + await fs.unlink(imagePath).catch(() => {}); } - } - console.log(`[OCR] Tesseract reconhecimento: ${Date.now() - t3}ms`); + }); - const averageConfidence = pageResults.length > 0 - ? totalConfidence / pageResults.length - : 0; + await Promise.all(ocrPromises); + console.log(`[OCR] Tesseract paralelo: ${Date.now() - t3}ms`); + + const validResults = pageResults.filter(r => r && r.confidence > 0); + const allText = pageResults.map(r => r?.text || '').join('\n\n'); + const totalConfidence = validResults.reduce((sum, r) => sum + r.confidence, 0); + const averageConfidence = validResults.length > 0 ? totalConfidence / validResults.length : 0; const totalTime = Date.now() - startTime; console.log(`[OCR] Concluído em ${totalTime}ms (${pageResults.length} páginas, confiança: ${averageConfidence.toFixed(1)}%)`); @@ -966,9 +916,10 @@ export class PDFService { * * Abordagem em 2 fases: * 1. RÁPIDA: Usa pdf-parse para extrair texto embarcado e adicionar como camada invisível - * 2. FALLBACK: Se PDF escaneado (sem texto), usa Tesseract + * 2. FALLBACK: Se PDF escaneado (sem texto), usa Tesseract com pool paralelo * * Para PDFs digitais (com texto embarcado), completa em <1 segundo. + * Para escaneados: ~5-10 segundos por página com processamento paralelo (4x mais rápido) */ static async addOCRLayerToPDF( inputPath: string, @@ -984,58 +935,63 @@ export class PDFService { const pdfDoc = await PDFDocument.load(pdfBytes); const totalPages = pdfDoc.getPageCount(); - // ========== FASE 1: Tentar extrair texto com pdf-parse (instantâneo) ========== - let pageTexts: string[] = []; + // ========== FASE 1: Verificar se já tem texto embarcado ========== let hasEmbeddedText = false; try { - const t1 = Date.now(); const pdfData = await pdfParse(pdfBytes); const fullText = pdfData.text || ''; - - // Verifica se tem texto significativo - hasEmbeddedText = fullText.trim().length > (totalPages * 30); - - if (hasEmbeddedText) { - // Separa texto por páginas - const textParts = fullText.split(/\f/); - if (textParts.length >= totalPages) { - pageTexts = textParts.slice(0, totalPages); - } else { - // Distribui texto entre páginas - 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(`[OCR Layer] ✅ Texto embarcado extraído via pdf-parse em ${Date.now() - t1}ms`); - } + const textPerPage = fullText.trim().length / totalPages; + hasEmbeddedText = textPerPage > 50; } catch (parseError) { console.warn(`[OCR Layer] pdf-parse falhou:`, parseError); } - // ========== FASE 2: Fallback Tesseract para PDFs escaneados ========== - if (!hasEmbeddedText) { - console.log(`[OCR Layer] ⚠️ PDF escaneado, usando Tesseract...`); + if (hasEmbeddedText) { + console.log(`[OCR Layer] ✅ PDF já tem texto embarcado, retornando original`); - const imagePaths = await this.pdfToImagesForOCR(inputPath, 'all'); - const worker = await this.getWorker(language); - - pageTexts = []; - for (let i = 0; i < imagePaths.length; i++) { - try { - const { data } = await worker.recognize(imagePaths[i]); - pageTexts.push(data.text); - await fs.unlink(imagePaths[i]).catch(() => {}); - } catch (error) { - console.error(`[OCR Layer] Erro Tesseract página ${i + 1}:`, error); - pageTexts.push(''); - } - } + const finalOutputPath = outputPath || path.join( + this.OUTPUT_DIR, + `ocr-${uuidv4()}.pdf` + ); + + await fs.copyFile(inputPath, finalOutputPath); + + return { + outputPath: finalOutputPath, + confidence: 99.0, + }; } + // ========== FASE 2: OCR PARALELO com pool de workers ========== + console.log(`[OCR Layer] ⚠️ PDF escaneado (${totalPages} páginas), usando Tesseract paralelo...`); + + // Inicializa pool de workers + await this.initWorkerPool(language); + + // Converte todas as páginas para imagens primeiro + const imagePaths = await this.pdfToImagesForOCR(inputPath, 'all'); + console.log(`[OCR Layer] ${imagePaths.length} imagens preparadas, iniciando OCR paralelo...`); + + // Processa OCR em paralelo usando o pool de workers + const pageTexts: string[] = new Array(imagePaths.length).fill(''); + + const ocrPromises = imagePaths.map(async (imagePath, index) => { + const { worker, index: workerIndex } = await this.getAvailableWorker(); + try { + const { data } = await worker.recognize(imagePath); + pageTexts[index] = data.text; + console.log(`[OCR Layer] Página ${index + 1}/${imagePaths.length} processada`); + } catch (error) { + console.error(`[OCR Layer] Erro Tesseract página ${index + 1}:`, error); + } finally { + this.releaseWorker(workerIndex); + await fs.unlink(imagePath).catch(() => {}); + } + }); + + await Promise.all(ocrPromises); + // ========== Cria PDF com camada de texto invisível ========== const newDoc = await PDFDocument.create(); @@ -1074,13 +1030,12 @@ export class PDFService { const newPdfBytes = await newDoc.save(); await fs.writeFile(finalOutputPath, newPdfBytes); - const confidence = hasEmbeddedText ? 99.0 : 85.0; const totalTime = Date.now() - startTime; - console.log(`[OCR Layer] Concluído em ${totalTime}ms (${totalPages} páginas, método: ${hasEmbeddedText ? 'pdf-parse' : 'Tesseract'})`); + console.log(`[OCR Layer] ✅ Concluído em ${totalTime}ms (${totalPages} páginas, ${this.WORKER_POOL_SIZE} workers paralelos)`); return { outputPath: finalOutputPath, - confidence, + confidence: 85.0, }; } catch (error) { console.error('Erro ao adicionar camada OCR:', error); @@ -1153,16 +1108,16 @@ export class PDFService { } /** - * Pré-aquece o worker Tesseract para o idioma especificado + * Pré-aquece o pool de workers Tesseract para o idioma especificado * Chame no startup do servidor para eliminar delay na primeira operação OCR */ static async warmupOCR(language: string = 'por'): Promise { try { - console.log(`[OCR] Pré-aquecendo worker para "${language}"...`); - await this.getWorker(language); - console.log(`[OCR] Worker "${language}" pronto para uso`); + console.log(`[OCR] Pré-aquecendo pool de workers para "${language}"...`); + await this.initWorkerPool(language); + console.log(`[OCR] Pool de ${this.WORKER_POOL_SIZE} workers pronto para uso`); } catch (error) { - console.error(`[OCR] Erro ao pré-aquecer worker "${language}":`, error); + console.error(`[OCR] Erro ao pré-aquecer workers "${language}":`, error); } } @@ -1170,14 +1125,16 @@ export class PDFService { * Encerra todos os workers Tesseract (chamar no shutdown do servidor) */ static async terminateWorkers(): Promise { - for (const [lang, worker] of this.workerCache) { + for (let i = 0; i < this.workerPool.length; i++) { try { - await worker.terminate(); - console.log(`[OCR] Worker "${lang}" encerrado`); + await this.workerPool[i].terminate(); + console.log(`[OCR] Worker ${i + 1} encerrado`); } catch (error) { - console.error(`[OCR] Erro ao encerrar worker "${lang}":`, error); + console.error(`[OCR] Erro ao encerrar worker ${i + 1}:`, error); } } - this.workerCache.clear(); + this.workerPool = []; + this.workerBusy = []; + this.workerLanguage = ''; } }