pdf: accelerate OCR
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
db7b832044
commit
ab73ea667e
2 files changed
+216
-27
No files matched your search
@@ -28,6 +28,7 @@ import pensaoAlimenticiaRouter from './routes/pensaoAlimenticia'; // Importar ro
|
|||||||
import financeiroPainelRoutes from './routes/financeiroPainel'; // Importar rotas do financeiro painel
|
import financeiroPainelRoutes from './routes/financeiroPainel'; // Importar rotas do financeiro painel
|
||||||
import audienciasRoutes from './routes/audiencias'; // Importar rotas de audiências
|
import audienciasRoutes from './routes/audiencias'; // Importar rotas de audiências
|
||||||
import pdfRoutes from './routes/pdf'; // Importar rotas de PDF
|
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();
|
const app = express();
|
||||||
@@ -1433,9 +1434,25 @@ app.use(
|
|||||||
// Aumentar timeout do servidor para operações longas (ex: PDF, OCR)
|
// Aumentar timeout do servidor para operações longas (ex: PDF, OCR)
|
||||||
const server = app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
console.log(`🚀 Servidor rodando em http://localhost:${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)
|
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) => {
|
process.on("unhandledRejection", (err) => {
|
||||||
console.error("Erro não tratado:", err);
|
console.error("Erro não tratado:", err);
|
||||||
});
|
});
|
||||||
|
|||||||
+199
-27
@@ -1,6 +1,6 @@
|
|||||||
import { PDFDocument, rgb } from 'pdf-lib';
|
import { PDFDocument, rgb } from 'pdf-lib';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createWorker } from 'tesseract.js';
|
import { createWorker, Worker } from 'tesseract.js';
|
||||||
import { fromPath } from 'pdf2pic';
|
import { fromPath } from 'pdf2pic';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
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 TEMP_DIR = path.join(process.cwd(), 'storage', 'temp', 'pdf-ops');
|
||||||
private static readonly OUTPUT_DIR = path.join(process.cwd(), 'storage', 'pdf-output');
|
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<string, Worker> = new Map();
|
||||||
|
private static workerInitializing: Map<string, Promise<Worker>> = 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<Worker> {
|
||||||
|
// 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<string[]> {
|
||||||
|
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
|
* Inicializa os diretórios necessários
|
||||||
*/
|
*/
|
||||||
@@ -459,6 +565,7 @@ export class PDFService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Realiza OCR em um PDF e extrai o texto
|
* Realiza OCR em um PDF e extrai o texto
|
||||||
|
* Otimizado para velocidade: worker em cache, baixa resolução, grayscale
|
||||||
*/
|
*/
|
||||||
static async performOCR(
|
static async performOCR(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
@@ -468,18 +575,23 @@ export class PDFService {
|
|||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
|
||||||
const { language = 'por', pages = 'all' } = options;
|
const { language = 'por', pages = 'all' } = options;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Primeiro converte o PDF em imagens
|
console.log(`[OCR] Iniciando OCR: idioma=${language}, páginas=${JSON.stringify(pages)}`);
|
||||||
const imageOptions: PDFToImageOptions = {
|
|
||||||
format: 'png',
|
|
||||||
density: 300,
|
|
||||||
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
|
if (imagePaths.length === 0) {
|
||||||
const worker = await createWorker(language);
|
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<{
|
const pageResults: Array<{
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
@@ -490,7 +602,8 @@ export class PDFService {
|
|||||||
let allText = '';
|
let allText = '';
|
||||||
let totalConfidence = 0;
|
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++) {
|
for (let i = 0; i < imagePaths.length; i++) {
|
||||||
try {
|
try {
|
||||||
const imagePath = imagePaths[i];
|
const imagePath = imagePaths[i];
|
||||||
@@ -508,16 +621,18 @@ export class PDFService {
|
|||||||
// Remove a imagem temporária
|
// Remove a imagem temporária
|
||||||
await fs.unlink(imagePath).catch(() => {});
|
await fs.unlink(imagePath).catch(() => {});
|
||||||
} catch (error) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`[OCR] Reconhecimento: ${Date.now() - t3}ms`);
|
||||||
await worker.terminate();
|
|
||||||
|
|
||||||
const averageConfidence = pageResults.length > 0
|
const averageConfidence = pageResults.length > 0
|
||||||
? totalConfidence / pageResults.length
|
? totalConfidence / pageResults.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
console.log(`[OCR] Concluído em ${totalTime}ms (${pageResults.length} páginas, confiança: ${averageConfidence.toFixed(1)}%)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: allText.trim(),
|
text: allText.trim(),
|
||||||
confidence: averageConfidence,
|
confidence: averageConfidence,
|
||||||
@@ -580,6 +695,7 @@ export class PDFService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adiciona camada de OCR a um PDF (torna o PDF pesquisável)
|
* 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(
|
static async addOCRLayerToPDF(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
@@ -588,29 +704,45 @@ export class PDFService {
|
|||||||
): Promise<{ outputPath: string; confidence: number }> {
|
): Promise<{ outputPath: string; confidence: number }> {
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
const startTime = Date.now();
|
||||||
// Primeiro, extrai o texto via OCR
|
console.log(`[OCR Layer] Iniciando adição de camada OCR...`);
|
||||||
const ocrResult = await this.performOCR(inputPath, { language, pages: 'all' });
|
|
||||||
|
|
||||||
// Carrega o PDF original
|
// Carrega o PDF original
|
||||||
const pdfBytes = await fs.readFile(inputPath);
|
const pdfBytes = await fs.readFile(inputPath);
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
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 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]);
|
const [copiedPage] = await newDoc.copyPages(pdfDoc, [i]);
|
||||||
newDoc.addPage(copiedPage);
|
newDoc.addPage(copiedPage);
|
||||||
|
|
||||||
// Adiciona texto invisível sobre a página (camada OCR)
|
// Faz OCR apenas se temos imagem para esta página
|
||||||
if (ocrResult.pages[i]) {
|
if (i < imagePaths.length) {
|
||||||
|
try {
|
||||||
|
const { data } = await worker.recognize(imagePaths[i]);
|
||||||
|
totalConfidence += data.confidence;
|
||||||
|
pagesProcessed++;
|
||||||
|
|
||||||
const newPage = newDoc.getPage(i);
|
const newPage = newDoc.getPage(i);
|
||||||
const { height } = newPage.getSize();
|
const { height } = newPage.getSize();
|
||||||
|
|
||||||
// Desenha texto invisível (transparente) sobre a página
|
// Adiciona texto invisível (camada OCR pesquisável)
|
||||||
const lines = ocrResult.pages[i].text.split('\n');
|
const lines = data.text.split('\n');
|
||||||
let yPosition = height - 20;
|
let yPosition = height - 20;
|
||||||
|
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
@@ -619,14 +751,21 @@ export class PDFService {
|
|||||||
x: 10,
|
x: 10,
|
||||||
y: yPosition,
|
y: yPosition,
|
||||||
size: 8,
|
size: 8,
|
||||||
color: rgb(0, 0, 0), // Texto preto mas será invisível
|
color: rgb(0, 0, 0),
|
||||||
opacity: 0.01, // Quase invisível mas indexável
|
opacity: 0.01,
|
||||||
});
|
});
|
||||||
yPosition -= 12;
|
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
|
// Salva o PDF com camada OCR
|
||||||
const finalOutputPath = outputPath || path.join(
|
const finalOutputPath = outputPath || path.join(
|
||||||
@@ -637,9 +776,13 @@ export class PDFService {
|
|||||||
const newPdfBytes = await newDoc.save();
|
const newPdfBytes = await newDoc.save();
|
||||||
await fs.writeFile(finalOutputPath, newPdfBytes);
|
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 {
|
return {
|
||||||
outputPath: finalOutputPath,
|
outputPath: finalOutputPath,
|
||||||
confidence: ocrResult.confidence,
|
confidence: avgConfidence,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao adicionar camada OCR:', 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'}`);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user