From 3ccccc67929fc951fc37ea1f1be6f30b9671c717 Mon Sep 17 00:00:00 2001 From: Rodrigo Martins Date: Mon, 21 Apr 2025 20:06:37 -0300 Subject: [PATCH] tenta fix pdfservice.js --- src/server/services/PdfService.ts | 364 +++++++++++++++--------------- 1 file changed, 180 insertions(+), 184 deletions(-) diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index 3d4c740..e15b305 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -284,92 +284,121 @@ export class PdfService { .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); } - // Função para remover todas as tags HTML, exceto as de formatação suportadas - function stripUnsupportedTags(html: string): string { - // Remove todas as tags, exceto , , , , ,
,

, , - // Remove atributos de exceto text-indent e text-align - return html - // Mantém apenas com text-indent ou text-align:center - .replace(/])*?>/gi, '') - // Remove atributos de , , , , ,

- .replace(/<(b|strong|i|em|u|p)[^>]*>/gi, '<$1>') - // Remove todas as outras tags e atributos - .replace(/<(?!\/?(b|strong|i|em|u|br|p|span)[ >])[^>]+>/gi, '') - // Remove spans sem text-indent ou text-align - .replace(/(.*?)<\/span>/gi, '$1') - // Remove comentários HTML - .replace(//g, ''); + // 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; } // Processar variáveis do template e decodificar entidades HTML // Mantém tags , , , , ,
,

, , const texto = decodeHtmlEntities( - stripUnsupportedTags( - this.processarTemplate(conteudo, escritorio) - .replace(//gi, '\n') - .replace(/<\/p>/gi, '\n\n') - .replace(/]*>/gi, '') // Remove apenas a tag de abertura

- // Marca recuo de parágrafo - .replace(/]*text-indent[^>]*>/gi, '§§INDENT§§') - // Marca centralização - .replace(/]*text-align\s*:\s*center[^>]*>/gi, '§§CENTER§§') - .replace(/<\/span>/gi, '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() - ) + this.processarTemplate(conteudo, escritorio) + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/]*>/gi, '') // Remove apenas a tag de abertura

+ // Marca recuo de parágrafo + .replace(/]*text-indent[^>]*>/gi, '§§INDENT§§') + // Marca centralização + .replace(/]*text-align\s*:\s*center[^>]*>/gi, '§§CENTER§§') + .replace(/<\/span>/gi, '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() ); // Quebra o texto em parágrafos (mantendo quebras de linha duplas como parágrafo) const paragrafos = texto.split(/\n{2,}/).map(p => p.trim()).filter(Boolean); - // Função para quebrar texto em linhas respeitando largura máxima e manter quebras de linha simples - function wrapTextStyled(text: string, font: any, fontSize: number, maxWidth: number) { - // Mantém quebras de linha simples para espaçamento - const rawLines = text.split('\n'); - const lines: { text: string, bold: boolean, italic: boolean, underline: boolean, indent: boolean, center: boolean }[] = []; - for (const rawLine of rawLines) { - let line = rawLine; - let bold = false, italic = false, underline = false, indent = false, center = false; - // Detecta recuo de parágrafo - if (line.startsWith('§§INDENT§§')) { - indent = true; - line = line.replace('§§INDENT§§', '').trimStart(); - } - // Detecta centralização - if (line.startsWith('§§CENTER§§')) { - center = true; - line = line.replace('§§CENTER§§', '').trimStart(); - } - // Detecta negrito, itálico, sublinhado (apenas no início e fim da linha) - if (/^|^/.test(line)) { bold = true; line = line.replace(/^|^/, ''); } - if (/^|^/.test(line)) { italic = true; line = line.replace(/^|^/, ''); } - if (/^/.test(line)) { underline = true; line = line.replace(/^/, ''); } - if (/<\/b>$|<\/strong>$/.test(line)) { bold = true; line = line.replace(/<\/b>$|<\/strong>$/, ''); } - if (/<\/i>$|<\/em>$/.test(line)) { italic = true; line = line.replace(/<\/i>$|<\/em>$/, ''); } - if (/<\/u>$/.test(line)) { underline = true; line = line.replace(/<\/u>$/, ''); } - // Remove tags intermediárias (não suporta aninhamento) - line = line.replace(/<\/?(b|strong|i|em|u)>/gi, ''); - // Quebra em palavras para justificar - const words = line.split(' '); - let currentLine = ''; - for (let i = 0; i < words.length; i++) { - const word = words[i]; - // Garante que múltiplos espaços sejam preservados - const testLine = currentLine ? currentLine + (words[i - 1] === '' ? ' ' : ' ') + word : word; - const width = font.widthOfTextAtSize(testLine, fontSize); - if (width > maxWidth && currentLine) { - lines.push({ text: currentLine, bold, italic, underline, indent, center }); - currentLine = word; - } else { - currentLine = testLine; - } - } - if (currentLine) lines.push({ text: currentLine, bold, italic, underline, indent, center }); - } - return lines; - } + // Fonte Fira Sans (se disponível), senão Helvetica + let font = await pdfDoc.embedFont(StandardFonts.Helvetica); + let fontBold = font, fontItalic = font, fontBoldItalic = font; + try { + const firaPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Regular.ttf'); + const firaBoldPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Bold.ttf'); + const firaItalicPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Italic.ttf'); + const firaBoldItalicPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-BoldItalic.ttf'); + const [fira, firaBold, firaItalic, firaBoldItalic] = await Promise.all([ + fs.readFile(firaPath).catch(() => null), + fs.readFile(firaBoldPath).catch(() => null), + fs.readFile(firaItalicPath).catch(() => null), + fs.readFile(firaBoldItalicPath).catch(() => null), + ]); + if (fira) font = await pdfDoc.embedFont(fira); + if (firaBold) fontBold = await pdfDoc.embedFont(firaBold); + if (firaItalic) fontItalic = await pdfDoc.embedFont(firaItalic); + if (firaBoldItalic) fontBoldItalic = await pdfDoc.embedFont(firaBoldItalic); + } catch { /* fallback para Helvetica */ } + + // Parse TinyMCE HTML para blocos estilizados + const blocks = parseHtmlToBlocks(conteudo); // Carregar o timbrado (se houver) e criar o PDF final let pdfDoc: PDFDocument; @@ -391,119 +420,86 @@ export class PdfService { pdfDoc = await PDFDocument.create(); } - // Fonte Fira Sans (se disponível), senão Helvetica - let font = await pdfDoc.embedFont(StandardFonts.Helvetica); - let fontBold = font, fontItalic = font; - try { - // Tenta carregar Fira Sans se estiver disponível no projeto - const firaPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Regular.ttf'); - const firaBoldPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Bold.ttf'); - const firaItalicPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-Italic.ttf'); - const [fira, firaBold, firaItalic] = await Promise.all([ - fs.readFile(firaPath).catch(() => null), - fs.readFile(firaBoldPath).catch(() => null), - fs.readFile(firaItalicPath).catch(() => null), - ]); - if (fira) font = await pdfDoc.embedFont(fira); - if (firaBold) fontBold = await pdfDoc.embedFont(firaBold); - if (firaItalic) fontItalic = await pdfDoc.embedFont(firaItalic); - } catch { /* fallback para Helvetica */ } - - // Quebrar o texto em páginas - let currentPar = 0; - let currentLine = 0; + // Renderização dos blocos no PDF let pageIndex = 0; + let y = PAGE_HEIGHT - margens.top; + let 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 + }); + } - while (currentPar < paragrafos.length) { - // Adicionar página do timbrado ou em branco - let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); - // Desenhar o timbrado como fundo (usando embeddedPage) - if (timbradoPages.length > 0) { - const embedded = timbradoPages[pageIndex % timbradoPages.length]; - page.drawPage(embedded, { - x: 0, - y: 0, - width: PAGE_WIDTH, - height: PAGE_HEIGHT + for (const block of blocks) { + if (block.tag === 'br') { + 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 width = usedFont.widthOfTextAtSize(block.text, blockFontSize); + + // 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); + } + + // 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; + } + + // Renderiza o texto + page.drawText(block.text, { + 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) }); } - let y = PAGE_HEIGHT - margens.top; - let firstLine = true; - // Escrever texto na página - while (currentPar < paragrafos.length) { - const styledLines = wrapTextStyled(paragrafos[currentPar], font, FONT_SIZE, MAX_WIDTH); - let lineIdx = 0; - if (currentLine > 0) lineIdx = currentLine; - for (; lineIdx < styledLines.length; lineIdx++) { - if (y < margens.bottom + LINE_HEIGHT) { - currentLine = lineIdx; - break; - } - let { text, bold, italic, underline, indent, center } = styledLines[lineIdx]; - let x = margens.left + (indent ? FONT_SIZE * 2 : 0); - let usedFont = bold ? fontBold : italic ? fontItalic : font; - let options: any = { - x, - y, - size: FONT_SIZE, - font: usedFont - }; - // Centralizado - if (center) { - const textWidth = usedFont.widthOfTextAtSize(text, FONT_SIZE); - options.x = margens.left + ((MAX_WIDTH - textWidth) / 2); - page.drawText(text, options); - } - // Justificado (exceto última linha do parágrafo e centralizado) - else if (text.trim().length > 0 && lineIdx < styledLines.length - 1 && !center) { - const words = text.split(' '); - if (words.length > 1) { - const textWidth = usedFont.widthOfTextAtSize(text, FONT_SIZE); - const spaceWidth = usedFont.widthOfTextAtSize(' ', FONT_SIZE); - const totalSpaces = words.length - 1; - const extraSpace = (MAX_WIDTH - (indent ? FONT_SIZE * 2 : 0) - textWidth) / totalSpaces; - let cursor = x; - for (let w = 0; w < words.length; w++) { - page.drawText(words[w], { - x: cursor, - y, - size: FONT_SIZE, - font: usedFont - }); - if (w < words.length - 1) { - cursor += usedFont.widthOfTextAtSize(words[w], FONT_SIZE) + spaceWidth + extraSpace; - } - } - } else { - page.drawText(text, options); - } - } else if (!center) { - page.drawText(text, options); - } - // Sublinhado - if (underline) { - const textWidth = usedFont.widthOfTextAtSize(text, FONT_SIZE); - page.drawLine({ - start: { x: options.x, y: y - 2 }, - end: { x: options.x + textWidth, y: y - 2 }, - thickness: 0.7, - color: rgb(0, 0, 0) - }); - } - y -= LINE_HEIGHT; - currentLine = 0; - firstLine = false; - } - // Espaço extra entre parágrafos - if (lineIdx >= styledLines.length) { - y -= LINE_HEIGHT * 0.5; - } - if (lineIdx < styledLines.length) { - break; - } - currentPar++; + + y -= blockFontSize * 1.5; + // Espaço extra entre parágrafos + if (block.tag === 'p' || block.styles.heading) { + y -= blockFontSize * 0.3; } - pageIndex++; } const pdfBytes = await pdfDoc.save();