diff --git a/src/server/index.ts b/src/server/index.ts index b7fe752..24659e3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,7 @@ import pensaoAlimenticiaRouter from './routes/pensaoAlimenticia'; // Importar ro import financeiroPainelRoutes from './routes/financeiroPainel'; // Importar rotas do financeiro painel import audienciasRoutes from './routes/audiencias'; // Importar rotas de audiências import pdfRoutes from './routes/pdf'; // Importar rotas de PDF +import { PDFService } from '../services/pdfService'; // Importar serviço de PDF para warmup OCR const app = express(); @@ -1433,9 +1434,25 @@ app.use( // Aumentar timeout do servidor para operações longas (ex: PDF, OCR) const server = app.listen(port, () => { console.log(`🚀 Servidor rodando em http://localhost:${port}`); + + // Pré-aquece o worker Tesseract OCR em background (elimina delay na primeira operação) + PDFService.warmupOCR('por').catch(() => {}); }); server.timeout = 600000; // 10 minutos (600 segundos) +// Encerra workers Tesseract ao desligar o servidor +process.on('SIGTERM', async () => { + console.log('Encerrando workers OCR...'); + await PDFService.terminateWorkers(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('Encerrando workers OCR...'); + await PDFService.terminateWorkers(); + process.exit(0); +}); + process.on("unhandledRejection", (err) => { console.error("Erro não tratado:", err); }); diff --git a/src/services/pdfService.ts b/src/services/pdfService.ts index c162611..fe93bbc 100644 --- a/src/services/pdfService.ts +++ b/src/services/pdfService.ts @@ -1,6 +1,6 @@ import { PDFDocument, rgb } from 'pdf-lib'; import sharp from 'sharp'; -import { createWorker } from 'tesseract.js'; +import { createWorker, Worker } from 'tesseract.js'; import { fromPath } from 'pdf2pic'; import fs from 'fs/promises'; import path from 'path'; @@ -63,6 +63,112 @@ 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(); + + /** + * Obtém ou cria um worker Tesseract persistente para o idioma especificado + * Worker é inicializado uma única vez e reutilizado em chamadas subsequentes + */ + private static async getWorker(language: string): Promise { + // Se já existe um worker pronto, retorna + if (this.workerCache.has(language)) { + return this.workerCache.get(language)!; + } + + // Se está inicializando, aguarda + if (this.workerInitializing.has(language)) { + return this.workerInitializing.get(language)!; + } + + // Cria novo worker e armazena a promise de inicialização + const initPromise = (async () => { + console.log(`[OCR] Inicializando worker Tesseract para "${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; + })(); + + this.workerInitializing.set(language, initPromise); + return initPromise; + } + + /** + * Converte páginas de PDF em imagens otimizadas para OCR (baixa resolução, grayscale) + * Muito mais rápido que pdfToImages() pois usa resolução mínima necessária + */ + private static async pdfToImagesForOCR( + inputPath: string, + pages: number[] | 'all' + ): Promise { + await this.initialize(); + + const pdfBytes = await fs.readFile(inputPath); + const pdfDoc = await PDFDocument.load(pdfBytes); + const totalPages = pdfDoc.getPageCount(); + + const pagesToConvert = pages === 'all' + ? Array.from({ length: totalPages }, (_, i) => i + 1) + : pages; + + // Usa resolução BAIXA para OCR (150 DPI em vez de 300) - suficiente para texto + const converter = fromPath(inputPath, { + density: 150, + saveFilename: `ocr-${uuidv4()}`, + savePath: this.TEMP_DIR, + format: 'png', + width: 1240, // Metade do A4 a 300 DPI + height: 1754, + }); + + const outputPaths: string[] = []; + + // Converte todas as páginas e otimiza com sharp (grayscale para OCR mais rápido) + const conversionPromises = pagesToConvert.map(async (pageNum) => { + if (pageNum < 1 || pageNum > totalPages) return null; + + try { + const result = await converter(pageNum, { responseType: 'image' }); + + if (result && typeof result === 'object' && 'path' in result) { + const rawPath = result.path as string; + const optimizedPath = path.join(this.TEMP_DIR, `ocr-opt-${pageNum}-${uuidv4()}.png`); + + // Converte para grayscale e comprime - muito mais rápido para OCR + await sharp(rawPath) + .grayscale() + .normalize() // Melhora contraste para OCR + .png({ compressionLevel: 1 }) // Compressão mínima (velocidade) + .toFile(optimizedPath); + + // Remove imagem temporária original + await fs.unlink(rawPath).catch(() => {}); + + return { pageNum, path: optimizedPath }; + } + } catch (error) { + console.error(`[OCR] Erro ao converter página ${pageNum}:`, error); + } + return null; + }); + + // Executa conversões (pdf2pic limita concorrência internamente) + const results = await Promise.all(conversionPromises); + + // Ordena por página e filtra nulos + results + .filter((r): r is { pageNum: number; path: string } => r !== null) + .sort((a, b) => a.pageNum - b.pageNum) + .forEach(r => outputPaths.push(r.path)); + + return outputPaths; + } + /** * Inicializa os diretórios necessários */ @@ -459,6 +565,7 @@ export class PDFService { /** * Realiza OCR em um PDF e extrai o texto + * Otimizado para velocidade: worker em cache, baixa resolução, grayscale */ static async performOCR( inputPath: string, @@ -468,18 +575,23 @@ export class PDFService { await this.initialize(); const { language = 'por', pages = 'all' } = options; + const startTime = Date.now(); - // Primeiro converte o PDF em imagens - const imageOptions: PDFToImageOptions = { - format: 'png', - density: 300, - pages, - }; + console.log(`[OCR] Iniciando OCR: idioma=${language}, páginas=${JSON.stringify(pages)}`); - const imagePaths = await this.pdfToImages(inputPath, imageOptions); + // Converte PDF em imagens otimizadas para OCR (150 DPI, grayscale) + const t1 = Date.now(); + const imagePaths = await this.pdfToImagesForOCR(inputPath, pages); + console.log(`[OCR] Conversão PDF→imagens: ${Date.now() - t1}ms (${imagePaths.length} páginas)`); - // Cria um worker do Tesseract - const worker = await createWorker(language); + if (imagePaths.length === 0) { + return { text: '', confidence: 0, pages: [] }; + } + + // Obtém worker do cache (rápido após primeira chamada) + const t2 = Date.now(); + const worker = await this.getWorker(language); + console.log(`[OCR] Worker obtido: ${Date.now() - t2}ms`); const pageResults: Array<{ pageNumber: number; @@ -490,7 +602,8 @@ export class PDFService { let allText = ''; let totalConfidence = 0; - // Processa cada imagem + // Processa cada imagem com o worker em cache + const t3 = Date.now(); for (let i = 0; i < imagePaths.length; i++) { try { const imagePath = imagePaths[i]; @@ -508,16 +621,18 @@ export class PDFService { // Remove a imagem temporária await fs.unlink(imagePath).catch(() => {}); } catch (error) { - console.error(`Erro ao processar OCR na página ${i + 1}:`, error); + console.error(`[OCR] Erro ao processar página ${i + 1}:`, error); } } - - await worker.terminate(); + console.log(`[OCR] Reconhecimento: ${Date.now() - t3}ms`); const averageConfidence = pageResults.length > 0 ? totalConfidence / pageResults.length : 0; + const totalTime = Date.now() - startTime; + console.log(`[OCR] Concluído em ${totalTime}ms (${pageResults.length} páginas, confiança: ${averageConfidence.toFixed(1)}%)`); + return { text: allText.trim(), confidence: averageConfidence, @@ -580,6 +695,7 @@ export class PDFService { /** * Adiciona camada de OCR a um PDF (torna o PDF pesquisável) + * Otimizado: usa worker em cache e imagens de baixa resolução */ static async addOCRLayerToPDF( inputPath: string, @@ -588,45 +704,68 @@ export class PDFService { ): Promise<{ outputPath: string; confidence: number }> { try { await this.initialize(); - - // Primeiro, extrai o texto via OCR - const ocrResult = await this.performOCR(inputPath, { language, pages: 'all' }); + const startTime = Date.now(); + console.log(`[OCR Layer] Iniciando adição de camada OCR...`); // Carrega o PDF original const pdfBytes = await fs.readFile(inputPath); const pdfDoc = await PDFDocument.load(pdfBytes); + const totalPages = pdfDoc.getPageCount(); - // Cria um novo PDF com camada de texto + // Converte em imagens otimizadas para OCR (baixa resolução, grayscale) + const t1 = Date.now(); + const imagePaths = await this.pdfToImagesForOCR(inputPath, 'all'); + console.log(`[OCR Layer] Conversão: ${Date.now() - t1}ms`); + + // Obtém worker do cache + const worker = await this.getWorker(language); + + // Cria novo PDF com camada de texto const newDoc = await PDFDocument.create(); - const pages = pdfDoc.getPages(); - for (let i = 0; i < pages.length; i++) { + let totalConfidence = 0; + let pagesProcessed = 0; + + const t2 = Date.now(); + for (let i = 0; i < totalPages; 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; + // Faz OCR apenas se temos imagem para esta página + if (i < imagePaths.length) { + try { + const { data } = await worker.recognize(imagePaths[i]); + totalConfidence += data.confidence; + pagesProcessed++; - 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; - } - }); + const newPage = newDoc.getPage(i); + const { height } = newPage.getSize(); + + // Adiciona texto invisível (camada OCR pesquisável) + const lines = data.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), + opacity: 0.01, + }); + yPosition -= 12; + } + }); + + // Remove imagem temporária + await fs.unlink(imagePaths[i]).catch(() => {}); + } catch (error) { + console.error(`[OCR Layer] Erro na página ${i + 1}:`, error); + } } } + console.log(`[OCR Layer] Reconhecimento: ${Date.now() - t2}ms`); // Salva o PDF com camada OCR const finalOutputPath = outputPath || path.join( @@ -637,9 +776,13 @@ export class PDFService { const newPdfBytes = await newDoc.save(); await fs.writeFile(finalOutputPath, newPdfBytes); + const avgConfidence = pagesProcessed > 0 ? totalConfidence / pagesProcessed : 0; + const totalTime = Date.now() - startTime; + console.log(`[OCR Layer] Concluído em ${totalTime}ms (${pagesProcessed} páginas, confiança: ${avgConfidence.toFixed(1)}%)`); + return { outputPath: finalOutputPath, - confidence: ocrResult.confidence, + confidence: avgConfidence, }; } catch (error) { console.error('Erro ao adicionar camada OCR:', error); @@ -710,4 +853,33 @@ export class PDFService { throw new Error(`Falha ao obter informações do PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`); } } + + /** + * Pré-aquece o worker 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`); + } catch (error) { + console.error(`[OCR] Erro ao pré-aquecer worker "${language}":`, error); + } + } + + /** + * Encerra todos os workers Tesseract (chamar no shutdown do servidor) + */ + static async terminateWorkers(): Promise { + for (const [lang, worker] of this.workerCache) { + try { + await worker.terminate(); + console.log(`[OCR] Worker "${lang}" encerrado`); + } catch (error) { + console.error(`[OCR] Erro ao encerrar worker "${lang}":`, error); + } + } + this.workerCache.clear(); + } }