775 lines
24 KiB
TypeScript
775 lines
24 KiB
TypeScript
import { PDFDocument, rgb } from 'pdf-lib';
|
|
import sharp from 'sharp';
|
|
import { createWorker } from 'tesseract.js';
|
|
import { fromPath } from 'pdf2pic';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import archiver from 'archiver';
|
|
import { createWriteStream } from 'fs';
|
|
|
|
/**
|
|
* Serviço completo de manipulação de PDFs
|
|
* Implementa compressão sem perda, divisão, mesclagem, conversão e OCR
|
|
*/
|
|
|
|
interface CompressOptions {
|
|
quality?: number; // 0-100, padrão 95 para qualidade alta
|
|
maxSizeMB?: number;
|
|
}
|
|
|
|
interface SplitOptions {
|
|
splitType: 'pages' | 'range' | 'chunks';
|
|
pages?: number[]; // Páginas específicas para extrair
|
|
ranges?: Array<{ start: number; end: number }>; // Intervalos de páginas
|
|
chunkSize?: number; // Tamanho de cada chunk em páginas
|
|
}
|
|
|
|
interface MergeOptions {
|
|
filePaths: string[];
|
|
outputPath: string;
|
|
}
|
|
|
|
interface PDFToImageOptions {
|
|
format?: 'png' | 'jpg' | 'webp';
|
|
quality?: number;
|
|
density?: number; // DPI, padrão 300
|
|
pages?: number[] | 'all'; // Páginas específicas ou todas
|
|
}
|
|
|
|
interface ImageToPDFOptions {
|
|
imagePaths: string[];
|
|
outputPath: string;
|
|
pageSize?: { width: number; height: number };
|
|
fitToPage?: boolean;
|
|
}
|
|
|
|
interface OCROptions {
|
|
language?: string; // padrão 'por' (português)
|
|
pages?: number[] | 'all';
|
|
}
|
|
|
|
interface OCRResult {
|
|
text: string;
|
|
confidence: number;
|
|
pages: Array<{
|
|
pageNumber: number;
|
|
text: string;
|
|
confidence: number;
|
|
}>;
|
|
}
|
|
|
|
export class PDFService {
|
|
private static readonly TEMP_DIR = path.join(process.cwd(), 'storage', 'temp', 'pdf-ops');
|
|
private static readonly OUTPUT_DIR = path.join(process.cwd(), 'storage', 'pdf-output');
|
|
|
|
/**
|
|
* Inicializa os diretórios necessários
|
|
*/
|
|
static async initialize(): Promise<void> {
|
|
try {
|
|
await fs.mkdir(this.TEMP_DIR, { recursive: true });
|
|
await fs.mkdir(this.OUTPUT_DIR, { recursive: true });
|
|
} catch (error) {
|
|
console.error('Erro ao criar diretórios:', error);
|
|
throw new Error('Falha ao inicializar diretórios para operações de PDF');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Comprime um PDF de forma agressiva para redução >60%
|
|
* Otimiza imagens, remove objetos duplicados e metadados
|
|
*/
|
|
static async compressPDF(
|
|
inputPath: string,
|
|
outputPath?: string,
|
|
options: CompressOptions = {}
|
|
): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
const { quality = 50, maxSizeMB } = options; // Qualidade padrão 50 para maior compressão
|
|
|
|
// Lê o PDF original
|
|
const pdfBytes = await fs.readFile(inputPath);
|
|
const originalSize = pdfBytes.length;
|
|
|
|
// Carrega o PDF
|
|
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
|
updateMetadata: false,
|
|
});
|
|
|
|
// Remove todos os metadados
|
|
pdfDoc.setTitle('');
|
|
pdfDoc.setAuthor('');
|
|
pdfDoc.setSubject('');
|
|
pdfDoc.setKeywords([]);
|
|
pdfDoc.setProducer('');
|
|
pdfDoc.setCreator('');
|
|
|
|
// Cria um novo PDF otimizado
|
|
const optimizedDoc = await PDFDocument.create();
|
|
const pages = pdfDoc.getPages();
|
|
|
|
// Processa cada página
|
|
for (let i = 0; i < pages.length; i++) {
|
|
const page = pages[i];
|
|
const { width, height } = page.getSize();
|
|
|
|
// Renderiza a página como imagem para recomprimir
|
|
const converter = fromPath(inputPath, {
|
|
density: 150, // DPI reduzido para menor tamanho
|
|
saveFilename: `temp-page-${i}`,
|
|
savePath: this.TEMP_DIR,
|
|
format: 'png',
|
|
width: Math.floor(width * 2), // Mantém resolução razoável
|
|
height: Math.floor(height * 2),
|
|
});
|
|
|
|
try {
|
|
const result = await converter(i + 1, { responseType: 'buffer' });
|
|
|
|
if (!result || !result.buffer) {
|
|
// Se falhar a conversão, copia a página original
|
|
const [copiedPage] = await optimizedDoc.copyPages(pdfDoc, [i]);
|
|
optimizedDoc.addPage(copiedPage);
|
|
continue;
|
|
}
|
|
|
|
// Comprime a imagem agressivamente
|
|
const compressedImageBuffer = await sharp(result.buffer)
|
|
.jpeg({
|
|
quality: quality, // Compressão agressiva
|
|
progressive: true,
|
|
optimizeScans: true,
|
|
})
|
|
.resize({
|
|
width: Math.floor(width * 2.5), // Resolução para manter legibilidade
|
|
height: Math.floor(height * 2.5),
|
|
fit: 'inside',
|
|
withoutEnlargement: true,
|
|
})
|
|
.toBuffer();
|
|
|
|
// Adiciona imagem comprimida ao novo PDF
|
|
const newPage = optimizedDoc.addPage([width, height]);
|
|
const pdfImage = await optimizedDoc.embedJpg(compressedImageBuffer);
|
|
|
|
newPage.drawImage(pdfImage, {
|
|
x: 0,
|
|
y: 0,
|
|
width: width,
|
|
height: height,
|
|
});
|
|
|
|
// Remove arquivo temporário
|
|
try {
|
|
await fs.unlink(result.path);
|
|
} catch {}
|
|
} catch (pageError) {
|
|
console.warn(`Erro ao processar página ${i + 1}, usando original:`, pageError);
|
|
// Em caso de erro, copia a página original
|
|
const [copiedPage] = await optimizedDoc.copyPages(pdfDoc, [i]);
|
|
optimizedDoc.addPage(copiedPage);
|
|
}
|
|
}
|
|
|
|
// Salva com máxima compressão
|
|
const compressedBytes = await optimizedDoc.save({
|
|
useObjectStreams: true,
|
|
addDefaultPage: false,
|
|
objectsPerTick: 100,
|
|
});
|
|
|
|
// Define o caminho de saída
|
|
const finalOutputPath = outputPath || path.join(
|
|
this.OUTPUT_DIR,
|
|
`compressed-${uuidv4()}.pdf`
|
|
);
|
|
|
|
// Verifica se atingiu o tamanho máximo
|
|
let finalBytes = compressedBytes;
|
|
if (maxSizeMB && compressedBytes.length > maxSizeMB * 1024 * 1024) {
|
|
// Se ainda estiver grande, tenta compressão adicional com qualidade ainda menor
|
|
const extraCompressedDoc = await PDFDocument.create();
|
|
const compDoc = await PDFDocument.load(compressedBytes);
|
|
const compPages = compDoc.getPages();
|
|
|
|
for (let i = 0; i < compPages.length; i++) {
|
|
const [copiedPage] = await extraCompressedDoc.copyPages(compDoc, [i]);
|
|
extraCompressedDoc.addPage(copiedPage);
|
|
}
|
|
|
|
finalBytes = await extraCompressedDoc.save({
|
|
useObjectStreams: true,
|
|
addDefaultPage: false,
|
|
});
|
|
}
|
|
|
|
// Salva o arquivo comprimido
|
|
await fs.writeFile(finalOutputPath, finalBytes);
|
|
|
|
const compressedSize = finalBytes.length;
|
|
const reduction = ((originalSize - compressedSize) / originalSize) * 100;
|
|
|
|
return {
|
|
outputPath: finalOutputPath,
|
|
originalSize,
|
|
compressedSize,
|
|
reduction: Math.max(0, reduction),
|
|
};
|
|
} catch (error) {
|
|
console.error('Erro ao comprimir PDF:', error);
|
|
throw new Error(`Falha ao comprimir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Divide um PDF em múltiplos arquivos
|
|
*/
|
|
static async splitPDF(
|
|
inputPath: string,
|
|
options: SplitOptions
|
|
): Promise<string[]> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
const pdfBytes = await fs.readFile(inputPath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
const totalPages = pdfDoc.getPageCount();
|
|
const outputPaths: string[] = [];
|
|
|
|
if (options.splitType === 'pages' && options.pages) {
|
|
// Divide em páginas específicas
|
|
for (const pageNum of options.pages) {
|
|
if (pageNum < 1 || pageNum > totalPages) {
|
|
throw new Error(`Página ${pageNum} inválida. O PDF tem ${totalPages} páginas.`);
|
|
}
|
|
|
|
const newDoc = await PDFDocument.create();
|
|
const [copiedPage] = await newDoc.copyPages(pdfDoc, [pageNum - 1]);
|
|
newDoc.addPage(copiedPage);
|
|
|
|
const outputPath = path.join(this.OUTPUT_DIR, `split-page-${pageNum}-${uuidv4()}.pdf`);
|
|
const newPdfBytes = await newDoc.save();
|
|
await fs.writeFile(outputPath, newPdfBytes);
|
|
outputPaths.push(outputPath);
|
|
}
|
|
} else if (options.splitType === 'range' && options.ranges) {
|
|
// Divide em intervalos de páginas
|
|
for (let i = 0; i < options.ranges.length; i++) {
|
|
const { start, end } = options.ranges[i];
|
|
|
|
if (start < 1 || end > totalPages || start > end) {
|
|
throw new Error(`Intervalo inválido: ${start}-${end}. O PDF tem ${totalPages} páginas.`);
|
|
}
|
|
|
|
const newDoc = await PDFDocument.create();
|
|
const pageIndices = Array.from({ length: end - start + 1 }, (_, idx) => start - 1 + idx);
|
|
const copiedPages = await newDoc.copyPages(pdfDoc, pageIndices);
|
|
copiedPages.forEach((page) => newDoc.addPage(page));
|
|
|
|
const outputPath = path.join(this.OUTPUT_DIR, `split-range-${start}-${end}-${uuidv4()}.pdf`);
|
|
const newPdfBytes = await newDoc.save();
|
|
await fs.writeFile(outputPath, newPdfBytes);
|
|
outputPaths.push(outputPath);
|
|
}
|
|
} else if (options.splitType === 'chunks' && options.chunkSize) {
|
|
// Divide em chunks de tamanho fixo
|
|
const chunkSize = options.chunkSize;
|
|
const numChunks = Math.ceil(totalPages / chunkSize);
|
|
|
|
for (let i = 0; i < numChunks; i++) {
|
|
const startPage = i * chunkSize;
|
|
const endPage = Math.min((i + 1) * chunkSize, totalPages);
|
|
|
|
const newDoc = await PDFDocument.create();
|
|
const pageIndices = Array.from({ length: endPage - startPage }, (_, idx) => startPage + idx);
|
|
const copiedPages = await newDoc.copyPages(pdfDoc, pageIndices);
|
|
copiedPages.forEach((page) => newDoc.addPage(page));
|
|
|
|
const outputPath = path.join(this.OUTPUT_DIR, `split-chunk-${i + 1}-${uuidv4()}.pdf`);
|
|
const newPdfBytes = await newDoc.save();
|
|
await fs.writeFile(outputPath, newPdfBytes);
|
|
outputPaths.push(outputPath);
|
|
}
|
|
} else {
|
|
throw new Error('Opções de divisão inválidas');
|
|
}
|
|
|
|
return outputPaths;
|
|
} catch (error) {
|
|
console.error('Erro ao dividir PDF:', error);
|
|
throw new Error(`Falha ao dividir PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mescla múltiplos PDFs em um único arquivo
|
|
*/
|
|
static async mergePDFs(options: MergeOptions): Promise<string> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
if (!options.filePaths || options.filePaths.length === 0) {
|
|
throw new Error('Nenhum arquivo fornecido para mesclagem');
|
|
}
|
|
|
|
const mergedDoc = await PDFDocument.create();
|
|
|
|
// Mescla cada PDF
|
|
for (const filePath of options.filePaths) {
|
|
try {
|
|
const pdfBytes = await fs.readFile(filePath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
const copiedPages = await mergedDoc.copyPages(pdfDoc, pdfDoc.getPageIndices());
|
|
copiedPages.forEach((page) => mergedDoc.addPage(page));
|
|
} catch (error) {
|
|
console.error(`Erro ao processar arquivo ${filePath}:`, error);
|
|
throw new Error(`Falha ao processar arquivo ${path.basename(filePath)}`);
|
|
}
|
|
}
|
|
|
|
const outputPath = options.outputPath || path.join(
|
|
this.OUTPUT_DIR,
|
|
`merged-${uuidv4()}.pdf`
|
|
);
|
|
|
|
const mergedBytes = await mergedDoc.save();
|
|
await fs.writeFile(outputPath, mergedBytes);
|
|
|
|
return outputPath;
|
|
} catch (error) {
|
|
console.error('Erro ao mesclar PDFs:', error);
|
|
throw new Error(`Falha ao mesclar PDFs: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converte páginas de PDF em imagens
|
|
*/
|
|
static async pdfToImages(
|
|
inputPath: string,
|
|
options: PDFToImageOptions = {}
|
|
): Promise<string[]> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
const {
|
|
format = 'png',
|
|
quality = 95,
|
|
density = 300,
|
|
pages = 'all',
|
|
} = options;
|
|
|
|
const pdfBytes = await fs.readFile(inputPath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
const totalPages = pdfDoc.getPageCount();
|
|
|
|
// Determina quais páginas converter
|
|
const pagesToConvert = pages === 'all'
|
|
? Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
: pages;
|
|
|
|
const outputPaths: string[] = [];
|
|
|
|
// Configura o conversor
|
|
const converter = fromPath(inputPath, {
|
|
density,
|
|
saveFilename: uuidv4(),
|
|
savePath: this.OUTPUT_DIR,
|
|
format,
|
|
width: 2480, // A4 em 300 DPI
|
|
height: 3508,
|
|
});
|
|
|
|
// Converte cada página
|
|
for (const pageNum of pagesToConvert) {
|
|
if (pageNum < 1 || pageNum > totalPages) {
|
|
console.warn(`Página ${pageNum} ignorada - fora do intervalo`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await converter(pageNum, { responseType: 'image' });
|
|
|
|
if (result && typeof result === 'object' && 'path' in result) {
|
|
const imagePath = result.path as string;
|
|
|
|
// Otimiza a imagem com sharp
|
|
const optimizedPath = path.join(
|
|
this.OUTPUT_DIR,
|
|
`page-${pageNum}-${uuidv4()}.${format}`
|
|
);
|
|
|
|
if (format === 'jpg') {
|
|
await sharp(imagePath)
|
|
.jpeg({ quality, progressive: true })
|
|
.toFile(optimizedPath);
|
|
} else if (format === 'png') {
|
|
await sharp(imagePath)
|
|
.png({ quality, progressive: true, compressionLevel: 9 })
|
|
.toFile(optimizedPath);
|
|
} else if (format === 'webp') {
|
|
await sharp(imagePath)
|
|
.webp({ quality })
|
|
.toFile(optimizedPath);
|
|
}
|
|
|
|
// Remove o arquivo temporário
|
|
await fs.unlink(imagePath).catch(() => {});
|
|
|
|
outputPaths.push(optimizedPath);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Erro ao converter página ${pageNum}:`, error);
|
|
}
|
|
}
|
|
|
|
return outputPaths;
|
|
} catch (error) {
|
|
console.error('Erro ao converter PDF para imagens:', error);
|
|
throw new Error(`Falha ao converter PDF para imagens: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converte imagens em PDF
|
|
*/
|
|
static async imagesToPDF(options: ImageToPDFOptions): Promise<string> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
if (!options.imagePaths || options.imagePaths.length === 0) {
|
|
throw new Error('Nenhuma imagem fornecida para conversão');
|
|
}
|
|
|
|
const pdfDoc = await PDFDocument.create();
|
|
|
|
for (const imagePath of options.imagePaths) {
|
|
try {
|
|
const imageBytes = await fs.readFile(imagePath);
|
|
const ext = path.extname(imagePath).toLowerCase();
|
|
|
|
let image;
|
|
if (ext === '.png') {
|
|
image = await pdfDoc.embedPng(imageBytes);
|
|
} else if (ext === '.jpg' || ext === '.jpeg') {
|
|
image = await pdfDoc.embedJpg(imageBytes);
|
|
} else {
|
|
// Converte outros formatos para PNG primeiro
|
|
const pngBuffer = await sharp(imageBytes).png().toBuffer();
|
|
image = await pdfDoc.embedPng(pngBuffer);
|
|
}
|
|
|
|
const { width, height } = image;
|
|
|
|
// Define o tamanho da página
|
|
let pageWidth = width;
|
|
let pageHeight = height;
|
|
|
|
if (options.pageSize) {
|
|
pageWidth = options.pageSize.width;
|
|
pageHeight = options.pageSize.height;
|
|
}
|
|
|
|
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
|
|
|
// Calcula dimensões para ajustar à página se necessário
|
|
if (options.fitToPage) {
|
|
const scale = Math.min(pageWidth / width, pageHeight / height);
|
|
const scaledWidth = width * scale;
|
|
const scaledHeight = height * scale;
|
|
const x = (pageWidth - scaledWidth) / 2;
|
|
const y = (pageHeight - scaledHeight) / 2;
|
|
|
|
page.drawImage(image, {
|
|
x,
|
|
y,
|
|
width: scaledWidth,
|
|
height: scaledHeight,
|
|
});
|
|
} else {
|
|
page.drawImage(image, {
|
|
x: 0,
|
|
y: 0,
|
|
width: pageWidth,
|
|
height: pageHeight,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Erro ao processar imagem ${imagePath}:`, error);
|
|
throw new Error(`Falha ao processar imagem ${path.basename(imagePath)}`);
|
|
}
|
|
}
|
|
|
|
const outputPath = options.outputPath || path.join(
|
|
this.OUTPUT_DIR,
|
|
`images-to-pdf-${uuidv4()}.pdf`
|
|
);
|
|
|
|
const pdfBytes = await pdfDoc.save();
|
|
await fs.writeFile(outputPath, pdfBytes);
|
|
|
|
return outputPath;
|
|
} catch (error) {
|
|
console.error('Erro ao converter imagens para PDF:', error);
|
|
throw new Error(`Falha ao converter imagens para PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Realiza OCR em um PDF e extrai o texto
|
|
*/
|
|
static async performOCR(
|
|
inputPath: string,
|
|
options: OCROptions = {}
|
|
): Promise<OCRResult> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
const { language = 'por', pages = 'all' } = options;
|
|
|
|
// Primeiro converte o PDF em imagens
|
|
const imageOptions: PDFToImageOptions = {
|
|
format: 'png',
|
|
density: 300,
|
|
pages,
|
|
};
|
|
|
|
const imagePaths = await this.pdfToImages(inputPath, imageOptions);
|
|
|
|
// Cria um worker do Tesseract
|
|
const worker = await createWorker(language);
|
|
|
|
const pageResults: Array<{
|
|
pageNumber: number;
|
|
text: string;
|
|
confidence: number;
|
|
}> = [];
|
|
|
|
let allText = '';
|
|
let totalConfidence = 0;
|
|
|
|
// Processa cada imagem
|
|
for (let i = 0; i < imagePaths.length; i++) {
|
|
try {
|
|
const imagePath = imagePaths[i];
|
|
const { data } = await worker.recognize(imagePath);
|
|
|
|
pageResults.push({
|
|
pageNumber: i + 1,
|
|
text: data.text,
|
|
confidence: data.confidence,
|
|
});
|
|
|
|
allText += data.text + '\n\n';
|
|
totalConfidence += data.confidence;
|
|
|
|
// Remove a imagem temporária
|
|
await fs.unlink(imagePath).catch(() => {});
|
|
} catch (error) {
|
|
console.error(`Erro ao processar OCR na página ${i + 1}:`, error);
|
|
}
|
|
}
|
|
|
|
await worker.terminate();
|
|
|
|
const averageConfidence = pageResults.length > 0
|
|
? totalConfidence / pageResults.length
|
|
: 0;
|
|
|
|
return {
|
|
text: allText.trim(),
|
|
confidence: averageConfidence,
|
|
pages: pageResults,
|
|
};
|
|
} catch (error) {
|
|
console.error('Erro ao realizar OCR:', error);
|
|
throw new Error(`Falha ao realizar OCR: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cria um arquivo ZIP com múltiplos arquivos
|
|
*/
|
|
static async createZipFromFiles(
|
|
filePaths: string[],
|
|
outputPath?: string
|
|
): Promise<string> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
const finalOutputPath = outputPath || path.join(
|
|
this.OUTPUT_DIR,
|
|
`arquivos-${uuidv4()}.zip`
|
|
);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const output = createWriteStream(finalOutputPath);
|
|
const archive = archiver('zip', {
|
|
zlib: { level: 9 } // Máxima compressão
|
|
});
|
|
|
|
output.on('close', () => {
|
|
console.log(`ZIP criado: ${archive.pointer()} bytes`);
|
|
resolve(finalOutputPath);
|
|
});
|
|
|
|
archive.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
archive.pipe(output);
|
|
|
|
// Adiciona todos os arquivos ao ZIP
|
|
filePaths.forEach((filePath, index) => {
|
|
const ext = path.extname(filePath);
|
|
|
|
// Renomeia arquivos para sequência
|
|
const zipFileName = `arquivo-${index + 1}${ext}`;
|
|
archive.file(filePath, { name: zipFileName });
|
|
});
|
|
|
|
archive.finalize();
|
|
});
|
|
} catch (error) {
|
|
console.error('Erro ao criar ZIP:', error);
|
|
throw new Error(`Falha ao criar ZIP: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adiciona camada de OCR a um PDF (torna o PDF pesquisável)
|
|
*/
|
|
static async addOCRLayerToPDF(
|
|
inputPath: string,
|
|
outputPath?: string,
|
|
language: string = 'por'
|
|
): Promise<{ outputPath: string; confidence: number }> {
|
|
try {
|
|
await this.initialize();
|
|
|
|
// Primeiro, extrai o texto via OCR
|
|
const ocrResult = await this.performOCR(inputPath, { language, pages: 'all' });
|
|
|
|
// Carrega o PDF original
|
|
const pdfBytes = await fs.readFile(inputPath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
|
|
// Cria um novo PDF com camada de texto
|
|
const newDoc = await PDFDocument.create();
|
|
const pages = pdfDoc.getPages();
|
|
|
|
for (let i = 0; i < pages.length; i++) {
|
|
const [copiedPage] = await newDoc.copyPages(pdfDoc, [i]);
|
|
newDoc.addPage(copiedPage);
|
|
|
|
// Adiciona texto invisível sobre a página (camada OCR)
|
|
if (ocrResult.pages[i]) {
|
|
const newPage = newDoc.getPage(i);
|
|
const { height } = newPage.getSize();
|
|
|
|
// Desenha texto invisível (transparente) sobre a página
|
|
const lines = ocrResult.pages[i].text.split('\n');
|
|
let yPosition = height - 20;
|
|
|
|
lines.forEach((line) => {
|
|
if (line.trim()) {
|
|
newPage.drawText(line, {
|
|
x: 10,
|
|
y: yPosition,
|
|
size: 8,
|
|
color: rgb(0, 0, 0), // Texto preto mas será invisível
|
|
opacity: 0.01, // Quase invisível mas indexável
|
|
});
|
|
yPosition -= 12;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Salva o PDF com camada OCR
|
|
const finalOutputPath = outputPath || path.join(
|
|
this.OUTPUT_DIR,
|
|
`ocr-${uuidv4()}.pdf`
|
|
);
|
|
|
|
const newPdfBytes = await newDoc.save();
|
|
await fs.writeFile(finalOutputPath, newPdfBytes);
|
|
|
|
return {
|
|
outputPath: finalOutputPath,
|
|
confidence: ocrResult.confidence,
|
|
};
|
|
} catch (error) {
|
|
console.error('Erro ao adicionar camada OCR:', error);
|
|
throw new Error(`Falha ao adicionar OCR: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Limpa arquivos temporários antigos
|
|
*/
|
|
static async cleanupOldFiles(olderThanHours: number = 24): Promise<void> {
|
|
try {
|
|
const dirs = [this.TEMP_DIR, this.OUTPUT_DIR];
|
|
const now = Date.now();
|
|
const maxAge = olderThanHours * 60 * 60 * 1000;
|
|
|
|
for (const dir of dirs) {
|
|
try {
|
|
const files = await fs.readdir(dir);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dir, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (now - stats.mtimeMs > maxAge) {
|
|
await fs.unlink(filePath);
|
|
console.log(`Arquivo removido: ${filePath}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Erro ao limpar diretório ${dir}:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao limpar arquivos antigos:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtém informações sobre um PDF
|
|
*/
|
|
static async getPDFInfo(filePath: string): Promise<{
|
|
pages: number;
|
|
size: number;
|
|
title?: string;
|
|
author?: string;
|
|
creator?: string;
|
|
producer?: string;
|
|
creationDate?: Date;
|
|
modificationDate?: Date;
|
|
}> {
|
|
try {
|
|
const pdfBytes = await fs.readFile(filePath);
|
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
|
|
|
return {
|
|
pages: pdfDoc.getPageCount(),
|
|
size: pdfBytes.length,
|
|
title: pdfDoc.getTitle(),
|
|
author: pdfDoc.getAuthor(),
|
|
creator: pdfDoc.getCreator(),
|
|
producer: pdfDoc.getProducer(),
|
|
creationDate: pdfDoc.getCreationDate(),
|
|
modificationDate: pdfDoc.getModificationDate(),
|
|
};
|
|
} catch (error) {
|
|
console.error('Erro ao obter informações do PDF:', error);
|
|
throw new Error(`Falha ao obter informações do PDF: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
|
}
|
|
}
|
|
}
|