tenta fix pdfservice.js

This commit is contained in:
rodrigo committed 2025-04-21 20:36:43 -03:00
1 parent 76fa2c91ab
commit f14f7ac3fd
1 file changed
+164 -174
+164 -174
View File
@@ -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(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&ndash;/gi, '–')
.replace(/&mdash;/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(`<body>${html}</body>`).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: <span style="font-weight:bold">, <span style="font-style:italic">, <span style="text-decoration:underline">
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(/<div[^>]*class=["'][^"']*MsoNormal[^"']*["'][^>]*>/gi, '<p>');
html = html.replace(/<div[^>]*>/gi, '<p>');
html = html.replace(/<\/div>/gi, '</p>');
html = html.replace(/<br\s*\/?>/gi, '\n');
html = html.replace(/<\/li>/gi, '</li>\n');
html = html.replace(/<\/p>/gi, '</p>\n');
html = html.replace(/<\/h[1-6]>/gi, m => m + '\n');
html = html.replace(/<\/ul>/gi, '</ul>\n');
html = html.replace(/<\/ol>/gi, '</ol>\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(/^<ul[^>]*>/i)) { inList = true; listType = 'ul'; continue; }
if (line.match(/^<ol[^>]*>/i)) { inList = true; listType = 'ol'; continue; }
if (line.match(/^<\/ul>/i) || line.match(/^<\/ol>/i)) { inList = false; listType = ''; continue; }
if (line.match(/^<li[^>]*>/i)) {
let text = line.replace(/^<li[^>]*>/i, '').replace(/<\/li>$/i, '');
blocks.push({ inlines: parseInline(text), tag: 'li', styles: {} });
continue;
}
// Títulos
let headingMatch = line.match(/^<h([1-6])([^>]*)>(.*?)<\/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([^>]*)>(.*?)<\/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[^>]*>(.*?)<\/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 (/^<u/i.test(match[0])) style.underline = true;
if (/^<span/i.test(match[0])) {
const fontMatch = match[0].match(/font-family:\s*['"]?([^;"']+)/i);
if (fontMatch) style.fontFamily = fontMatch[1];
}
stack.push(style);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < html.length) {
result.push({
text: html.substring(lastIndex),
...stack.reduce((acc, s) => ({ ...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;
}