pdf: novo refator do compress

Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
rodrigo committed 2026-02-28 19:21:47 -03:00
1 parent cdc4134b16
commit ed9fe8385c
4 files changed
+115 -237

No files matched your search

+1
View File
@@ -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
+14 -12
View File
@@ -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>
+5 -5
View File
@@ -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',
}); });
+95 -220
View File
@@ -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 {
@@ -186,246 +189,122 @@ 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 de PDF
// Similar ao que iLovePDF/SmallPDF usam internamente
const gsCommand = [
'gs',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
`-dPDFSETTINGS=${preset.pdfSettings}`,
'-dNOPAUSE',
'-dQUIET',
'-dBATCH',
'-dDetectDuplicateImages=true',
'-dCompressFonts=true',
'-dSubsetFonts=true',
`-dDownsampleColorImages=true`,
`-dColorImageResolution=${preset.dpi}`,
`-dDownsampleGrayImages=true`,
`-dGrayImageResolution=${preset.dpi}`,
`-dDownsampleMonoImages=true`,
`-dMonoImageResolution=${preset.dpi}`,
`-dColorImageDownsampleType=/Bicubic`,
`-dGrayImageDownsampleType=/Bicubic`,
`-dMonoImageDownsampleType=/Bicubic`,
`-dJPEGQ=${preset.imageQuality}`,
`-sOutputFile="${finalOutputPath}"`,
`"${inputPath}"`,
].join(' ');
try {
await execAsync(gsCommand, { timeout: 300000 }); // 5 min timeout
} catch (gsError: any) {
console.error('[Compress] Erro Ghostscript:', gsError.message);
// Fallback: copia o arquivo original se Ghostscript falhar
await fs.copyFile(inputPath, finalOutputPath);
console.warn('[Compress] Fallback: arquivo copiado sem compressão');
}
// Verifica se o arquivo foi criado
let compressedSize: number;
try {
const compressedStats = await fs.stat(finalOutputPath);
compressedSize = compressedStats.size;
} catch {
// Arquivo não criado, fallback
await fs.copyFile(inputPath, finalOutputPath);
compressedSize = originalSize;
}
// Se o arquivo comprimido ficou MAIOR, usa o original
if (compressedSize > originalSize) {
console.warn(`[Compress] Comprimido ficou maior (${compressedSize} > ${originalSize}), usando original`);
await fs.copyFile(inputPath, finalOutputPath);
compressedSize = originalSize;
}
// Adiciona OCR se solicitado
if (enableOCR) {
try {
const ocrResult = await this.addOCRLayerToPDF(finalOutputPath, undefined, ocrLanguage);
// Substitui pelo arquivo com OCR
await fs.rename(ocrResult.outputPath, finalOutputPath);
const ocrStats = await fs.stat(finalOutputPath);
compressedSize = ocrStats.size;
console.log(`[Compress] OCR adicionado com sucesso`);
} catch (ocrError) {
console.warn(`[Compress] Erro ao adicionar OCR:`, ocrError);
}
}
const compressedSize = compressedBytes.length;
const reduction = ((originalSize - compressedSize) / originalSize) * 100; const 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 +315,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(() => {});
}
} }
} }