diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index 65db0cb..6c9d2fe 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -247,115 +247,6 @@ 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; @@ -366,7 +257,6 @@ 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)]); @@ -376,7 +266,6 @@ 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'); @@ -388,7 +277,7 @@ export class PdfService { fs.readFile(firaItalicPath).catch(() => null), fs.readFile(firaBoldItalicPath).catch(() => null), ]); - if (fira) { font = await pdfDoc.embedFont(fira); useCustomFont = true; } + if (fira) font = await pdfDoc.embedFont(fira); if (firaBoldBuf) fontBold = await pdfDoc.embedFont(firaBoldBuf); if (firaItalicBuf) fontItalic = await pdfDoc.embedFont(firaItalicBuf); if (firaBoldItalicBuf) fontBoldItalic = await pdfDoc.embedFont(firaBoldItalicBuf); @@ -398,8 +287,96 @@ export class PdfService { if (!fontItalic) fontItalic = await pdfDoc.embedFont(StandardFonts.HelveticaOblique); if (!fontBoldItalic) fontBoldItalic = fontBold; - // Parse TinyMCE HTML para blocos estilizados - const blocks = parseHtmlToBlocks(conteudo); + // --- NOVO PARSER HTML TinyMCE PARA BLOCOS --- + // Usa jsdom para manter estrutura de blocos e estilos + const { JSDOM } = require('jsdom'); + const dom = new JSDOM(`${conteudo}`); + const body = dom.window.document.body; + + // Função auxiliar para obter estilos relevantes + function getBlockStyles(el: HTMLElement) { + const styles: any = {}; + const computed = el.getAttribute('style') || ''; + if (/font-weight:\s*bold/i.test(computed) || el.tagName === 'B' || el.tagName === 'STRONG') styles.bold = true; + if (/font-style:\s*italic/i.test(computed) || el.tagName === 'I' || el.tagName === 'EM') styles.italic = true; + if (/text-decoration:\s*underline/i.test(computed) || el.tagName === 'U') styles.underline = true; + if (/text-align:\s*center/i.test(computed) || el.align === 'center' || el.tagName === 'CENTER') styles.align = 'center'; + if (/text-align:\s*right/i.test(computed) || el.align === 'right') styles.align = 'right'; + if (/text-indent:\s*2em/i.test(computed)) styles.indent = true; + if (/font-family:\s*['"]?Fira Sans/i.test(computed)) styles.fontFamily = 'Fira Sans'; + if (el.tagName.match(/^H[1-6]$/)) styles.heading = el.tagName.toLowerCase(); + return styles; + } + + // Função para quebrar texto em linhas respeitando largura máxima + function breakLines(text: string, fontObj: any, fontSize: number, maxWidth: number) { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + for (const word of words) { + const testLine = currentLine ? currentLine + ' ' + word : word; + const width = fontObj.widthOfTextAtSize(testLine, fontSize); + if (width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + if (currentLine) lines.push(currentLine); + return lines; + } + + // Função para processar blocos do HTML + function parseBlocks(node: HTMLElement, parentStyles: any = {}) { + const blocks: any[] = []; + for (const el of Array.from(node.childNodes)) { + if (el.nodeType === 3) { // texto puro + let text = el.textContent || ''; + if (text.trim() !== '') { + blocks.push({ text, tag: parentStyles.tag || 'span', styles: { ...parentStyles } }); + } + continue; + } + if (el.nodeType !== 1) continue; + const elem = el as HTMLElement; + let styles = { ...parentStyles, ...getBlockStyles(elem) }; + let tag = elem.tagName.toLowerCase(); + + if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'center'].includes(tag)) { + let blockText = ''; + elem.childNodes.forEach(child => { + if (child.nodeType === 3) blockText += child.textContent; + }); + blocks.push({ text: blockText, tag, styles: { ...styles } }); + blocks.push(...parseBlocks(elem, styles)); + blocks.push({ tag: 'br', text: '', styles: {} }); + continue; + } + if (tag === 'br') { + blocks.push({ tag: 'br', text: '', styles: { ...styles } }); + continue; + } + if (['b', 'strong', 'i', 'em', 'u', 'span'].includes(tag)) { + blocks.push(...parseBlocks(elem, styles)); + continue; + } + // Listas + if (tag === 'ul' || tag === 'ol') { + elem.childNodes.forEach(li => { + if (li.nodeType === 1 && (li as HTMLElement).tagName === 'LI') { + blocks.push({ text: '• ' + (li.textContent || ''), tag: 'li', styles: { ...styles } }); + blocks.push({ tag: 'br', text: '', styles: {} }); + } + }); + continue; + } + } + return blocks; + } + + // Gera os blocos do HTML do TinyMCE + const blocks = parseBlocks(body); // Renderização dos blocos no PDF let pageIndex = 0; @@ -433,53 +410,57 @@ export class PdfService { // Remove quebras de linha do texto antes de medir e desenhar const cleanText = (block.text || '').replace(/\r?\n/g, ' '); - let x = margens.left + (block.styles.indent ? FONT_SIZE * 2 : 0); - let width = usedFont.widthOfTextAtSize(cleanText, blockFontSize); + // Quebra em múltiplas linhas se necessário + const lines = breakLines(cleanText, usedFont, blockFontSize, MAX_WIDTH); - // Centralização - if (block.styles.align === 'center' || block.styles.heading === 'h1') { - x = margens.left + ((MAX_WIDTH - width) / 2); - } - if (block.styles.align === 'right') { - x = margens.left + (MAX_WIDTH - width); - } + for (const line of lines) { + let x = margens.left + (block.styles.indent ? FONT_SIZE * 2 : 0); + let width = usedFont.widthOfTextAtSize(line, blockFontSize); - // 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 - }); + // Centralização + if (block.styles.align === 'center' || block.styles.heading === 'h1') { + x = margens.left + ((MAX_WIDTH - width) / 2); + } + if (block.styles.align === 'right') { + x = margens.left + (MAX_WIDTH - width); } - y = PAGE_HEIGHT - margens.top; - } - // Renderiza o texto (sem \n) - page.drawText(cleanText, { - x, - y, - size: blockFontSize, - font: usedFont, - color: rgb(0, 0, 0) - }); + // 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 = PAGE_HEIGHT - margens.top; + } - // Sublinhado - if (block.styles.underline) { - page.drawLine({ - start: { x, y: y - 2 }, - end: { x: x + width, y: y - 2 }, - thickness: 0.7, + page.drawText(line, { + x, + y, + size: blockFontSize, + font: usedFont, color: rgb(0, 0, 0) }); - } - y -= blockFontSize * 1.5; + // 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;