pdf: refatorar compressPDF
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
d381d17435
commit
fcf2778a28
3 files changed
+190
-49
No files matched your search
@@ -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(50);
|
const [compressQuality, setCompressQuality] = useState(85);
|
||||||
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');
|
||||||
@@ -460,14 +460,14 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="30"
|
min="50"
|
||||||
max="100"
|
max="100"
|
||||||
value={compressQuality}
|
value={compressQuality}
|
||||||
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
|
onChange={(e) => setCompressQuality(parseInt(e.target.value))}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
30-50%: Máxima compressão (>60% redução) | 70-100%: Alta qualidade
|
50-70%: Máxima compressão | 80-90%: Equilíbrio ideal | 90-100%: Alta fidelidade
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ export const PDFManipulationPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
⚠️ OCR aumenta o tempo de processamento significativamente
|
OCR torna o PDF pesquisável. É instantâneo para PDFs digitais.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class PDFController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quality = req.body.quality ? parseInt(req.body.quality) : 50; // Padrão 50 para mais compressão
|
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;
|
const maxSizeMB = req.body.maxSizeMB ? parseInt(req.body.maxSizeMB) : undefined;
|
||||||
const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
|
const enableOCR = req.body.enableOCR === 'true' || req.body.enableOCR === true;
|
||||||
|
|
||||||
@@ -100,25 +100,12 @@ export class PDFController {
|
|||||||
arquivosOriginais.push(file.path);
|
arquivosOriginais.push(file.path);
|
||||||
tamanhoOriginal += BigInt(file.size);
|
tamanhoOriginal += BigInt(file.size);
|
||||||
|
|
||||||
let processedPath = file.path;
|
// Comprime com OCR integrado (evita dupla conversão de imagens)
|
||||||
|
const result = await PDFService.compressPDF(file.path, undefined, {
|
||||||
// Adiciona OCR se solicitado
|
|
||||||
if (enableOCR) {
|
|
||||||
try {
|
|
||||||
const ocrResult = await PDFService.addOCRLayerToPDF(file.path);
|
|
||||||
processedPath = ocrResult.outputPath;
|
|
||||||
// Remove arquivo temporário anterior
|
|
||||||
if (processedPath !== file.path) {
|
|
||||||
await fs.unlink(file.path).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch (ocrError) {
|
|
||||||
console.warn('Erro ao adicionar OCR, continuando sem OCR:', ocrError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await PDFService.compressPDF(processedPath, undefined, {
|
|
||||||
quality,
|
quality,
|
||||||
maxSizeMB,
|
maxSizeMB,
|
||||||
|
enableOCR,
|
||||||
|
ocrLanguage: 'por',
|
||||||
});
|
});
|
||||||
|
|
||||||
arquivosResultado.push(result.outputPath);
|
arquivosResultado.push(result.outputPath);
|
||||||
@@ -132,10 +119,6 @@ export class PDFController {
|
|||||||
outputPath: result.outputPath,
|
outputPath: result.outputPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove o arquivo intermediário
|
|
||||||
if (processedPath !== file.path && processedPath !== result.outputPath) {
|
|
||||||
await fs.unlink(processedPath).catch(() => {});
|
|
||||||
}
|
|
||||||
// Remove o arquivo original
|
// Remove o arquivo original
|
||||||
await fs.unlink(file.path).catch(() => {});
|
await fs.unlink(file.path).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-23
@@ -15,8 +15,10 @@ import { createWriteStream } from 'fs';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
interface CompressOptions {
|
interface CompressOptions {
|
||||||
quality?: number; // 0-100, padrão 95 para qualidade alta
|
quality?: number; // 0-100, padrão 85 para boa qualidade com compressão agressiva
|
||||||
maxSizeMB?: number;
|
maxSizeMB?: number;
|
||||||
|
enableOCR?: boolean; // Integra OCR durante a compressão (evita dupla conversão)
|
||||||
|
ocrLanguage?: string; // Idioma do OCR, padrão 'por'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitOptions {
|
interface SplitOptions {
|
||||||
@@ -184,50 +186,199 @@ export class PDFService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprime um PDF de forma agressiva para redução >60%
|
* Comprime um PDF de forma agressiva via conversão para imagens JPEG comprimidas.
|
||||||
* Usa abordagem simplificada e eficiente
|
* Abordagem: PDF → Imagens (pdf2pic) → JPEG comprimido (sharp+mozjpeg) → Novo PDF
|
||||||
|
* Suporta OCR integrado para evitar dupla conversão de imagens.
|
||||||
*/
|
*/
|
||||||
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[] = []; // Rastreia arquivos temporários para limpeza
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
const { quality = 50 } = options;
|
const {
|
||||||
|
quality = 85,
|
||||||
|
enableOCR = false,
|
||||||
|
ocrLanguage = 'por',
|
||||||
|
} = options;
|
||||||
|
|
||||||
// Lê o PDF original
|
// Lê o PDF original
|
||||||
const pdfBytes = await fs.readFile(inputPath);
|
const pdfBytes = await fs.readFile(inputPath);
|
||||||
const originalSize = pdfBytes.length;
|
const originalSize = pdfBytes.length;
|
||||||
|
|
||||||
// Carrega o PDF
|
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
||||||
updateMetadata: false,
|
|
||||||
ignoreEncryption: true,
|
ignoreEncryption: true,
|
||||||
});
|
});
|
||||||
|
const totalPages = pdfDoc.getPageCount();
|
||||||
|
|
||||||
// Remove TODOS os metadados
|
console.log(`[Compress] Iniciando compressão de ${totalPages} páginas (quality=${quality}, OCR=${enableOCR})`);
|
||||||
pdfDoc.setTitle('');
|
|
||||||
pdfDoc.setAuthor('');
|
|
||||||
pdfDoc.setSubject('');
|
|
||||||
pdfDoc.setKeywords([]);
|
|
||||||
pdfDoc.setProducer('');
|
|
||||||
pdfDoc.setCreator('');
|
|
||||||
|
|
||||||
// Para compressão agressiva, reconstrói o PDF do zero
|
// ========== FASE 1: Extrair texto via pdf-parse (instantâneo) se OCR habilitado ==========
|
||||||
|
let pageTexts: string[] = [];
|
||||||
|
let hasEmbeddedText = false;
|
||||||
|
|
||||||
|
if (enableOCR) {
|
||||||
|
try {
|
||||||
|
const pdfData = await pdfParse(pdfBytes);
|
||||||
|
const fullText = pdfData.text || '';
|
||||||
|
hasEmbeddedText = fullText.trim().length > (totalPages * 30);
|
||||||
|
|
||||||
|
if (hasEmbeddedText) {
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[Compress] ✅ Texto embarcado extraído via pdf-parse`);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn(`[Compress] pdf-parse falhou:`, parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FASE 2: Converter páginas em imagens e comprimir ==========
|
||||||
|
// DPI baseado na qualidade solicitada
|
||||||
|
const density = quality >= 90 ? 250 : quality >= 70 ? 200 : 150;
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
// Precisa Tesseract para OCR? (apenas se OCR habilitado e sem texto embarcado)
|
||||||
|
const needsTesseractOCR = enableOCR && !hasEmbeddedText;
|
||||||
|
let tesseractWorker: Worker | null = null;
|
||||||
|
if (needsTesseractOCR) {
|
||||||
|
tesseractWorker = await this.getWorker(ocrLanguage);
|
||||||
|
console.log(`[Compress] ⚠️ PDF escaneado, 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);
|
||||||
|
|
||||||
|
// Se precisa Tesseract OCR, faz antes de comprimir a imagem
|
||||||
|
if (needsTesseractOCR && tesseractWorker) {
|
||||||
|
try {
|
||||||
|
// Otimiza para OCR: grayscale + normalização
|
||||||
|
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 progressivo com mozjpeg
|
||||||
|
const jpegPath = path.join(this.TEMP_DIR, `compress-page-${pageNum}-${uuidv4()}.jpg`);
|
||||||
|
tempFiles.push(jpegPath);
|
||||||
|
|
||||||
|
await sharp(rawImagePath)
|
||||||
|
.jpeg({
|
||||||
|
quality,
|
||||||
|
progressive: true,
|
||||||
|
mozjpeg: true, // Mozjpeg para compressão otimizada
|
||||||
|
chromaSubsampling: quality >= 90 ? '4:4:4' : '4:2:0', // Menos subsampling em alta qualidade
|
||||||
|
})
|
||||||
|
.toFile(jpegPath);
|
||||||
|
|
||||||
|
compressedImagePaths.push(jpegPath);
|
||||||
|
|
||||||
|
// Remove imagem PNG bruta
|
||||||
|
await fs.unlink(rawImagePath).catch(() => {});
|
||||||
|
|
||||||
|
console.log(`[Compress] Página ${pageNum}/${totalPages} convertida e comprimida`);
|
||||||
|
} 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FASE 3: Montar novo PDF a partir das imagens comprimidas ==========
|
||||||
const newDoc = await PDFDocument.create();
|
const newDoc = await PDFDocument.create();
|
||||||
const pageCount = pdfDoc.getPageCount();
|
|
||||||
|
|
||||||
// Copia cada página
|
|
||||||
const copiedPages = await newDoc.copyPages(pdfDoc, Array.from({ length: pageCount }, (_, i) => i));
|
|
||||||
copiedPages.forEach((page: any) => newDoc.addPage(page));
|
|
||||||
|
|
||||||
// Salva com máxima compressão
|
for (let i = 0; i < compressedImagePaths.length; i++) {
|
||||||
|
const imgBytes = await fs.readFile(compressedImagePaths[i]);
|
||||||
|
const image = await newDoc.embedJpg(imgBytes);
|
||||||
|
|
||||||
|
// Usa as dimensões originais da página do PDF
|
||||||
|
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 camada de texto OCR (invisível) se disponí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, // Invisível mas selecionável/buscável
|
||||||
|
});
|
||||||
|
yPosition -= 12;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpa imagem temporária
|
||||||
|
await fs.unlink(compressedImagePaths[i]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salva com compressão de streams
|
||||||
const compressedBytes = await newDoc.save({
|
const compressedBytes = await newDoc.save({
|
||||||
useObjectStreams: true, // Compressão de streams
|
useObjectStreams: true,
|
||||||
addDefaultPage: false,
|
addDefaultPage: false,
|
||||||
objectsPerTick: 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define o caminho de saída
|
// Define o caminho de saída
|
||||||
@@ -236,11 +387,13 @@ export class PDFService {
|
|||||||
`compressed-${uuidv4()}.pdf`
|
`compressed-${uuidv4()}.pdf`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Salva o arquivo
|
|
||||||
await fs.writeFile(finalOutputPath, compressedBytes);
|
await fs.writeFile(finalOutputPath, compressedBytes);
|
||||||
|
|
||||||
const compressedSize = compressedBytes.length;
|
const compressedSize = compressedBytes.length;
|
||||||
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
|
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(1)}MB → ${(compressedSize / 1024 / 1024).toFixed(1)}MB (${reduction.toFixed(1)}% redução)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
outputPath: finalOutputPath,
|
outputPath: finalOutputPath,
|
||||||
@@ -251,6 +404,11 @@ 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 {
|
||||||
|
// Limpeza de qualquer arquivo temporário restante
|
||||||
|
for (const tmpFile of tempFiles) {
|
||||||
|
await fs.access(tmpFile).then(() => fs.unlink(tmpFile)).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user