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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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'}`); } } }