pdf: novo refator do compress
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
cdc4134b16
commit
8f826ab223
4 files changed
+297
-337
No files matched your search
@@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y chromium \
|
|||||||
libgconf-2-4 \
|
libgconf-2-4 \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
graphicsmagick \
|
graphicsmagick \
|
||||||
|
ghostscript \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
&& ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
const [history, setHistory] = useState<any[]>([]);
|
const [history, setHistory] = useState<any[]>([]);
|
||||||
|
|
||||||
// Configurações para cada operação
|
// 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 [enableOCR, setEnableOCR] = useState(false);
|
||||||
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
|
const [splitType, setSplitType] = useState<'pages' | 'range' | 'chunks'>('pages');
|
||||||
const [splitPages, setSplitPages] = useState<string>('1,2,3');
|
const [splitPages, setSplitPages] = useState<string>('1,2,3');
|
||||||
@@ -107,7 +107,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
|
|
||||||
switch (selectedOperation) {
|
switch (selectedOperation) {
|
||||||
case 'compress':
|
case 'compress':
|
||||||
formData.append('quality', compressQuality.toString());
|
formData.append('quality', compressQuality);
|
||||||
formData.append('enableOCR', enableOCR.toString());
|
formData.append('enableOCR', enableOCR.toString());
|
||||||
response = await axios.post('/api/pdf/compress', formData, config);
|
response = await axios.post('/api/pdf/compress', formData, config);
|
||||||
toast.success('PDFs comprimidos com sucesso!');
|
toast.success('PDFs comprimidos com sucesso!');
|
||||||
@@ -456,18 +456,20 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">
|
<label className="block text-sm font-medium mb-2">
|
||||||
Qualidade ({compressQuality}%)
|
Nível de Compressão
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="range"
|
|
||||||
min="50"
|
|
||||||
max="100"
|
|
||||||
value={compressQuality}
|
value={compressQuality}
|
||||||
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
|
onChange={(e) => setCompressQuality(e.target.value as any)}
|
||||||
className="w-full"
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
/>
|
>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<option value="extreme">Extrema (72 DPI) - Máxima redução, visualização na tela</option>
|
||||||
50-70%: Máxima compressão | 80-90%: Equilíbrio ideal | 90-100%: Alta fidelidade
|
<option value="low">Alta compressão (100 DPI) - Arquivos pequenos</option>
|
||||||
|
<option value="medium">Equilibrada (150 DPI) - Recomendado</option>
|
||||||
|
<option value="high">Qualidade (200 DPI) - Para impressão</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
💡 Usa Ghostscript, mesmo motor do iLovePDF e SmallPDF
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ export class PDFController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quality = req.body.quality ? parseInt(req.body.quality) : 85; // Padrão 85 para boa qualidade com compressão agressiva
|
// Presets de qualidade: extreme, low, medium, high (padrão: medium)
|
||||||
const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined;
|
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 enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
|
||||||
|
|
||||||
const arquivosOriginais: string[] = [];
|
const arquivosOriginais: string[] = [];
|
||||||
@@ -88,7 +89,7 @@ export class PDFController {
|
|||||||
arquivosOriginais: [],
|
arquivosOriginais: [],
|
||||||
arquivosResultado: [],
|
arquivosResultado: [],
|
||||||
tamanhoOriginal: BigInt(0),
|
tamanhoOriginal: BigInt(0),
|
||||||
parametros: { quality, maxSizeMB },
|
parametros: { quality },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +101,9 @@ export class PDFController {
|
|||||||
arquivosOriginais.push(file.path);
|
arquivosOriginais.push(file.path);
|
||||||
tamanhoOriginal += BigInt(file.size);
|
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, {
|
const result = await PDFService.compressPDF(file.path, undefined, {
|
||||||
quality,
|
quality,
|
||||||
maxSizeMB,
|
|
||||||
enableOCR,
|
enableOCR,
|
||||||
ocrLanguage: 'por',
|
ocrLanguage: 'por',
|
||||||
});
|
});
|
||||||
|
|||||||
+277
-320
@@ -8,6 +8,10 @@ import path from 'path';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import { createWriteStream } from 'fs';
|
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
|
* Serviço completo de manipulação de PDFs
|
||||||
@@ -15,10 +19,9 @@ import { createWriteStream } from 'fs';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
interface CompressOptions {
|
interface CompressOptions {
|
||||||
quality?: number; // 0-100, padrão 85 para boa qualidade com compressão agressiva
|
quality?: 'low' | 'medium' | 'high' | 'extreme'; // Presets de qualidade estilo iLovePDF
|
||||||
maxSizeMB?: number;
|
enableOCR?: boolean;
|
||||||
enableOCR?: boolean; // Integra OCR durante a compressão (evita dupla conversão)
|
ocrLanguage?: string;
|
||||||
ocrLanguage?: string; // Idioma do OCR, padrão 'por'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitOptions {
|
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 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
|
// Pool de workers do Tesseract - 4 workers para processamento paralelo
|
||||||
private static workerCache: Map<string, Worker> = new Map();
|
private static readonly WORKER_POOL_SIZE = 4;
|
||||||
private static workerInitializing: Map<string, Promise<Worker>> = new Map();
|
private static workerPool: Worker[] = [];
|
||||||
|
private static workerBusy: boolean[] = [];
|
||||||
|
private static workerLanguage: string = '';
|
||||||
|
private static workerInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtém ou cria um worker Tesseract persistente para o idioma especificado
|
* Inicializa pool de workers Tesseract para processamento paralelo
|
||||||
* Worker é inicializado uma única vez e reutilizado em chamadas subsequentes
|
|
||||||
*/
|
*/
|
||||||
private static async getWorker(language: string): Promise<Worker> {
|
private static async initWorkerPool(language: string): Promise<void> {
|
||||||
// Se já existe um worker pronto, retorna
|
// Se já está inicializado com o mesmo idioma, retorna
|
||||||
if (this.workerCache.has(language)) {
|
if (this.workerPool.length === this.WORKER_POOL_SIZE && this.workerLanguage === language) {
|
||||||
return this.workerCache.get(language)!;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se está inicializando, aguarda
|
// Se está inicializando, aguarda
|
||||||
if (this.workerInitializing.has(language)) {
|
if (this.workerInitPromise) {
|
||||||
return this.workerInitializing.get(language)!;
|
await this.workerInitPromise;
|
||||||
|
if (this.workerLanguage === language) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cria novo worker e armazena a promise de inicialização
|
// Encerra workers antigos se mudou idioma
|
||||||
const initPromise = (async () => {
|
if (this.workerPool.length > 0) {
|
||||||
console.log(`[OCR] Inicializando worker Tesseract para "${language}"...`);
|
await this.terminateWorkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workerInitPromise = (async () => {
|
||||||
|
console.log(`[OCR] Inicializando pool de ${this.WORKER_POOL_SIZE} workers Tesseract (${language})...`);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const worker = await createWorker(language);
|
|
||||||
const elapsed = Date.now() - startTime;
|
const workerPromises = Array.from({ length: this.WORKER_POOL_SIZE }, async () => {
|
||||||
console.log(`[OCR] Worker "${language}" pronto em ${elapsed}ms`);
|
const worker = await createWorker(language);
|
||||||
this.workerCache.set(language, worker);
|
return worker;
|
||||||
this.workerInitializing.delete(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);
|
await this.workerInitPromise;
|
||||||
return initPromise;
|
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:
|
* Comprime um PDF usando Ghostscript (padrão da indústria - usado por iLovePDF, SmallPDF, etc.)
|
||||||
* - PDFs vetoriais/texto: usa compressão de streams (não aumenta tamanho)
|
* Presets de qualidade:
|
||||||
* - PDFs escaneados/imagens: converte para JPEG comprimido (redução significativa)
|
* - extreme: Máxima compressão (72 DPI) - reduções de 70-90%
|
||||||
* Detecta automaticamente o tipo de PDF baseado na proporção tamanho/páginas e presença de texto.
|
* - 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(
|
static async compressPDF(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
outputPath?: string,
|
outputPath?: string,
|
||||||
options: CompressOptions = {}
|
options: CompressOptions = {}
|
||||||
): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> {
|
): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> {
|
||||||
const tempFiles: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
quality = 85,
|
quality = 'medium',
|
||||||
enableOCR = false,
|
enableOCR = false,
|
||||||
ocrLanguage = 'por',
|
ocrLanguage = 'por',
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Lê o PDF original
|
// Lê tamanho original
|
||||||
const pdfBytes = await fs.readFile(inputPath);
|
const stats = await fs.stat(inputPath);
|
||||||
const originalSize = pdfBytes.length;
|
const originalSize = stats.size;
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
// Define configurações baseadas no preset de qualidade
|
||||||
ignoreEncryption: true,
|
const presets: Record<string, { dpi: number; imageQuality: number; pdfSettings: string }> = {
|
||||||
});
|
extreme: { dpi: 72, imageQuality: 30, pdfSettings: '/screen' },
|
||||||
const totalPages = pdfDoc.getPageCount();
|
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 ==========
|
const preset = presets[quality] || presets.medium;
|
||||||
// Calcula tamanho médio por página
|
|
||||||
const avgSizePerPage = originalSize / totalPages;
|
|
||||||
const isLikelyScanned = avgSizePerPage > 150 * 1024; // > 150KB por página = provavelmente escaneado
|
|
||||||
|
|
||||||
// Verifica se tem texto embarcado
|
console.log(`[Compress] Iniciando compressão Ghostscript (preset=${quality}, DPI=${preset.dpi})`);
|
||||||
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
|
|
||||||
|
|
||||||
if (hasEmbeddedText && enableOCR) {
|
// Define caminho de saída
|
||||||
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
|
|
||||||
const finalOutputPath = outputPath || path.join(
|
const finalOutputPath = outputPath || path.join(
|
||||||
this.OUTPUT_DIR,
|
this.OUTPUT_DIR,
|
||||||
`compressed-${uuidv4()}.pdf`
|
`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 reduction = ((originalSize - compressedSize) / originalSize) * 100;
|
||||||
const elapsed = Date.now() - startTime;
|
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 {
|
return {
|
||||||
outputPath: finalOutputPath,
|
outputPath: finalOutputPath,
|
||||||
@@ -436,10 +390,6 @@ export class PDFService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao comprimir PDF:', error);
|
console.error('Erro ao comprimir PDF:', error);
|
||||||
throw new Error(`Falha ao comprimir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
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) ==========
|
// ========== FASE 2: Tesseract OCR PARALELO (fallback para PDFs escaneados) ==========
|
||||||
console.log(`[OCR] ⚠️ PDF escaneado detectado (sem texto embarcado). Usando Tesseract...`);
|
|
||||||
|
|
||||||
const imagePaths = await this.pdfToImagesForOCR(inputPath, pages);
|
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] 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) {
|
if (imagePaths.length === 0) {
|
||||||
return { text: '', confidence: 0, pages: [] };
|
return { text: '', confidence: 0, pages: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = await this.getWorker(language);
|
// Inicializa pool de workers
|
||||||
|
await this.initWorkerPool(language);
|
||||||
|
|
||||||
const pageResults: Array<{
|
const pageResults: Array<{
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
text: string;
|
text: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
}> = [];
|
}> = new Array(imagePaths.length);
|
||||||
|
|
||||||
let allText = '';
|
|
||||||
let totalConfidence = 0;
|
|
||||||
|
|
||||||
|
// Processa em paralelo
|
||||||
const t3 = Date.now();
|
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 {
|
try {
|
||||||
const imagePath = imagePaths[i];
|
|
||||||
const { data } = await worker.recognize(imagePath);
|
const { data } = await worker.recognize(imagePath);
|
||||||
|
pageResults[i] = {
|
||||||
pageResults.push({
|
|
||||||
pageNumber: i + 1,
|
pageNumber: i + 1,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
confidence: data.confidence,
|
confidence: data.confidence,
|
||||||
});
|
};
|
||||||
|
console.log(`[OCR] Página ${i + 1}/${imagePaths.length} processada`);
|
||||||
allText += data.text + '\n\n';
|
|
||||||
totalConfidence += data.confidence;
|
|
||||||
|
|
||||||
await fs.unlink(imagePath).catch(() => {});
|
|
||||||
} catch (error) {
|
} 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
|
await Promise.all(ocrPromises);
|
||||||
? totalConfidence / pageResults.length
|
console.log(`[OCR] Tesseract paralelo: ${Date.now() - t3}ms`);
|
||||||
: 0;
|
|
||||||
|
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;
|
const totalTime = Date.now() - startTime;
|
||||||
console.log(`[OCR] Concluído em ${totalTime}ms (${pageResults.length} páginas, confiança: ${averageConfidence.toFixed(1)}%)`);
|
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:
|
* Abordagem em 2 fases:
|
||||||
* 1. RÁPIDA: Usa pdf-parse para extrair texto embarcado e adicionar como camada invisível
|
* 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 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(
|
static async addOCRLayerToPDF(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
@@ -984,58 +935,63 @@ export class PDFService {
|
|||||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
const totalPages = pdfDoc.getPageCount();
|
const totalPages = pdfDoc.getPageCount();
|
||||||
|
|
||||||
// ========== FASE 1: Tentar extrair texto com pdf-parse (instantâneo) ==========
|
// ========== FASE 1: Verificar se já tem texto embarcado ==========
|
||||||
let pageTexts: string[] = [];
|
|
||||||
let hasEmbeddedText = false;
|
let hasEmbeddedText = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const t1 = Date.now();
|
|
||||||
const pdfData = await pdfParse(pdfBytes);
|
const pdfData = await pdfParse(pdfBytes);
|
||||||
const fullText = pdfData.text || '';
|
const fullText = pdfData.text || '';
|
||||||
|
const textPerPage = fullText.trim().length / totalPages;
|
||||||
// Verifica se tem texto significativo
|
hasEmbeddedText = textPerPage > 50;
|
||||||
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`);
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.warn(`[OCR Layer] pdf-parse falhou:`, parseError);
|
console.warn(`[OCR Layer] pdf-parse falhou:`, parseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== FASE 2: Fallback Tesseract para PDFs escaneados ==========
|
if (hasEmbeddedText) {
|
||||||
if (!hasEmbeddedText) {
|
console.log(`[OCR Layer] ✅ PDF já tem texto embarcado, retornando original`);
|
||||||
console.log(`[OCR Layer] ⚠️ PDF escaneado, usando Tesseract...`);
|
|
||||||
|
|
||||||
const imagePaths = await this.pdfToImagesForOCR(inputPath, 'all');
|
const finalOutputPath = outputPath || path.join(
|
||||||
const worker = await this.getWorker(language);
|
this.OUTPUT_DIR,
|
||||||
|
`ocr-${uuidv4()}.pdf`
|
||||||
pageTexts = [];
|
);
|
||||||
for (let i = 0; i < imagePaths.length; i++) {
|
|
||||||
try {
|
await fs.copyFile(inputPath, finalOutputPath);
|
||||||
const { data } = await worker.recognize(imagePaths[i]);
|
|
||||||
pageTexts.push(data.text);
|
return {
|
||||||
await fs.unlink(imagePaths[i]).catch(() => {});
|
outputPath: finalOutputPath,
|
||||||
} catch (error) {
|
confidence: 99.0,
|
||||||
console.error(`[OCR Layer] Erro Tesseract página ${i + 1}:`, error);
|
};
|
||||||
pageTexts.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 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 ==========
|
// ========== Cria PDF com camada de texto invisível ==========
|
||||||
const newDoc = await PDFDocument.create();
|
const newDoc = await PDFDocument.create();
|
||||||
|
|
||||||
@@ -1074,13 +1030,12 @@ export class PDFService {
|
|||||||
const newPdfBytes = await newDoc.save();
|
const newPdfBytes = await newDoc.save();
|
||||||
await fs.writeFile(finalOutputPath, newPdfBytes);
|
await fs.writeFile(finalOutputPath, newPdfBytes);
|
||||||
|
|
||||||
const confidence = hasEmbeddedText ? 99.0 : 85.0;
|
|
||||||
const totalTime = Date.now() - startTime;
|
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 {
|
return {
|
||||||
outputPath: finalOutputPath,
|
outputPath: finalOutputPath,
|
||||||
confidence,
|
confidence: 85.0,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao adicionar camada OCR:', 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
|
* Chame no startup do servidor para eliminar delay na primeira operação OCR
|
||||||
*/
|
*/
|
||||||
static async warmupOCR(language: string = 'por'): Promise<void> {
|
static async warmupOCR(language: string = 'por'): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`[OCR] Pré-aquecendo worker para "${language}"...`);
|
console.log(`[OCR] Pré-aquecendo pool de workers para "${language}"...`);
|
||||||
await this.getWorker(language);
|
await this.initWorkerPool(language);
|
||||||
console.log(`[OCR] Worker "${language}" pronto para uso`);
|
console.log(`[OCR] Pool de ${this.WORKER_POOL_SIZE} workers pronto para uso`);
|
||||||
} catch (error) {
|
} 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)
|
* Encerra todos os workers Tesseract (chamar no shutdown do servidor)
|
||||||
*/
|
*/
|
||||||
static async terminateWorkers(): Promise<void> {
|
static async terminateWorkers(): Promise<void> {
|
||||||
for (const [lang, worker] of this.workerCache) {
|
for (let i = 0; i < this.workerPool.length; i++) {
|
||||||
try {
|
try {
|
||||||
await worker.terminate();
|
await this.workerPool[i].terminate();
|
||||||
console.log(`[OCR] Worker "${lang}" encerrado`);
|
console.log(`[OCR] Worker ${i + 1} encerrado`);
|
||||||
} catch (error) {
|
} 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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user