Files
advx/src/server/services/PdfService.ts
T
2025-04-21 20:15:22 -03:00

508 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import puppeteer from 'puppeteer';
import { prisma } from '../prismaClient';
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import fs from 'fs/promises';
import path from 'path';
/**
* Serviço para geração e manipulação de arquivos PDF
*/
export class PdfService {
/**
* Cria um documento PDF básico
* @returns Documento PDF
*/
async criarDocumento() {
return await PDFDocument.create();
}
/**
* Carrega um documento PDF existente
* @param pdfBytes - Bytes do PDF
* @returns Documento PDF
*/
async carregarDocumento(pdfBytes: Uint8Array) {
return await PDFDocument.load(pdfBytes);
}
/**
* Adiciona uma página ao documento
* @param doc - Documento PDF
* @param tamanho - Tamanho da página (padrão: A4)
* @returns Página adicionada
*/
adicionarPagina(doc: any, tamanho = [595.28, 841.89]) {
return doc.addPage(tamanho);
}
/**
* Salva o documento como buffer
* @param doc - Documento PDF
* @returns Buffer do PDF
*/
async salvarComoBuffer(doc: any): Promise<Buffer> {
const pdfBytes = await doc.save();
return Buffer.from(pdfBytes);
}
/**
* Carrega uma fonte padrão
* @param doc - Documento PDF
* @param fonte - Nome da fonte padrão
* @returns Fonte carregada
*/
async carregarFonte(doc: any, fonte: StandardFonts) {
return await doc.embedFont(fonte);
}
/**
* Desenha texto em uma página
* @param pagina - Página do PDF
* @param texto - Texto a ser desenhado
* @param x - Posição X
* @param y - Posição Y
* @param fonte - Fonte a ser usada
* @param tamanho - Tamanho da fonte
* @param cor - Cor do texto (padrão: preto)
*/
desenharTexto(
pagina: any,
texto: string,
x: number,
y: number,
fonte: any,
tamanho: number,
cor = rgb(0, 0, 0)
) {
pagina.drawText(texto, {
x,
y,
font: fonte,
size: tamanho,
color: cor,
});
}
/**
* Cria um cabeçalho para o documento
* @param pagina - Página do PDF
* @param titulo - Título do cabeçalho
* @param fonte - Fonte a ser usada
* @param margemSuperior - Margem superior
* @param margem - Margem lateral
* @returns Posição Y após o cabeçalho
*/
criarCabecalho(
pagina: any,
titulo: string,
fonte: any,
margemSuperior: number,
margem: number
): number {
const yPos = pagina.getHeight() - margemSuperior;
this.desenharTexto(
pagina,
titulo,
margem,
yPos,
fonte,
18,
rgb(0, 0, 0.7)
);
// Linha separadora
pagina.drawLine({
start: { x: margem, y: yPos - 10 },
end: { x: pagina.getWidth() - margem, y: yPos - 10 },
thickness: 1,
color: rgb(0, 0, 0.5),
});
return yPos - 30; // Retorna a posição Y após o cabeçalho
}
/**
* Cria um rodapé para o documento
* @param pagina - Página do PDF
* @param texto - Texto do rodapé
* @param fonte - Fonte a ser usada
* @param margem - Margem
*/
criarRodape(pagina: any, texto: string, fonte: any, margem: number) {
const yPos = margem / 2;
this.desenharTexto(
pagina,
texto,
margem,
yPos,
fonte,
8,
rgb(0.5, 0.5, 0.5)
);
}
private async getConfiguracao() {
try {
// Buscar configuração ou usar valores padrão
const config = await prisma.configuracaoDocumento.findFirst({
orderBy: { criadoEm: 'desc' },
select: {
id: true,
timbradoUrl: true,
timbradoMarginTop: true,
timbradoMarginBottom: true,
timbradoMarginLeft: true,
timbradoMarginRight: true,
templatePadrao: true,
margensTemplate: true,
criadoEm: true
}
});
console.log('Configuração encontrada:', config); // Debug
// Retornar configuração padrão se não existir
return {
// Valores padrão
cabecalhoHtml: `
<div style="text-align: center; padding: 20px;">
<h2 style="margin:0;font-size:14pt">{{escritorio.nome}}</h2>
</div>
`,
rodapeHtml: `
<div style="text-align: center; font-size: 10pt;">
{{escritorio.endereco}}<br>
Tel: {{escritorio.telefone}} | Email: {{escritorio.email}}
</div>
`,
// Campos que existem no modelo
timbradoUrl: config?.timbradoUrl || '',
timbradoMarginTop: config?.timbradoMarginTop || 100,
timbradoMarginBottom: config?.timbradoMarginBottom || 50,
timbradoMarginLeft: config?.timbradoMarginLeft || 50,
timbradoMarginRight: config?.timbradoMarginRight || 50,
templatePadrao: config?.templatePadrao || null,
margensTemplate: config?.margensTemplate || null
};
} catch (error) {
console.error('Erro ao buscar configuração:', error);
// Retornar configuração padrão para não interromper o fluxo
return {
cabecalhoHtml: `<div style="text-align: center; padding: 20px;"><h2 style="margin:0;font-size:14pt">Documento</h2></div>`,
rodapeHtml: `<div style="text-align: center; font-size: 10pt;">Documento gerado pelo sistema</div>`,
timbradoUrl: '',
timbradoMarginTop: 100,
timbradoMarginBottom: 50,
timbradoMarginLeft: 50,
timbradoMarginRight: 50,
templatePadrao: null,
margensTemplate: null
};
}
}
/**
* Gera um PDF a partir do conteúdo HTML simples (apenas texto) e dados do escritório,
* encaixando o texto sobre o timbrado (PDF de fundo) em todas as páginas.
* Não usa Puppeteer, apenas PDF-LIB.
*/
async gerarPDF(conteudo: string, escritorio: any = {}): Promise<Buffer> {
try {
console.log('Buscando configurações do documento...');
const config = await this.getConfiguracao();
// Verificar se temos um timbrado para usar
const temTimbrado = !!config.timbradoUrl;
let timbradoPath = '';
let timbradoExists = false;
if (temTimbrado) {
if (config.timbradoUrl.startsWith('/uploads')) {
timbradoPath = path.join(process.cwd(), 'public', config.timbradoUrl);
} else {
timbradoPath = path.join(process.cwd(), 'public', 'uploads', path.basename(config.timbradoUrl));
}
try {
await fs.access(timbradoPath);
timbradoExists = true;
} catch (e) {
timbradoExists = false;
}
}
// Margens
const margens = config.margensTemplate || {
top: config.timbradoMarginTop || 100,
left: config.timbradoMarginLeft || 50,
right: config.timbradoMarginRight || 50,
bottom: config.timbradoMarginBottom || 50
};
// Parâmetros de página
const PAGE_WIDTH = 595.28; // A4
const PAGE_HEIGHT = 841.89; // A4
const FONT_SIZE = 12;
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(/&Aacute;/g, 'Á').replace(/&aacute;/g, 'á')
.replace(/&Eacute;/g, 'É').replace(/&eacute;/g, 'é')
.replace(/&Iacute;/g, 'Í').replace(/&iacute;/g, 'í')
.replace(/&Oacute;/g, 'Ó').replace(/&oacute;/g, 'ó')
.replace(/&Uacute;/g, 'Ú').replace(/&uacute;/g, 'ú')
.replace(/&Agrave;/g, 'À').replace(/&agrave;/g, 'à')
.replace(/&Egrave;/g, 'È').replace(/&egrave;/g, 'è')
.replace(/&Igrave;/g, 'Ì').replace(/&igrave;/g, 'ì')
.replace(/&Ograve;/g, 'Ò').replace(/&ograve;/g, 'ò')
.replace(/&Ugrave;/g, 'Ù').replace(/&ugrave;/g, 'ù')
.replace(/&Acirc;/g, 'Â').replace(/&acirc;/g, 'â')
.replace(/&Ecirc;/g, 'Ê').replace(/&ecirc;/g, 'ê')
.replace(/&Icirc;/g, 'Î').replace(/&icirc;/g, 'î')
.replace(/&Ocirc;/g, 'Ô').replace(/&ocirc;/g, 'ô')
.replace(/&Ucirc;/g, 'Û').replace(/&ucirc;/g, 'û')
.replace(/&Atilde;/g, 'Ã').replace(/&atilde;/g, 'ã')
.replace(/&Otilde;/g, 'Õ').replace(/&otilde;/g, 'õ')
.replace(/&Ccedil;/g, 'Ç').replace(/&ccedil;/g, 'ç')
.replace(/&Ntilde;/g, 'Ñ').replace(/&ntilde;/g, 'ñ')
.replace(/&Uuml;/g, 'Ü').replace(/&uuml;/g, 'ü')
// Outras entidades comuns
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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;
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();
// 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);
}
}
// 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);
// 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
});
}
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)
});
}
y -= blockFontSize * 1.5;
// Espaço extra entre parágrafos
if (block.tag === 'p' || block.styles.heading) {
y -= blockFontSize * 0.3;
}
}
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
} catch (error) {
console.error('Erro geral ao gerar PDF:', error);
throw error;
}
}
private processarTemplate(template: string, dados: any): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const keyParts = key.trim().split('.');
let value = dados;
for (const part of keyParts) {
if (value && typeof value === 'object' && part in value) {
value = value[part];
} else {
value = undefined;
break;
}
}
return value !== undefined ? value : match;
});
}
}