diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index fbee94b..65db0cb 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -247,6 +247,115 @@ export class PdfService { const LINE_HEIGHT = FONT_SIZE * 1.5; const MAX_WIDTH = PAGE_WIDTH - margens.left - margens.right; + // Função para decodificar entidades HTML comuns (acentuação e outros) + function decodeHtmlEntities(str: string): string { + if (!str) return ''; + return str + // 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, 'ü') + // Outras entidades comuns + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/–/gi, '–') + .replace(/—/gi, '—') + .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); + } + + // Função para converter HTML do TinyMCE em uma árvore de blocos com estilos + function parseHtmlToBlocks(html: string): any[] { + // Usa DOMParser se disponível, senão fallback simples + let doc: Document; + try { + const { JSDOM } = require('jsdom'); + doc = new JSDOM(`${html}`).window.document; + } catch { + // fallback: ignora tags e retorna texto puro + return [{ text: html, tag: 'p', styles: {} }]; + } + const blocks: any[] = []; + function parseNode(node: Node, parentStyles: any = {}) { + if (node.nodeType === 3) { // Text + let text = decodeHtmlEntities(node.textContent || ''); + if (text.trim() !== '' || parentStyles['pre']) { + blocks.push({ text, tag: parentStyles.tag || 'span', styles: { ...parentStyles } }); + } + return; + } + if (node.nodeType !== 1) return; // Element + const el = node as HTMLElement; + let styles = { ...parentStyles }; + let tag = el.tagName.toLowerCase(); + + // TinyMCE: , , + if (el.style) { + if (el.style.fontWeight === 'bold' || tag === 'b' || tag === 'strong') styles.bold = true; + if (el.style.fontStyle === 'italic' || tag === 'i' || tag === 'em') styles.italic = true; + if (el.style.textDecoration?.includes('underline') || tag === 'u') styles.underline = true; + if (el.style.textAlign === 'center' || tag === 'center') styles.align = 'center'; + if (el.style.textAlign === 'right') styles.align = 'right'; + if (el.style.textIndent) styles.indent = true; + if (el.style.fontFamily) styles.fontFamily = el.style.fontFamily; + } + if (tag === 'b' || tag === 'strong') styles.bold = true; + if (tag === 'i' || tag === 'em') styles.italic = true; + if (tag === 'u') styles.underline = true; + if (tag === 'center') styles.align = 'center'; + if (tag === 'pre') styles.pre = true; + if (tag === 'p' && el.style.textAlign === 'center') styles.align = 'center'; + if (tag === 'p' && el.style.textAlign === 'right') styles.align = 'right'; + if (tag === 'p' && el.style.textIndent) styles.indent = true; + if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') styles.heading = tag; + if (tag === 'ul' || tag === 'ol') styles.list = tag; + if (tag === 'li') styles.listItem = true; + + // Blocos de texto + if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'pre', 'div', 'center'].includes(tag)) { + let blockText = ''; + el.childNodes.forEach(child => { + if (child.nodeType === 3) blockText += child.textContent; + }); + if (blockText.trim() !== '' || styles['pre']) { + blocks.push({ text: blockText, tag, styles: { ...styles } }); + } + el.childNodes.forEach(child => parseNode(child, styles)); + if (tag === 'li') blocks.push({ text: '', tag: 'br', styles: {} }); + return; + } + // Quebra de linha + if (tag === 'br') { + blocks.push({ text: '', tag: 'br', styles: { ...styles } }); + return; + } + // Span ou inline + el.childNodes.forEach(child => parseNode(child, styles)); + } + doc.body.childNodes.forEach(node => parseNode(node)); + return blocks; + } + // Carregar o timbrado (se houver) e criar o PDF final let timbradoPdf: PDFDocument | null = null; let timbradoPageCount = 0; @@ -257,6 +366,7 @@ export class PdfService { const timbradoBytes = await fs.readFile(timbradoPath); timbradoPdf = await PDFDocument.load(timbradoBytes); timbradoPageCount = timbradoPdf.getPageCount(); + // Copiar as páginas do timbrado como EMBEDDED PAGE (PDFEmbeddedPage) timbradoPages = []; for (let i = 0; i < timbradoPageCount; i++) { const [embedded] = await pdfDoc.embedPages([timbradoPdf.getPage(i)]); @@ -266,6 +376,7 @@ export class PdfService { // Fonte Fira Sans (se disponível), senão Helvetica let font = null, fontBold = null, fontItalic = null, fontBoldItalic = null; + let useCustomFont = false; try { const firaPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Regular.ttf'); const firaBoldPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Bold.ttf'); @@ -277,7 +388,7 @@ export class PdfService { fs.readFile(firaItalicPath).catch(() => null), fs.readFile(firaBoldItalicPath).catch(() => null), ]); - if (fira) font = await pdfDoc.embedFont(fira); + if (fira) { font = await pdfDoc.embedFont(fira); useCustomFont = true; } if (firaBoldBuf) fontBold = await pdfDoc.embedFont(firaBoldBuf); if (firaItalicBuf) fontItalic = await pdfDoc.embedFont(firaItalicBuf); if (firaBoldItalicBuf) fontBoldItalic = await pdfDoc.embedFont(firaBoldItalicBuf); @@ -287,107 +398,7 @@ export class PdfService { if (!fontItalic) fontItalic = await pdfDoc.embedFont(StandardFonts.HelveticaOblique); if (!fontBoldItalic) fontBoldItalic = fontBold; - // --- PARSER HTML TinyMCE INLINE E BLOCOS (LEGADO MELHORADO) --- - // Suporte a blocos, alinhamento, recuo, negrito, itálico, sublinhado, listas, títulos, font-family, centralização, tags inline - function parseHtmlToBlocks(html: string): any[] { - // Normaliza blocos - html = html.replace(/]*class=["'][^"']*MsoNormal[^"']*["'][^>]*>/gi, '

'); - html = html.replace(/]*>/gi, '

'); - html = html.replace(/<\/div>/gi, '

'); - html = html.replace(//gi, '\n'); - html = html.replace(/<\/li>/gi, '\n'); - html = html.replace(/<\/p>/gi, '

\n'); - html = html.replace(/<\/h[1-6]>/gi, m => m + '\n'); - html = html.replace(/<\/ul>/gi, '\n'); - html = html.replace(/<\/ol>/gi, '\n'); - const lines = html.split('\n'); - const blocks: any[] = []; - let inList = false, listType = ''; - for (let raw of lines) { - let line = raw.trim(); - if (!line) continue; - // Listas - if (line.match(/^]*>/i)) { inList = true; listType = 'ul'; continue; } - if (line.match(/^]*>/i)) { inList = true; listType = 'ol'; continue; } - if (line.match(/^<\/ul>/i) || line.match(/^<\/ol>/i)) { inList = false; listType = ''; continue; } - if (line.match(/^]*>/i)) { - let text = line.replace(/^]*>/i, '').replace(/<\/li>$/i, ''); - blocks.push({ inlines: parseInline(text), tag: 'li', styles: {} }); - continue; - } - // Títulos - let headingMatch = line.match(/^]*)>(.*?)<\/h\1>/i); - if (headingMatch) { - let styles: any = { heading: `h${headingMatch[1]}` }; - if (/text-align:\s*center/i.test(headingMatch[2])) styles.align = 'center'; - if (/text-align:\s*right/i.test(headingMatch[2])) styles.align = 'right'; - if (/font-family:\s*['"]?Fira Sans/i.test(headingMatch[2])) styles.fontFamily = 'Fira Sans'; - blocks.push({ inlines: parseInline(headingMatch[3]), tag: `h${headingMatch[1]}`, styles }); - continue; - } - // Parágrafo - let pMatch = line.match(/^]*)>(.*?)<\/p>$/i); - if (pMatch) { - let styles: any = {}; - let styleAttr = pMatch[1]; - if (/text-align:\s*center/i.test(styleAttr)) styles.align = 'center'; - if (/text-align:\s*right/i.test(styleAttr)) styles.align = 'right'; - if (/text-indent:\s*2em/i.test(styleAttr)) styles.indent = true; - if (/font-family:\s*['"]?Fira Sans/i.test(styleAttr)) styles.fontFamily = 'Fira Sans'; - blocks.push({ inlines: parseInline(pMatch[2]), tag: 'p', styles }); - continue; - } - // Centralizado - let centerMatch = line.match(/^]*>(.*?)<\/center>$/i); - if (centerMatch) { - blocks.push({ inlines: parseInline(centerMatch[1]), tag: 'p', styles: { align: 'center' } }); - continue; - } - // Texto puro - blocks.push({ inlines: parseInline(line.replace(/<\/?[^>]+>/g, '')), tag: 'span', styles: {} }); - } - return blocks; - } - - // Parser inline para negrito, itálico, sublinhado, font-family, combinações - function parseInline(html: string): any[] { - const result: any[] = []; - let regex = /(<(b|strong|i|em|u|span)[^>]*>|<\/(b|strong|i|em|u|span)>)/gi; - let stack: any[] = []; - let lastIndex = 0; - let match; - while ((match = regex.exec(html)) !== null) { - if (match.index > lastIndex) { - result.push({ - text: html.substring(lastIndex, match.index), - ...stack.reduce((acc, s) => ({ ...acc, ...s }), {}) - }); - } - if (match[0][1] === '/') { - stack.pop(); - } else { - let style: any = {}; - if (/^<(b|strong)/i.test(match[0])) style.bold = true; - if (/^<(i|em)/i.test(match[0])) style.italic = true; - if (/^ ({ ...acc, ...s }), {}) - }); - } - return result.filter(i => i.text && i.text.replace(/\s+/g, '').length > 0); - } - - // Gera os blocos do HTML do TinyMCE + // Parse TinyMCE HTML para blocos estilizados const blocks = parseHtmlToBlocks(conteudo); // Renderização dos blocos no PDF @@ -409,88 +420,67 @@ export class PdfService { y -= LINE_HEIGHT * 0.5; continue; } + let usedFont = font; + if (block.styles.bold && block.styles.italic && fontBoldItalic) usedFont = fontBoldItalic; + else if (block.styles.bold && fontBold) usedFont = fontBold; + else if (block.styles.italic && fontItalic) usedFont = fontItalic; + let blockFontSize = FONT_SIZE; if (block.styles.heading === 'h1') blockFontSize = 22; if (block.styles.heading === 'h2') blockFontSize = 18; if (block.styles.heading === 'h3') blockFontSize = 16; - let x = margens.left + (block.styles.indent ? FONT_SIZE * 2 : 0); - let line = ''; - let lineWidth = 0; - for (const inline of block.inlines) { - let usedFont = font; - if (inline.bold && inline.italic && fontBoldItalic) usedFont = fontBoldItalic; - else if (inline.bold && fontBold) usedFont = fontBold; - else if (inline.italic && fontItalic) usedFont = fontItalic; - if (inline.fontFamily && inline.fontFamily.toLowerCase().includes('fira')) usedFont = font; + // Remove quebras de linha do texto antes de medir e desenhar + const cleanText = (block.text || '').replace(/\r?\n/g, ' '); - const words = (inline.text || '').split(' '); - for (let i = 0; i < words.length; i++) { - let word = words[i]; - let testLine = line ? line + ' ' + word : word; - let testWidth = usedFont.widthOfTextAtSize(testLine, blockFontSize); - if (testWidth > MAX_WIDTH && line) { - let drawX = x; - if (block.styles.align === 'center' || block.styles.heading === 'h1') { - drawX = margens.left + ((MAX_WIDTH - lineWidth) / 2); - } - if (block.styles.align === 'right') { - drawX = margens.left + (MAX_WIDTH - lineWidth); - } - page.drawText(line, { - x: drawX, - y, - size: blockFontSize, - font: usedFont, - color: rgb(0, 0, 0) - }); - if (inline.underline) { - page.drawLine({ - start: { x: drawX, y: y - 2 }, - end: { x: drawX + lineWidth, y: y - 2 }, - thickness: 0.7, - color: rgb(0, 0, 0) - }); - } - y -= blockFontSize * 1.5; - line = word; - lineWidth = usedFont.widthOfTextAtSize(word, blockFontSize); - } else { - if (line) { - line += ' ' + word; - lineWidth = usedFont.widthOfTextAtSize(line, blockFontSize); - } else { - line = word; - lineWidth = usedFont.widthOfTextAtSize(line, blockFontSize); - } - } - } + let x = margens.left + (block.styles.indent ? FONT_SIZE * 2 : 0); + let width = usedFont.widthOfTextAtSize(cleanText, blockFontSize); + + // Centralização + if (block.styles.align === 'center' || block.styles.heading === 'h1') { + x = margens.left + ((MAX_WIDTH - width) / 2); } - if (line) { - let drawX = x; - if (block.styles.align === 'center' || block.styles.heading === 'h1') { - drawX = margens.left + ((MAX_WIDTH - lineWidth) / 2); - } - if (block.styles.align === 'right') { - drawX = margens.left + (MAX_WIDTH - lineWidth); - } - page.drawText(line, { - x: drawX, - y, - size: blockFontSize, - font: font, - color: rgb(0, 0, 0) - }); - if (block.inlines.some(i => i.underline)) { - page.drawLine({ - start: { x: drawX, y: y - 2 }, - end: { x: drawX + lineWidth, y: y - 2 }, - thickness: 0.7, - color: rgb(0, 0, 0) + if (block.styles.align === 'right') { + x = margens.left + (MAX_WIDTH - width); + } + + // Quebra de página se necessário + if (y < margens.bottom + blockFontSize * 1.2) { + pageIndex++; + page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); + if (timbradoPages && timbradoPages.length > 0) { + const embedded = timbradoPages[pageIndex % timbradoPages.length]; + page.drawPage(embedded, { + x: 0, + y: 0, + width: PAGE_WIDTH, + height: PAGE_HEIGHT }); } - y -= blockFontSize * 1.5; + y = PAGE_HEIGHT - margens.top; } + + // Renderiza o texto (sem \n) + page.drawText(cleanText, { + x, + y, + size: blockFontSize, + font: usedFont, + color: rgb(0, 0, 0) + }); + + // Sublinhado + if (block.styles.underline) { + page.drawLine({ + start: { x, y: y - 2 }, + end: { x: x + width, y: y - 2 }, + thickness: 0.7, + color: rgb(0, 0, 0) + }); + } + + y -= blockFontSize * 1.5; + // Espaço extra entre parágrafos if (block.tag === 'p' || block.styles.heading) { y -= blockFontSize * 0.3; }