import { PDFDocument } 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'; /** * 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 mantendo a qualidade máxima possível * Usa otimização de streams e remoção de metadados desnecessários */ static async compressPDF( inputPath: string, outputPath?: string, options: CompressOptions = {} ): Promise<{ outputPath: string; originalSize: number; compressedSize: number; reduction: number }> { try { await this.initialize(); const { quality = 95 } = options; // 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 metadados desnecessários mas mantém metadados essenciais pdfDoc.setTitle(''); pdfDoc.setAuthor(''); pdfDoc.setSubject(''); pdfDoc.setKeywords([]); pdfDoc.setProducer(''); pdfDoc.setCreator(''); // Salva com compressão const compressedBytes = await pdfDoc.save({ useObjectStreams: true, // Comprime streams de objetos addDefaultPage: false, objectsPerTick: 50, }); // Se a compressão não foi suficiente, otimiza as imagens let finalBytes = compressedBytes; if (options.maxSizeMB && compressedBytes.length > options.maxSizeMB * 1024 * 1024) { // Recarrega para otimizar imagens const reloadedDoc = await PDFDocument.load(compressedBytes); const pages = reloadedDoc.getPages(); // Processa cada página para otimizar imagens for (const page of pages) { // A otimização de imagens seria feita aqui // Por limitações da pdf-lib, mantemos a qualidade original } finalBytes = await reloadedDoc.save({ useObjectStreams: true, addDefaultPage: false, }); } // Define o caminho de saída const finalOutputPath = outputPath || path.join( this.OUTPUT_DIR, `compressed-${uuidv4()}.pdf` ); // 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'}`); } } /** * 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'}`); } } }