diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index 65db0cb..3d4c740 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -284,206 +284,226 @@ export class PdfService { .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(); + // 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, ''); + } - // 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; + // 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() + ) + ); - // 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 } }); + // 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; } - 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)); + if (currentLine) lines.push({ text: currentLine, bold, italic, underline, indent, center }); } - doc.body.childNodes.forEach(node => parseNode(node)); - return blocks; + return lines; } // Carregar o timbrado (se houver) e criar o PDF final + let pdfDoc: PDFDocument; let timbradoPdf: PDFDocument | null = null; let timbradoPageCount = 0; let timbradoPages: any[] = []; - let pdfDoc: PDFDocument = await PDFDocument.create(); - if (timbradoExists) { const timbradoBytes = await fs.readFile(timbradoPath); timbradoPdf = await PDFDocument.load(timbradoBytes); timbradoPageCount = timbradoPdf.getPageCount(); + pdfDoc = await PDFDocument.create(); // 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)]); timbradoPages.push(embedded); } + } else { + pdfDoc = await PDFDocument.create(); } // Fonte Fira Sans (se disponível), senão Helvetica - let font = null, fontBold = null, fontItalic = null, fontBoldItalic = null; - let useCustomFont = false; + 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 firaBoldItalicPath = path.join(process.cwd(), 'public', 'fonts', 'FiraSans-BoldItalic.ttf'); - const [fira, firaBoldBuf, firaItalicBuf, firaBoldItalicBuf] = await Promise.all([ + const [fira, firaBold, firaItalic] = 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); useCustomFont = true; } - if (firaBoldBuf) fontBold = await pdfDoc.embedFont(firaBoldBuf); - if (firaItalicBuf) fontItalic = await pdfDoc.embedFont(firaItalicBuf); - if (firaBoldItalicBuf) fontBoldItalic = await pdfDoc.embedFont(firaBoldItalicBuf); + 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 */ } - if (!font) font = await pdfDoc.embedFont(StandardFonts.Helvetica); - if (!fontBold) fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - if (!fontItalic) fontItalic = await pdfDoc.embedFont(StandardFonts.HelveticaOblique); - if (!fontBoldItalic) fontBoldItalic = fontBold; - // Parse TinyMCE HTML para blocos estilizados - const blocks = parseHtmlToBlocks(conteudo); - - // Renderização dos blocos no PDF + // Quebrar o texto em páginas + let currentPar = 0; + let currentLine = 0; 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 - }); - } - 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; - - // 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); - - // 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 (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) + 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 }); } - - y -= blockFontSize * 1.5; - // Espaço extra entre parágrafos - if (block.tag === 'p' || block.styles.heading) { - y -= blockFontSize * 0.3; + 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++; } + pageIndex++; } const pdfBytes = await pdfDoc.save();