import puppeteer from 'puppeteer'; import { prisma } from '../prismaClient'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import fs from 'fs/promises'; import path from 'path'; /** * Serviço para geração e manipulação de arquivos PDF */ export class PdfService { /** * Cria um documento PDF básico * @returns Documento PDF */ async criarDocumento() { return await PDFDocument.create(); } /** * Carrega um documento PDF existente * @param pdfBytes - Bytes do PDF * @returns Documento PDF */ async carregarDocumento(pdfBytes: Uint8Array) { return await PDFDocument.load(pdfBytes); } /** * Adiciona uma página ao documento * @param doc - Documento PDF * @param tamanho - Tamanho da página (padrão: A4) * @returns Página adicionada */ adicionarPagina(doc: any, tamanho = [595.28, 841.89]) { return doc.addPage(tamanho); } /** * Salva o documento como buffer * @param doc - Documento PDF * @returns Buffer do PDF */ async salvarComoBuffer(doc: any): Promise { const pdfBytes = await doc.save(); return Buffer.from(pdfBytes); } /** * Carrega uma fonte padrão * @param doc - Documento PDF * @param fonte - Nome da fonte padrão * @returns Fonte carregada */ async carregarFonte(doc: any, fonte: StandardFonts) { return await doc.embedFont(fonte); } /** * Desenha texto em uma página * @param pagina - Página do PDF * @param texto - Texto a ser desenhado * @param x - Posição X * @param y - Posição Y * @param fonte - Fonte a ser usada * @param tamanho - Tamanho da fonte * @param cor - Cor do texto (padrão: preto) */ desenharTexto( pagina: any, texto: string, x: number, y: number, fonte: any, tamanho: number, cor = rgb(0, 0, 0) ) { pagina.drawText(texto, { x, y, font: fonte, size: tamanho, color: cor, }); } /** * Cria um cabeçalho para o documento * @param pagina - Página do PDF * @param titulo - Título do cabeçalho * @param fonte - Fonte a ser usada * @param margemSuperior - Margem superior * @param margem - Margem lateral * @returns Posição Y após o cabeçalho */ criarCabecalho( pagina: any, titulo: string, fonte: any, margemSuperior: number, margem: number ): number { const yPos = pagina.getHeight() - margemSuperior; this.desenharTexto( pagina, titulo, margem, yPos, fonte, 18, rgb(0, 0, 0.7) ); // Linha separadora pagina.drawLine({ start: { x: margem, y: yPos - 10 }, end: { x: pagina.getWidth() - margem, y: yPos - 10 }, thickness: 1, color: rgb(0, 0, 0.5), }); return yPos - 30; // Retorna a posição Y após o cabeçalho } /** * Cria um rodapé para o documento * @param pagina - Página do PDF * @param texto - Texto do rodapé * @param fonte - Fonte a ser usada * @param margem - Margem */ criarRodape(pagina: any, texto: string, fonte: any, margem: number) { const yPos = margem / 2; this.desenharTexto( pagina, texto, margem, yPos, fonte, 8, rgb(0.5, 0.5, 0.5) ); } private async getConfiguracao() { try { // Buscar configuração ou usar valores padrão const config = await prisma.configuracaoDocumento.findFirst({ orderBy: { criadoEm: 'desc' }, select: { id: true, timbradoUrl: true, timbradoMarginTop: true, timbradoMarginBottom: true, timbradoMarginLeft: true, timbradoMarginRight: true, templatePadrao: true, margensTemplate: true, criadoEm: true } }); console.log('Configuração encontrada:', config); // Debug // Retornar configuração padrão se não existir return { // Valores padrão cabecalhoHtml: `

{{escritorio.nome}}

`, rodapeHtml: `
{{escritorio.endereco}}
Tel: {{escritorio.telefone}} | Email: {{escritorio.email}}
`, // Campos que existem no modelo timbradoUrl: config?.timbradoUrl || '', timbradoMarginTop: config?.timbradoMarginTop || 100, timbradoMarginBottom: config?.timbradoMarginBottom || 50, timbradoMarginLeft: config?.timbradoMarginLeft || 50, timbradoMarginRight: config?.timbradoMarginRight || 50, templatePadrao: config?.templatePadrao || null, margensTemplate: config?.margensTemplate || null }; } catch (error) { console.error('Erro ao buscar configuração:', error); // Retornar configuração padrão para não interromper o fluxo return { cabecalhoHtml: `

Documento

`, rodapeHtml: `
Documento gerado pelo sistema
`, timbradoUrl: '', timbradoMarginTop: 100, timbradoMarginBottom: 50, timbradoMarginLeft: 50, timbradoMarginRight: 50, templatePadrao: null, margensTemplate: null }; } } /** * Gera um PDF a partir do conteúdo HTML e dados do escritório * @param conteudo - Conteúdo HTML para o PDF * @param escritorio - Dados do escritório para substituir no template * @returns Buffer contendo o PDF gerado */ async gerarPDF(conteudo: string, escritorio: any = {}): Promise { try { console.log('Buscando configurações do documento...'); const config = await this.getConfiguracao(); // Verificar se temos um timbrado para usar const temTimbrado = !!config.timbradoUrl; console.log(`Iniciando geração de PDF ${temTimbrado ? 'com' : 'sem'} timbrado...`); // Se temos timbrado, vamos primeiro verificar se ele existe let timbradoPath = ''; let timbradoExists = false; if (temTimbrado) { if (config.timbradoUrl.startsWith('/uploads')) { timbradoPath = path.join(process.cwd(), 'public', config.timbradoUrl); } else { timbradoPath = path.join(process.cwd(), 'public', 'uploads', path.basename(config.timbradoUrl)); } console.log('Verificando timbrado em:', timbradoPath); try { await fs.access(timbradoPath); timbradoExists = true; console.log('Timbrado encontrado'); } catch (e) { console.error('Timbrado não encontrado:', e); } } // Margens definidas no template ou padrão const margens = config.margensTemplate || { top: config.timbradoMarginTop || 100, left: config.timbradoMarginLeft || 50, right: config.timbradoMarginRight || 50, bottom: config.timbradoMarginBottom || 50 }; console.log('Usando margens:', margens); // Abordagem focada apenas no pdf-lib para maximizar consistência try { console.log('Gerando PDF com pdf-lib e tratamento aprimorado de HTML...'); // 1. Primeiro carregar o timbrado se existir let pdfDoc; const fontSize = 11; const lineHeight = fontSize * 1.4; let font, boldFont; const pageWidth = 595; const pageHeight = 842; if (timbradoExists) { console.log('Usando timbrado como base do documento...'); // Carregar o arquivo de timbrado const timbradoBytes = await fs.readFile(timbradoPath); try { pdfDoc = await PDFDocument.load(timbradoBytes); console.log('Timbrado carregado com sucesso'); // Verificar se o timbrado tem páginas if (pdfDoc.getPageCount() === 0) { console.log('Timbrado não contém páginas, adicionando página padrão'); pdfDoc.addPage([pageWidth, pageHeight]); } } catch (loadError) { console.error('Erro ao carregar timbrado:', loadError); // Se falhar ao carregar o timbrado, criar novo documento pdfDoc = await PDFDocument.create(); pdfDoc.addPage([pageWidth, pageHeight]); } } else { // Criar documento em branco console.log('Criando novo documento sem timbrado...'); pdfDoc = await PDFDocument.create(); pdfDoc.addPage([pageWidth, pageHeight]); } // 2. Carregar fontes font = await pdfDoc.embedFont(StandardFonts.Helvetica); boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold); // 3. Processar e formatar o HTML de forma mais precisa console.log('Processando conteúdo HTML com tratamento aprimorado...'); // Processar variáveis de template const processedContent = this.processarTemplate(conteudo, escritorio); // Função para decodificar entidades HTML const decodeHtmlEntities = (text) => { const entities = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", '¢': '¢', '£': '£', '¥': '¥', '€': '€', '©': '©', '®': '®', '™': '™', 'Á': 'Á', 'á': 'á', 'É': 'É', 'é': 'é', 'Í': 'Í', 'í': 'í', 'Ó': 'Ó', 'ó': 'ó', 'Ú': 'Ú', 'ú': 'ú', 'Ç': 'Ç', 'ç': 'ç', 'Ã': 'Ã', 'ã': 'ã', 'Õ': 'Õ', 'õ': 'õ', 'Â': 'Â', 'â': 'â', 'Ê': 'Ê', 'ê': 'ê', 'Ô': 'Ô', 'ô': 'ô', '–': '–', '—': '—', '−': '−' }; return text.replace(/&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-fA-F]{1,6});/gi, (match) => { if (entities[match]) { return entities[match]; } else if (match.startsWith('&#x')) { // Hexadecimal Unicode const hex = match.substring(3, match.length - 1); return String.fromCodePoint(parseInt(hex, 16)); } else if (match.startsWith('&#')) { // Decimal Unicode const decimal = match.substring(2, match.length - 1); return String.fromCodePoint(parseInt(decimal, 10)); } return match; // Se não foi possível decodificar, manter original }); }; // Processa o HTML de forma mais estruturada const processHtml = (html) => { // Substituir tags comuns por marcadores para melhor formatação let processed = html // Corrigir múltiplos   para evitar espaços excessivos .replace(/( ){2,}/g, ' ') .replace(/ /g, ' ') // Preservar quebras de linha .replace(//gi, '\n') .replace(/<\/p>\s*]*>/gi, '\n\n') .replace(/<\/div>\s*]*>/gi, '\n') // Preservar parágrafos .replace(/]*>/gi, '') .replace(/<\/p>/gi, '\n\n') // Preservar divisões .replace(/]*>/gi, '') .replace(/<\/div>/gi, '\n') // Preservar cabeçalhos e listas .replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n') .replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n') .replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n') .replace(/]*>(.*?)<\/h4>/gi, '#### $1\n\n') .replace(/]*>(.*?)<\/li>/gi, '• $1\n') // Preservar negrito e itálico .replace(/]*>(.*?)<\/strong>/gi, '*$1*') .replace(/]*>(.*?)<\/b>/gi, '*$1*') .replace(/]*>(.*?)<\/em>/gi, '_$1_') .replace(/]*>(.*?)<\/i>/gi, '_$1_') // Remover tags restantes .replace(/<[^>]*>/g, ''); // Decodificar entidades HTML processed = decodeHtmlEntities(processed); // Tratar linhas em branco múltiplas processed = processed.replace(/\n{3,}/g, '\n\n'); // Dividir em linhas return processed.split('\n').map(line => line.trim()); }; // Processar linhas do HTML const lines = processHtml(processedContent); // 4. Renderizar o conteúdo no PDF console.log(`Renderizando ${lines.length} linhas de conteúdo...`); // Obter primeira página do documento let currentPage = pdfDoc.getPage(0); let y = pageHeight - margens.top; let pageIndex = 0; // Adicionar cabeçalho com título do documento e data y -= 10; currentPage.drawText(escritorio.nome || 'Documento', { x: margens.left, y, font: boldFont, size: fontSize * 1.3 }); y -= lineHeight * 1.5; const today = new Date().toLocaleDateString('pt-BR'); currentPage.drawText(`Data: ${today}`, { x: margens.left, y, font, size: fontSize }); y -= lineHeight * 2; // Função para quebrar texto em linhas de acordo com a largura disponível const wrapTextToLines = (text, maxWidth) => { const words = text.split(' '); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; // Calcular largura aproximada do texto (melhor estimativa possível) const testWidth = testLine.length * (fontSize * 0.55); if (testWidth > maxWidth) { if (currentLine) { lines.push(currentLine); currentLine = word; } else { // Palavra muito grande, não quebrar lines.push(word); } } else { currentLine = testLine; } } if (currentLine) { lines.push(currentLine); } return lines; }; // Função para adicionar nova página quando necessário const addNewPageIfNeeded = (minSpace) => { if (y - minSpace < margens.bottom) { try { // Criar nova página const newPage = pdfDoc.addPage([pageWidth, pageHeight]); currentPage = newPage; pageIndex++; y = pageHeight - margens.top; return true; } catch (error) { console.error('Erro ao adicionar nova página:', error); return false; } } return false; }; // Processar cada linha de texto for (const line of lines) { if (!line.trim()) { // Linha vazia, adicionar espaço y -= lineHeight * 0.5; continue; } // Determinar formatação com base na linha let currentFont = font; let currentSize = fontSize; let extraSpace = 0; // Verificar se é título if (line.startsWith('# ')) { currentFont = boldFont; currentSize = fontSize * 1.8; extraSpace = lineHeight * 0.5; // Desenhar texto sem o marcador const titleText = line.substring(2).trim(); addNewPageIfNeeded(lineHeight * 2); // Centralizar título const titleWidth = titleText.length * (currentSize * 0.55); const centerX = Math.max(margens.left, (pageWidth - titleWidth) / 2); currentPage.drawText(titleText, { x: centerX, y, font: currentFont, size: currentSize }); y -= lineHeight + extraSpace; continue; } else if (line.startsWith('## ')) { currentFont = boldFont; currentSize = fontSize * 1.5; extraSpace = lineHeight * 0.3; const titleText = line.substring(3).trim(); addNewPageIfNeeded(lineHeight * 1.8); currentPage.drawText(titleText, { x: margens.left, y, font: currentFont, size: currentSize }); y -= lineHeight + extraSpace; continue; } else if (line.startsWith('### ')) { currentFont = boldFont; currentSize = fontSize * 1.2; const titleText = line.substring(4).trim(); addNewPageIfNeeded(lineHeight * 1.5); currentPage.drawText(titleText, { x: margens.left, y, font: currentFont, size: currentSize }); y -= lineHeight + extraSpace; continue; } else if (line.startsWith('• ')) { // Lista com marcador addNewPageIfNeeded(lineHeight); const bulletText = line.trim(); const availableWidth = pageWidth - margens.left - margens.right - 10; // Espaço para o marcador // Quebrar o texto em linhas const bulletWrappedLines = wrapTextToLines(bulletText, availableWidth); for (let i = 0; i < bulletWrappedLines.length; i++) { if (i === 0) { // Primeira linha com marcador currentPage.drawText(bulletWrappedLines[i], { x: margens.left, y, font: currentFont, size: currentSize }); } else { // Linhas seguintes alinhadas com o texto após o marcador currentPage.drawText(bulletWrappedLines[i], { x: margens.left + 10, y, font: currentFont, size: currentSize }); } y -= lineHeight; if (i < bulletWrappedLines.length - 1) { addNewPageIfNeeded(lineHeight); } } continue; } // Verificar se texto contém formatação (negrito/itálico) const formattedLine = line .replace(/\*([^*]+)\*/g, '$1') // Remover negrito por enquanto .replace(/_([^_]+)_/g, '$1'); // Remover itálico por enquanto // Calcular largura disponível const availableWidth = pageWidth - margens.left - margens.right; // Quebrar texto em linhas ajustadas à largura const wrappedLines = wrapTextToLines(formattedLine, availableWidth); // Adicionar cada linha ao PDF for (const wrappedLine of wrappedLines) { addNewPageIfNeeded(lineHeight); currentPage.drawText(wrappedLine, { x: margens.left, y, font: currentFont, size: currentSize }); y -= lineHeight; } // Adicionar espaço extra após parágrafos if (wrappedLines.length > 0) { y -= lineHeight * 0.2; } } // Adicionar rodapé na última página com dados do escritório if (escritorio.nome || escritorio.endereco || escritorio.telefone || escritorio.email) { const footerY = margens.bottom - 20; try { if (escritorio.nome) { currentPage.drawText(escritorio.nome, { x: margens.left, y: footerY, size: 8, font }); } if (escritorio.endereco) { currentPage.drawText(escritorio.endereco, { x: margens.left, y: footerY - 10, size: 8, font }); } if (escritorio.telefone || escritorio.email) { let contactInfo = ''; if (escritorio.telefone) contactInfo += `Tel: ${escritorio.telefone}`; if (escritorio.telefone && escritorio.email) contactInfo += ' | '; if (escritorio.email) contactInfo += `Email: ${escritorio.email}`; currentPage.drawText(contactInfo, { x: margens.left, y: footerY - 20, size: 8, font }); } } catch (footerError) { console.error('Erro ao adicionar rodapé:', footerError); } } console.log('Salvando documento final...'); const pdfBytes = await pdfDoc.save(); console.log(`PDF gerado com sucesso: ${pdfBytes.length} bytes`); return Buffer.from(pdfBytes); } catch (error) { console.error('Erro ao gerar PDF:', error); // Fallback absolutamente mínimo e à prova de falhas try { console.log('Usando fallback ultra-minimalista...'); // Criar documento do zero da forma mais simples possível const pdfDoc = await PDFDocument.create(); const page = pdfDoc.addPage(); const font = await pdfDoc.embedFont(StandardFonts.Helvetica); // Desenhar texto mínimo page.drawText('Documento', { x: 50, y: 800, size: 14, font }); page.drawText('Gerado em: ' + new Date().toLocaleString('pt-BR'), { x: 50, y: 780, size: 10, font }); // Extrair e adicionar apenas parte do texto const plainText = conteudo.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); // Garantir que não exceda limite seguro const safeText = plainText.substring(0, Math.min(plainText.length, 1000)); // Adicionar linhas de texto com posições absolutos const maxLines = 30; const lineLength = 60; for (let i = 0; i < safeText.length && i/lineLength < maxLines; i += lineLength) { const line = safeText.substring(i, i + lineLength); const lineY = 750 - Math.floor(i/lineLength) * 12; if (lineY < 50) break; // Evitar desenhar fora da página page.drawText(line, { x: 50, y: lineY, size: 9, font }); } // Salvar PDF const pdfBytes = await pdfDoc.save(); return Buffer.from(pdfBytes); } catch (fallbackError) { console.error('Erro no fallback:', fallbackError); // Criar um PDF manualmente como último recurso return Buffer.from('%PDF-1.4\n1 0 obj\n<>\nendobj\n2 0 obj\n<>\nendobj\n3 0 obj\n<>>>\nendobj\nxref\n0 4\n0000000000 65535 f\n0000000015 00000 n\n0000000060 00000 n\n0000000111 00000 n\ntrailer\n<>\nstartxref\n190\n%%EOF'); } } } catch (error) { console.error('Erro geral ao gerar PDF:', error); // Método de fallback extremamente básico try { console.log('Usando método de fallback simples...'); const pdfDoc = await PDFDocument.create(); const page = pdfDoc.addPage([595, 842]); const font = await pdfDoc.embedFont(StandardFonts.Helvetica); // Título page.drawText('Documento de Emergência', { x: 50, y: 800, size: 16, font }); // Mensagem de erro page.drawText('Este documento foi gerado em modo de emergência.', { x: 50, y: 760, size: 10, font }); // Adicionar parte do conteúdo como texto plano const plainText = conteudo.replace(/<[^>]*>/g, ' ').slice(0, 1000); // Quebrar em linhas const lines = []; for (let i = 0; i < plainText.length; i += 70) { lines.push(plainText.substring(i, i + 70)); } // Adicionar no máximo 30 linhas lines.slice(0, 30).forEach((line, index) => { page.drawText(line, { x: 50, y: 720 - (index * 12), size: 8, font }); }); const pdfBytes = await pdfDoc.save(); return Buffer.from(pdfBytes); } catch (fallbackError) { console.error('Falha no fallback:', fallbackError); throw new Error('Falha completa ao gerar PDF'); } } } /** * Processa um template HTML substituindo variáveis pelos valores do objeto * @param template - Template HTML com variáveis no formato {{variavel}} * @param dados - Objeto com valores para substituir * @returns Template processado */ private processarTemplate(template: string, dados: any): string { return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { const keyParts = key.trim().split('.'); let value = dados; // Acessar propriedades aninhadas (ex: escritorio.nome) for (const part of keyParts) { if (value && typeof value === 'object' && part in value) { value = value[part]; } else { value = undefined; break; } } return value !== undefined ? value : match; }); } }