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); timbradoExists = true; } catch (e) { timbradoExists = false; } } // Margens const margens = config.margensTemplate || { top: config.timbradoMarginTop || 100, left: config.timbradoMarginLeft || 50, right: config.timbradoMarginRight || 50, bottom: config.timbradoMarginBottom || 50 }; // 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; // Função para remover todas as tags HTML, exceto as de formatação suportadas function stripUnsupportedTags(html: string): string { // Primeiro, simplifica o HTML complexo removendo classes e atributos desnecessários let simplified = html // Remove comentários HTML .replace(//g, '') // Limpa atributos de estilo complexos, mas preserva text-indent e text-align .replace(/style="[^"]*?text-indent:\s*(\d+(?:\.\d+)?(?:pt|px|em))[^"]*?"/gi, 'data-indent="$1"') .replace(/style="[^"]*?text-align:\s*(center|justify|right)[^"]*?"/gi, 'data-align="$1"') // Remove todos os outros atributos de estilo .replace(/\s+style="[^"]*?"/gi, '') // Substitui classes específicas por atributos de dados para preservar formatação .replace(/class="[^"]*?MsoListParagraph[^"]*?"/gi, 'data-list-para="true"') // Normaliza blockquote e indentações .replace(/]*>/gi, '

') .replace(/<\/blockquote>/gi, '

') // Trata listas .replace(/]*>/gi, '

• ') .replace(/<\/li>/gi, '

'); // Remove todas as tags, exceto , , , , ,
,

, // e mantém os atributos data-* que acabamos de criar para formatação return simplified .replace(/<(b|strong|i|em|u|p|span|br)([^>]*)>/gi, (match, tag, attrs) => { // Preserva apenas data-* atributos const dataAttrs = attrs.match(/\s+data-[^=]+="[^"]*"/gi) || []; return `<${tag}${dataAttrs.join('')}>`; }) // Remove todas as outras tags não suportadas .replace(/<(?!\/?(?:b|strong|i|em|u|br|p|span))[^>]+>/gi, '') // Converte números seguidos de parêntese (como em listas numeradas) em formato padronizado .replace(/(\d+\))\s+/g, '$1 '); } // Função para decodificar entidades HTML comuns (acentuação, símbolos, º, etc) function decodeHtmlEntities(str: string): string { if (!str) return ''; // Como estamos em Node.js, usamos apenas o método de substituição return str .replace(/\r\n/g, '\n') // Acentos minúsculos e maiúsculos .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 comuns .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, '>') // Numéricos .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); } const texto = decodeHtmlEntities( stripUnsupportedTags( this.processarTemplate(conteudo, escritorio) ) ); // Process paragraph alignment and formatting const paragrafos = texto.split(/\n{2,}/).map(p => { let paragraph = p.trim(); if (!paragraph) return ''; // Process paragraph alignment let align = 'justify'; // default const alignMatch = paragraph.match(/^§§ALIGN=(center|justify|right)§§/); if (alignMatch) { align = alignMatch[1]; paragraph = paragraph.substring(alignMatch[0].length); } // Process indentation let indent = 0; const indentMatch = paragraph.match(/^§§INDENT=([0-9.]+)(?:pt|px|em)§§/); if (indentMatch) { indent = parseFloat(indentMatch[1]); paragraph = paragraph.substring(indentMatch[0].length); } // Mark paragraph with alignment and indentation return `§§ALIGN=${align}§§§§INDENT=${indent}§§${paragraph}`; }).filter(Boolean); /** * Renderiza um parágrafo com formatação inline (negrito, itálico, sublinhado) */ const renderInlineFormattedParagraph = ( page: any, text: string, x: number, y: number, normalFont: any, boldFont: any, italicFont: any, boldItalicFont: any, fontSize: number, color: any, align: 'left' | 'center' | 'justify' = 'left', maxWidth: number = MAX_WIDTH, isContinuation: boolean = false, marginTop: number = margens.top, marginBottom: number = margens.bottom, pageHeight: number = PAGE_HEIGHT ): number | null => { // Se não houver espaço suficiente na página atual para pelo menos uma linha de texto if (y < marginBottom + fontSize) { return null; // Indica que precisa criar nova página } const words = text.split(/\s+/); let currentLine = ''; let currentX = x; let currentY = y; const lineHeight = fontSize * 1.2; // Processamento de formatação inline básica let formattedChunks: { text: string, isItalic: boolean, isBold: boolean }[] = []; // Pré-processamento do texto para detectar formatação let remaining = text; let inBold = false; let inItalic = false; let currentChunk = ''; while (remaining.length > 0) { if (remaining.startsWith('') || remaining.startsWith('')) { // Adiciona o texto acumulado antes da tag if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold }); currentChunk = ''; } inBold = true; remaining = remaining.replace(/^<(b|strong)>/, ''); } else if (remaining.startsWith('') || remaining.startsWith('')) { // Adiciona o texto acumulado em negrito if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: true }); currentChunk = ''; } inBold = false; remaining = remaining.replace(/^<\/(b|strong)>/, ''); } else if (remaining.startsWith('') || remaining.startsWith('')) { // Adiciona o texto acumulado antes da tag if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold }); currentChunk = ''; } inItalic = true; remaining = remaining.replace(/^<(i|em)>/, ''); } else if (remaining.startsWith('') || remaining.startsWith('')) { // Adiciona o texto acumulado em itálico if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: true, isBold: inBold }); currentChunk = ''; } inItalic = false; remaining = remaining.replace(/^<\/(i|em)>/, ''); } else if (remaining.startsWith('
') || remaining.startsWith('
') || remaining.startsWith('
')) { // Adiciona o texto acumulado e então adiciona uma quebra de linha if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold }); currentChunk = ''; } formattedChunks.push({ text: '\n', isItalic: false, isBold: false }); remaining = remaining.replace(/^/i, ''); } else { // Acumula caractere a caractere currentChunk += remaining[0]; remaining = remaining.substring(1); } } // Adiciona qualquer texto restante if (currentChunk) { formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold }); } let lines: { chunks: { text: string, isItalic: boolean, isBold: boolean }[], width: number }[] = [{ chunks: [], width: 0 }]; let currentLineIndex = 0; for (const chunk of formattedChunks) { if (chunk.text === '\n') { // Forçar quebra de linha lines.push({ chunks: [], width: 0 }); currentLineIndex++; continue; } const words = chunk.text.split(/\s+/); for (let i = 0; i < words.length; i++) { const word = words[i]; if (!word) continue; const font = chunk.isBold && chunk.isItalic ? boldItalicFont : chunk.isBold ? boldFont : chunk.isItalic ? italicFont : normalFont; const wordWidth = font.widthOfTextAtSize(word, fontSize); const spaceWidth = font.widthOfTextAtSize(' ', fontSize); // Verificar se precisamos adicionar espaço antes da palavra let wordWithSpace = word; let totalWidth = wordWidth; // Adicionar espaço se não for a primeira palavra da linha e não estivermos após uma quebra forçada if (lines[currentLineIndex].chunks.length > 0) { wordWithSpace = ' ' + word; totalWidth = wordWidth + spaceWidth; } // Verificar se a palavra cabe na linha atual if (lines[currentLineIndex].width + totalWidth <= maxWidth) { // Adicionar à linha atual lines[currentLineIndex].chunks.push({ text: wordWithSpace, isItalic: chunk.isItalic, isBold: chunk.isBold }); lines[currentLineIndex].width += totalWidth; } else { // Criar nova linha lines.push({ chunks: [{ text: word, isItalic: chunk.isItalic, isBold: chunk.isBold }], width: wordWidth }); currentLineIndex++; // Verificar se temos espaço para mais uma linha if (currentY - lineHeight < marginBottom) { // Não há espaço suficiente para mais uma linha return null; // Indica que precisa criar nova página } } } } // Desenhar as linhas com formatação for (let i = 0; i < lines.length; i++) { const line = lines[i]; currentY -= lineHeight; // Verificar se temos espaço para esta linha if (currentY < marginBottom) { return null; // Indica que precisa criar nova página } // Ajustar posição X baseada no alinhamento let startX = x; if (align === 'center') { startX = x + (maxWidth - line.width) / 2; } else if (align === 'justify' && i < lines.length - 1 && line.chunks.length > 1) { // Justificar texto (exceto última linha do parágrafo) let wordCount = 0; let textWidth = 0; // Contar palavras e determinar largura total do texto for (const chunk of line.chunks) { const text = chunk.text.trim(); if (text) { wordCount += text.split(/\s+/).length; const font = chunk.isBold && chunk.isItalic ? boldItalicFont : chunk.isBold ? boldFont : chunk.isItalic ? italicFont : normalFont; textWidth += font.widthOfTextAtSize(text, fontSize); } } // Calcular espaçamento extra entre palavras para justificar const totalSpaces = wordCount - 1; const spaceWidth = totalSpaces > 0 ? (maxWidth - textWidth) / totalSpaces : 0; // Desenhar cada chunk com espaçamento justificado let runningX = x; for (const chunk of line.chunks) { const font = chunk.isBold && chunk.isItalic ? boldItalicFont : chunk.isBold ? boldFont : chunk.isItalic ? italicFont : normalFont; // Desenhar texto do chunk page.drawText(chunk.text, { x: runningX, y: currentY, size: fontSize, font: font, color: color }); // Avançar posição X runningX += font.widthOfTextAtSize(chunk.text, fontSize) + spaceWidth; } // Pular para a próxima linha continue; } // Desenhar cada chunk let runningX = startX; for (const chunk of line.chunks) { const font = chunk.isBold && chunk.isItalic ? boldItalicFont : chunk.isBold ? boldFont : chunk.isItalic ? italicFont : normalFont; page.drawText(chunk.text, { x: runningX, y: currentY, size: fontSize, font: font, color: color }); runningX += font.widthOfTextAtSize(chunk.text, fontSize); } } return currentY; // Retorna a posição Y final }; let timbradoPdf: PDFDocument | null = null; let timbradoPageCount = 0; let timbradoPages: any[] = []; if (timbradoExists) { const timbradoBytes = await fs.readFile(timbradoPath); timbradoPdf = await PDFDocument.load(timbradoBytes); timbradoPageCount = timbradoPdf.getPageCount(); timbradoPages = []; for (let i = 0; i < timbradoPageCount; i++) { const [embedded] = await pdfDoc.embedPages([timbradoPdf.getPage(i)]); timbradoPages.push(embedded); } } let currentPar = 0; let pageIndex = 0; while (currentPar < paragrafos.length) { let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); if (timbradoPages.length > 0) { const embedded = timbradoPages[pageIndex % timbradoPages.length]; page.drawPage(embedded, { x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT }); } let y = PAGE_HEIGHT - margens.top; 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; 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) { pageIndex++; break; } y = yAfter - FONT_SIZE * 0.5; currentPar++; if (y < margens.bottom + FONT_SIZE * 1.5) break; } pageIndex++; } 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; }); } }