diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index 5906667..d63f486 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -1,148 +1,7 @@ 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 @@ -150,13 +9,14 @@ export class PdfService { orderBy: { criadoEm: 'desc' }, select: { id: true, + headerImageUrl: true, + footerImageUrl: true, + logoUrl: true, timbradoUrl: true, timbradoMarginTop: true, timbradoMarginBottom: true, timbradoMarginLeft: true, timbradoMarginRight: true, - templatePadrao: true, - margensTemplate: true, criadoEm: true } }); @@ -165,554 +25,129 @@ export class PdfService { // Retornar configuração padrão se não existir return { - // Valores padrão - cabecalhoHtml: ` + cabecalhoHtml: config?.cabecalhoHtml || `

{{escritorio.nome}}

`, - rodapeHtml: ` + rodapeHtml: config?.rodapeHtml || `
{{escritorio.endereco}}
Tel: {{escritorio.telefone}} | Email: {{escritorio.email}}
`, - // Campos que existem no modelo + logoUrl: config?.logoUrl || '', 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 + timbradoMarginRight: config?.timbradoMarginRight || 50 }; } 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 - }; + throw new Error('Falha ao buscar configuração'); } } - /** - * 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 { + private gerarHTML(content: string, config: any) { + const timbradoUrl = config.timbradoUrl + ? `${process.env.BACKEND_URL || 'http://localhost:5000'}${config.timbradoUrl}` + : ''; + + console.log('Gerando PDF com timbrado:', timbradoUrl); + + return ` + + + + + + + + +
+
+ ${content} +
+
+ + + `; + } + + async gerarPDF(conteudo: string, escritorio: any) { try { - console.log('Buscando configurações do documento...'); - const config = await this.getConfiguracao(); + const config = await prisma.configuracaoDocumento.findFirst({ + orderBy: { criadoEm: 'desc' } + }); - // 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') - // Extrair alinhamentos de texto de parágrafos e divs - .replace(/]*style="[^"]*text-align\s*:\s*(center|right|justify)[^"]*"[^>]*>|]*align="(center|right|justify)"[^>]*>/gi, - (match, alignStyle, alignAttr) => `§§ALIGN=${alignStyle || alignAttr}§§`) - .replace(/]*style="[^"]*text-align\s*:\s*(center|right|justify)[^"]*"[^>]*>|]*align="(center|right|justify)"[^>]*>/gi, - (match, alignStyle, alignAttr) => `§§ALIGN=${alignStyle || alignAttr}§§`) - // Substituir parágrafos, divs e blockquote por marcadores de bloco + quebra de linha - .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, '') - // Detectar sublinhados também - .replace(//gi, '') // Usamos negrito para representar sublinhado - .replace(/<\/u>/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; - - // Verifica se o texto parece ser um título (todo em maiúsculas ou curto) - const isTitle = text.toUpperCase() === text || text.length < 50; - - // Se for título, centralizar independente do alinhamento fornecido - const effectiveAlign = isTitle ? 'center' : align; - - // Processa texto com formatação - remover tags HTML e aplicar estilos - let currentFont = normalFont; - let boldActive = false; - let italicActive = false; - - // Simplificar texto antes de processar para evitar loops infinitos - // Remove tags não suportadas como , , etc. - text = text - .replace(/|<\/u>/gi, '') // Remover tags de sublinhados - .replace(/]*>|<\/span>/gi, '') // Remover tags span - .replace(/<[^>]*style=[^>]*>/gi, '') // Remover atributos style - .replace(/<(?!\/?(?:b|i|strong|em)(?!\w)[^>]*>)[^>]*>/gi, ''); // Manter apenas b, i, strong, em - - // Primeiro, extrair partes do texto com suas formatações - let remainingText = text; - let textParts: Array<{text: string, bold: boolean, italic: boolean}> = []; - - while (remainingText.length > 0) { - const boldStartMatch = remainingText.match(/|/i); - const italicStartMatch = remainingText.match(/|/i); - const boldEndMatch = remainingText.match(/<\/b>|<\/strong>/i); - const italicEndMatch = remainingText.match(/<\/i>|<\/em>/i); - - // Encontrar a próxima tag - const matches = [boldStartMatch, italicStartMatch, boldEndMatch, italicEndMatch] - .filter(m => m !== null); - - if (matches.length === 0) { - // Não há mais tags, adicionar o texto restante - if (remainingText.length > 0) { - textParts.push({ - text: remainingText, - bold: boldActive, - italic: italicActive - }); - } - break; - } - - // Ordenar matches por índice - matches.sort((a, b) => a!.index! - b!.index!); - const nextMatch = matches[0]!; - const matchIndex = nextMatch.index!; - - // Texto antes da próxima tag - if (matchIndex > 0) { - textParts.push({ - text: remainingText.substring(0, matchIndex), - bold: boldActive, - italic: italicActive - }); - } - - // Atualizar estado de formatação - if (nextMatch[0].match(/|/i)) boldActive = true; - else if (nextMatch[0].match(/<\/b>|<\/strong>/i)) boldActive = false; - else if (nextMatch[0].match(/|/i)) italicActive = true; - else if (nextMatch[0].match(/<\/i>|<\/em>/i)) italicActive = false; - - // Remover o texto processado e a tag - remainingText = remainingText.substring(matchIndex + nextMatch[0].length); - } - - // Processar as partes extraídas - for (const part of textParts) { - // Determinar a fonte baseado na formatação - let font = normalFont; - if (part.bold && part.italic) font = boldItalicFont; - else if (part.bold) font = boldFont; - else if (part.italic) font = italicFont; - - // Dividir em palavras - const partWords = part.text.split(/\s+/); - - for (const wordText of partWords) { - if (!wordText) continue; - - const width = font.widthOfTextAtSize(wordText, fontSize); - words.push({ text: wordText, font, width }); - words.push({ text: ' ', font, width: font.widthOfTextAtSize(' ', fontSize) }); - } - - // Remove o último espaço adicionado - if (words.length > 0) { - words.pop(); - } - } - - // Verificar se há espaço suficiente na página para pelo menos uma linha - if (y - lineHeight < marginBottom) { - return null; // Não há espaço suficiente para renderizar - } - - // Aumentar ligeiramente o espaçamento de linhas para títulos - const effectiveLineHeight = isTitle ? lineHeight * 1.3 : lineHeight; - - // Aumentar o tamanho da fonte para títulos - const effectiveFontSize = isTitle ? fontSize * 1.2 : fontSize; - - // Se for um título, adicionar um espaço extra antes - if (isTitle && y < startY) { - y -= lineHeight * 0.5; - } - - // Agora, renderizar as palavras em linhas - for (let i = 0; i < words.length; i++) { - const word = words[i]; - - // Verificar se a palavra cabe na linha atual - if (currentLineWidth + word.width <= maxWidth || currentLine.length === 0) { - // Adicionar palavra à linha atual - currentLine.push(word); - currentLineWidth += word.width; - } else { - // Renderizar a linha atual - if (y - effectiveLineHeight < marginBottom) { - return null; // Não há mais espaço na página - } - - // Ajustar X baseado no alinhamento - let lineX = x; - - // Para justificado, calcular espaçamentos extras entre palavras - // Mas não justificar a última linha de um parágrafo ou títulos - const isLastLine = i === words.length - 1; - const shouldJustify = effectiveAlign === 'justify' && !isLastLine && !isTitle && currentLine.length > 1; - const extraSpacing = shouldJustify ? - (maxWidth - currentLineWidth) / (currentLine.length - 1) : 0; - - // Garantir espaço mínimo entre palavras, mesmo para não justificado - const minSpacing = fontSize * 0.3; // Espaçamento mínimo entre palavras - - if (effectiveAlign === 'center') { - lineX = x + (maxWidth - currentLineWidth) / 2; - } else if (effectiveAlign === 'right') { - lineX = x + maxWidth - currentLineWidth; - } - - // Desenhar a linha - let posX = lineX; - for (let j = 0; j < currentLine.length; j++) { - const item = currentLine[j]; - const itemFontSize = isTitle ? effectiveFontSize : fontSize; - - page.drawText(item.text, { - x: posX, - y: y, - font: item.font, - size: itemFontSize, - color - }); - - // Avançar posição X - posX += item.width; - - // Adicionar espaço extra entre palavras - if (j < currentLine.length - 1) { - // Usar espaçamento mínimo ou espaçamento justificado, o que for maior - const spacingToUse = shouldJustify ? Math.max(extraSpacing, minSpacing) : minSpacing; - posX += spacingToUse; - } - } - - // Avançar para a próxima linha - y -= effectiveLineHeight; - currentLine = [word]; - currentLineWidth = word.width; - - // Verificar se ainda temos espaço - if (y - effectiveLineHeight < marginBottom) { - return null; - } - } - } - - // Renderizar a última linha, se houver - if (currentLine.length > 0) { - // Ajustar X baseado no alinhamento (última linha nunca é justificada, exceto se for curta) - let lineX = x; - - // Se for título ou alinhado ao centro/direita, ajustar posição - if (effectiveAlign === 'center') { - lineX = x + (maxWidth - currentLineWidth) / 2; - } else if (effectiveAlign === 'right') { - lineX = x + maxWidth - currentLineWidth; - } - - // Desenhar a linha - let posX = lineX; - for (const item of currentLine) { - const itemFontSize = isTitle ? effectiveFontSize : fontSize; - - page.drawText(item.text, { - x: posX, - y: y, - font: item.font, - size: itemFontSize, - color - }); - posX += item.width; - - // Adicionar espaço mínimo entre palavras - if (posX < lineX + maxWidth) { - posX += fontSize * 0.3; - } - } - - y -= effectiveLineHeight; - } - - // Se for título, adicionar um espaço extra depois - if (isTitle) { - y -= lineHeight * 0.5; - } - - return y; - } - - const textoLimpo = stripUnsupportedTags(conteudo); - const texto = decodeHtmlEntities(textoLimpo); - console.log('Texto processado para renderização:', texto.substring(0, 100) + '...'); + console.log('Configuração do PDF:', config); - const paragrafos = texto.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0); - console.log(`Gerando PDF com ${paragrafos.length} parágrafos`); + const html = this.gerarHTML(conteudo, config); + + const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox'] + }); - let currentPar = 0; - let pageIndex = 0; - 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]); - let y = PAGE_HEIGHT - margens.top; - let processedAnyParagraph = false; + const page = await browser.newPage(); + + await page.setViewport({ + width: 794, // A4 width in pixels (72 dpi) + height: 1123, + deviceScaleFactor: 2 + }); - while (currentPar < paragrafos.length) { - const text = paragrafos[currentPar]; - const align = 'justify'; - const indent = 0; - const isTitle = false; - let x = margens.left + indent; - let parWidth = MAX_WIDTH - indent; - const fontSizeToUse = isTitle ? FONT_SIZE * 1.2 : FONT_SIZE; + await page.setContent(html, { + waitUntil: ['networkidle0', 'load'], + timeout: 30000 + }); - if (text.length < 2) { - currentPar++; - continue; - } + const pdfBuffer = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '0', right: '0', bottom: '0', left: '0' }, + preferCSSPageSize: true + }); - if (isTitle && processedAnyParagraph) { - y -= FONT_SIZE * 0.8; - } + await browser.close(); + return Buffer.from(pdfBuffer); - if (y < margens.bottom + fontSizeToUse * 2) { - break; - } - - console.log(`Tentando renderizar parágrafo ${currentPar + 1} em y=${y} com alinhamento ${align}${isTitle ? ' (título)' : ''}`); - let yAfter = renderInlineFormattedParagraph( - page, - text, - x, - y, - font, - fontBold, - fontItalic, - fontBoldItalic, - fontSizeToUse, - rgb(0, 0, 0), - align, - parWidth, - false, - margens.top, - margens.bottom, - PAGE_HEIGHT - ); - - if (yAfter === null) { - if (processedAnyParagraph) { - console.log(`Página ${pageIndex + 1} está cheia, criando nova página`); - break; - } else { - console.log(`Não foi possível processar o parágrafo ${currentPar + 1}, avançando`); - currentPar++; - continue; - } - } else { - y = yAfter - FONT_SIZE * 0.5; - currentPar++; - processedAnyParagraph = true; - console.log(`Parágrafo ${currentPar} processado com sucesso, nova posição y=${y}`); - } - - 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; + console.error('Erro ao gerar PDF:', error); + throw new Error('Falha ao gerar PDF'); } } - - 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; - }); - } } \ No newline at end of file