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 simples (apenas texto) e dados do escritório, * encaixando o texto sobre o timbrado (PDF de fundo) em todas as páginas. * Não usa Puppeteer, apenas PDF-LIB. */ 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; 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)); } try { await fs.access(timbradoPath); console.log('Timbrado encontrado em:', timbradoPath); timbradoExists = true; } catch (e) { console.error('Arquivo de timbrado não encontrado:', timbradoPath, e); timbradoExists = false; } } // Margens 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); // Parâmetros de página const PAGE_WIDTH = 595.28; // A4 const PAGE_HEIGHT = 841.89; // A4 const FONT_SIZE = 12; const LINE_HEIGHT = FONT_SIZE * 1.5; const MAX_WIDTH = PAGE_WIDTH - margens.left - margens.right; // Crie o PDFDocument ANTES de tentar usar pdfDoc para embedFont! let pdfDoc: PDFDocument = await PDFDocument.create(); // Fonte padrão do pdf-lib: TimesRoman, TimesBold, TimesItalic const font = await pdfDoc.embedFont('Times-Roman'); const fontBold = await pdfDoc.embedFont('Times-Bold'); const fontItalic = await pdfDoc.embedFont('Times-Italic'); const fontBoldItalic = fontBold; let timbradoPdf: PDFDocument | null = null; let timbradoPageCount = 0; let timbradoPages: any[] = []; if (timbradoExists) { try { console.log('Carregando timbrado PDF...'); const timbradoBytes = await fs.readFile(timbradoPath); timbradoPdf = await PDFDocument.load(timbradoBytes); timbradoPageCount = timbradoPdf.getPageCount(); console.log(`Timbrado carregado com ${timbradoPageCount} páginas`); // Usar a primeira página do timbrado como modelo para todas as páginas if (timbradoPageCount > 0) { // Carregue a primeira página do timbrado como imagem de fundo const [firstPage] = await pdfDoc.embedPdf(timbradoPdf, [0]); timbradoPages = [firstPage]; console.log('Timbrado incorporado com sucesso'); } } catch (error) { console.error('Erro ao carregar timbrado:', error); timbradoExists = false; } } function stripUnsupportedTags(html: string): string { // Primeiro, substituir entidades e tags comuns const cleanHtml = html // Preserva quebras de linha HTML como marcadores especiais .replace(/|<\/p>

|<\/div>

/gi, '\n') // Substitui parágrafos, divs e blockquote por marcadores de bloco + quebra de linha .replace(/]*style="[^"]*text-align\s*:\s*(center|right|justify)[^"]*"[^>]*>/gi, (match, align) => `

`) .replace(/]*style="[^"]*text-align\s*:\s*(center|right|justify)[^"]*"[^>]*>/gi, (match, align) => `

`) .replace(/]*>|]*>|]*>/gi, '') .replace(/<\/p>|<\/div>|<\/blockquote>/gi, '\n\n') // Marca formatação de texto .replace(/|/gi, '') .replace(/<\/b>|<\/strong>/gi, '') .replace(/|/gi, '') .replace(/<\/i>|<\/em>/gi, '') // Normaliza espaços extras e quebras de linha .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]+/g, ' '); return cleanHtml; } // Função para decodificar entidades HTML comuns function decodeHtmlEntities(str: string): string { if (!str) return ''; return str .replace(/\r\n/g, '\n') // Acentos e caracteres especiais .replace(/Á/g, 'Á').replace(/á/g, 'á') .replace(/É/g, 'É').replace(/é/g, 'é') .replace(/Í/g, 'Í').replace(/í/g, 'í') .replace(/Ó/g, 'Ó').replace(/ó/g, 'ó') .replace(/Ú/g, 'Ú').replace(/ú/g, 'ú') .replace(/À/g, 'À').replace(/à/g, 'à') .replace(/È/g, 'È').replace(/è/g, 'è') .replace(/Ì/g, 'Ì').replace(/ì/g, 'ì') .replace(/Ò/g, 'Ò').replace(/ò/g, 'ò') .replace(/Ù/g, 'Ù').replace(/ù/g, 'ù') .replace(/Â/g, 'Â').replace(/â/g, 'â') .replace(/Ê/g, 'Ê').replace(/ê/g, 'ê') .replace(/Î/g, 'Î').replace(/î/g, 'î') .replace(/Ô/g, 'Ô').replace(/ô/g, 'ô') .replace(/Û/g, 'Û').replace(/û/g, 'û') .replace(/Ã/g, 'Ã').replace(/ã/g, 'ã') .replace(/Õ/g, 'Õ').replace(/õ/g, 'õ') .replace(/Ç/g, 'Ç').replace(/ç/g, 'ç') .replace(/Ñ/g, 'Ñ').replace(/ñ/g, 'ñ') .replace(/Ü/g, 'Ü').replace(/ü/g, 'ü') // Símbolos e caracteres especiais .replace(/º/g, 'º') .replace(/ª/g, 'ª') .replace(/§/g, '§') .replace(/°/g, '°') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>'); } /** * Renderiza um parágrafo com formatação inline (negrito, itálico) * @returns A posição Y após renderizar ou null se não coube na página */ function renderInlineFormattedParagraph( page: any, text: string, x: number, startY: number, normalFont: any, boldFont: any, italicFont: any, boldItalicFont: any, fontSize: number, color: any, align: 'left' | 'center' | 'right' | 'justify', maxWidth: number, debug: boolean = false, marginTop: number, marginBottom: number, pageHeight: number ): number | null { const lineHeight = fontSize * 1.5; let y = startY; let currentX = x; let words: Array<{text: string, font: any, width: number}> = []; let currentLine: Array<{text: string, font: any, width: number}> = []; let currentLineWidth = 0; let textParts = []; // Processa texto com formatação - remover tags HTML e aplicar estilos let processedText = ''; let currentFont = normalFont; let boldActive = false; let italicActive = false; // Primeiro, extrair partes do texto com suas formatações let i = 0; while (i < text.length) { if (text.substring(i, i+3) === '') { boldActive = true; currentFont = italicActive ? boldItalicFont : boldFont; i += 3; } else if (text.substring(i, i+4) === '') { boldActive = false; currentFont = italicActive ? italicFont : normalFont; i += 4; } else if (text.substring(i, i+3) === '') { italicActive = true; currentFont = boldActive ? boldItalicFont : italicFont; i += 3; } else if (text.substring(i, i+4) === '') { italicActive = false; currentFont = boldActive ? boldFont : normalFont; i += 4; } else { // Encontrar o próximo início de tag let nextTagPos = text.indexOf('<', i); if (nextTagPos === -1) { // Não há mais tags, pegar o restante do texto textParts.push({ text: text.substring(i), font: currentFont }); break; } else { // Pegar texto até a próxima tag textParts.push({ text: text.substring(i, nextTagPos), font: currentFont }); i = nextTagPos; } } } // Processa partes de texto em palavras para quebra de linha for (const part of textParts) { const partWords = part.text.split(/\s+/); for (let i = 0; i < partWords.length; i++) { if (partWords[i].length === 0) continue; const wordText = i < partWords.length - 1 ? partWords[i] + ' ' : partWords[i]; const wordWidth = part.font.widthOfTextAtSize(wordText, fontSize); words.push({ text: wordText, font: part.font, width: wordWidth }); } } // Processa palavras em linhas for (let i = 0; i < words.length; i++) { const word = words[i]; if (currentLineWidth + word.width <= maxWidth) { // A palavra cabe na linha atual currentLine.push(word); currentLineWidth += word.width; } else { // A palavra não cabe, desenha a linha atual e começa nova linha // Primeiro verifica se há espaço vertical suficiente if (y - lineHeight < marginBottom) { return null; // Não há espaço suficiente na página } // Desenha a linha com o alinhamento apropriado y = drawFormattedLine(page, currentLine, x, y, maxWidth, align, fontSize, color); // Começa uma nova linha com a palavra atual currentLine = [word]; currentLineWidth = word.width; } } // Desenha a última linha se houver palavras restantes if (currentLine.length > 0) { if (y - lineHeight < marginBottom) { return null; // Não há espaço suficiente na página } y = drawFormattedLine(page, currentLine, x, y, maxWidth, align, fontSize, color); } return y; } /** * Desenha uma linha formatada com alinhamento específico */ function drawFormattedLine( page: any, line: Array<{text: string, font: any, width: number}>, x: number, y: number, maxWidth: number, align: 'left' | 'center' | 'right' | 'justify', fontSize: number, color: any ): number { const lineHeight = fontSize * 1.5; let currentX = x; let totalWidth = line.reduce((sum, word) => sum + word.width, 0); // Calcula o espaço adicional entre palavras para justificação let extraSpacing = 0; if (align === 'justify' && line.length > 1 && totalWidth < maxWidth * 0.8) { extraSpacing = (maxWidth - totalWidth) / (line.length - 1); } // Ajusta a posição x inicial baseada no alinhamento if (align === 'center') { currentX = x + (maxWidth - totalWidth) / 2; } else if (align === 'right') { currentX = x + maxWidth - totalWidth; } // Desenha cada palavra for (let i = 0; i < line.length; i++) { const word = line[i]; page.drawText(word.text, { x: currentX, y: y, font: word.font, size: fontSize, color: color }); // Avança X para a próxima palavra currentX += word.width; // Adiciona espaço extra para justificação, exceto na última palavra if (align === 'justify' && i < line.length - 1) { currentX += extraSpacing; } } // Retorna a nova posição Y após desenhar a linha return y - lineHeight; } // Processe o texto com as novas funções melhoradas const textoLimpo = stripUnsupportedTags( this.processarTemplate(conteudo, escritorio) ); const texto = decodeHtmlEntities(textoLimpo); console.log('Texto processado para renderização:', texto.substring(0, 100) + '...'); // Corrigir o processamento de parágrafos para lidar melhor com formatações const paragrafos = texto.split(/\n{2,}/).map(p => { let paragraph = p.trim(); if (!paragraph) return ''; // Extrair informações de formatação let align = 'left'; // padrão const alignMatch = paragraph.match(/§§ALIGN=(center|right|justify)§§/); if (alignMatch) { align = alignMatch[1]; paragraph = paragraph.replace(/§§ALIGN=(center|right|justify)§§/, ''); } let indent = 0; const indentMatch = paragraph.match(/§§INDENT=(\d+)(pt|px|em)§§/); if (indentMatch) { indent = parseInt(indentMatch[1]); if (indentMatch[2] === 'em') indent *= 12; // Converter em para pontos aproximadamente paragraph = paragraph.replace(/§§INDENT=\d+(pt|px|em)§§/, ''); } return `§§ALIGN=${align}§§§§INDENT=${indent}§§${paragraph.trim()}`; }).filter(Boolean); let currentPar = 0; let pageIndex = 0; console.log(`Gerando PDF com ${paragrafos.length} parágrafos`); // Define um contador máximo para evitar loops infinitos const MAX_PAGES = 100; while (currentPar < paragrafos.length && pageIndex < MAX_PAGES) { console.log(`Criando página ${pageIndex + 1}, processando parágrafo ${currentPar + 1} de ${paragrafos.length}`); let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); // Adicionar timbrado à página if (timbradoPages.length > 0) { console.log(`Adicionando timbrado na página ${pageIndex + 1}`); page.drawPage(timbradoPages[0], { x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT }); } let y = PAGE_HEIGHT - margens.top; let processedAnyParagraph = false; // Flag para verificar se processamos algum parágrafo // Tenta processar parágrafos na página atual while (currentPar < paragrafos.length) { const paraText = paragrafos[currentPar]; // Extract alignment let align: 'left' | 'center' | 'justify' = 'left'; const alignMatch = paraText.match(/^§§ALIGN=(center|justify|right)§§/); if (alignMatch) { align = alignMatch[1] as 'center' | 'justify' | 'left'; } // Extract indentation let indent = 0; const indentMatch = paraText.match(/§§INDENT=([0-9.]+)§§/); if (indentMatch) { indent = parseFloat(indentMatch[1]); } // Clean text let text = paraText .replace(/^§§ALIGN=(center|justify|right)§§/, '') .replace(/§§INDENT=[0-9.]+§§/, '') .trim(); let x = margens.left + indent; let parWidth = MAX_WIDTH - indent; // Se o texto for muito curto, avance para o próximo parágrafo if (text.length < 2) { currentPar++; processedAnyParagraph = true; continue; } console.log(`Tentando renderizar parágrafo ${currentPar + 1} em y=${y}`); let yAfter = renderInlineFormattedParagraph( page, text, x, y, font, fontBold, fontItalic, fontBoldItalic, FONT_SIZE, rgb(0, 0, 0), align, parWidth, false, margens.top, margens.bottom, PAGE_HEIGHT ); if (yAfter === null) { // Se não conseguimos renderizar este parágrafo, vamos para a próxima página // Mas só se já tivermos processado algum parágrafo nesta página if (processedAnyParagraph) { console.log(`Página ${pageIndex + 1} está cheia, criando nova página`); break; } else { // Se não conseguimos nem processar um parágrafo, algo está errado // Vamos avançar para o próximo parágrafo para evitar um loop infinito console.log(`Não foi possível processar o parágrafo ${currentPar + 1}, avançando`); currentPar++; processedAnyParagraph = true; } } else { // Parágrafo foi renderizado com sucesso y = yAfter - FONT_SIZE * 0.5; currentPar++; processedAnyParagraph = true; console.log(`Parágrafo ${currentPar} processado com sucesso, nova posição y=${y}`); // Verifica se ainda temos espaço para pelo menos uma linha if (y < margens.bottom + FONT_SIZE * 2) { console.log(`Espaço insuficiente para próximo parágrafo, criando nova página`); break; } } } pageIndex++; } if (pageIndex >= MAX_PAGES) { console.warn(`AVISO: Número máximo de páginas (${MAX_PAGES}) atingido. Geração de PDF interrompida.`); } console.log(`PDF gerado com ${pageIndex} páginas`); const pdfBytes = await pdfDoc.save(); return Buffer.from(pdfBytes); } catch (error) { console.error('Erro geral ao gerar PDF:', error); throw error; } } private processarTemplate(template: string, dados: any): string { return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { const keyParts = key.trim().split('.'); let value = dados; 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; }); } }