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, '') // 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();
,
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/