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

599 lines
20 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;
// Crie o PDFDocument ANTES de tentar usar pdfDoc para embedFont!
let pdfDoc: PDFDocument = await PDFDocument.create();
// Fonte padrão do pdf-lib: TimesRoman, TimesBold, TimesItalic
const font = await pdfDoc.embedFont('Times-Roman');
const fontBold = await pdfDoc.embedFont('Times-Bold');
const fontItalic = await pdfDoc.embedFont('Times-Italic');
const fontBoldItalic = fontBold;
// Função para decodificar entidades HTML comuns (acentuação, símbolos, º, etc)
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, 'ü')
// Símbolos comuns
.replace(/&ordm;/gi, 'º')
.replace(/&ordf;/gi, 'ª')
.replace(/&sect;/gi, '§')
.replace(/&deg;/gi, '°')
.replace(/&copy;/gi, '©')
.replace(/&reg;/gi, '®')
.replace(/&para;/gi, '¶')
.replace(/&bull;/gi, '•')
.replace(/&ndash;/gi, '–')
.replace(/&mdash;/gi, '—')
.replace(/&laquo;/gi, '«')
.replace(/&raquo;/gi, '»')
.replace(/&lsquo;/gi, '‘')
.replace(/&rsquo;/gi, '’')
.replace(/&ldquo;/gi, '“')
.replace(/&rdquo;/gi, '”')
.replace(/&euro;/gi, '€')
.replace(/&yen;/gi, '¥')
.replace(/&pound;/gi, '£')
.replace(/&cent;/gi, '¢')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
// Numéricos
.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 <b>, <strong>, <i>, <em>, <u>, <br>, <p>, <span style="text-indent:2em;">, <span style="text-align:center;">
// Remove atributos de <span> exceto text-indent e text-align
return html
// Mantém apenas <span> com text-indent ou text-align:center
.replace(/<span\b(?:(?!text-indent|text-align)[^>])*?>/gi, '')
// Remove atributos de <b>, <strong>, <i>, <em>, <u>, <p>
.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>(.*?)<\/span>/gi, '$1')
// Remove comentários HTML
.replace(/<!--[\s\S]*?-->/g, '');
}
// Função para renderizar texto com formatação inline (negrito, itálico, sublinhado) e controle de quebra de linha e recuo
function renderInlineFormattedParagraph(
page,
text: string,
x: number,
y: number,
font,
fontBold,
fontItalic,
fontBoldItalic,
fontSize: number,
color,
align: string,
maxWidth: number,
indent: boolean,
topMargin: number,
bottomMargin: number,
pageHeight: number
) {
let regex = /(<\/?(b|strong|i|em|u)>)/gi;
let tokens: { text: string, bold: boolean, italic: boolean, underline: boolean }[] = [];
let lastIndex = 0;
let match;
let currentStyle = { bold: false, italic: false, underline: false };
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
tokens.push({
text: text.substring(lastIndex, match.index),
bold: currentStyle.bold,
italic: currentStyle.italic,
underline: currentStyle.underline
});
}
if (/^<(b|strong)>$/i.test(match[0])) currentStyle.bold = true;
if (/^<\/(b|strong)>$/i.test(match[0])) currentStyle.bold = false;
if (/^<(i|em)>$/i.test(match[0])) currentStyle.italic = true;
if (/^<\/(i|em)>$/i.test(match[0])) currentStyle.italic = false;
if (/^<u>$/i.test(match[0])) currentStyle.underline = true;
if (/^<\/u>$/i.test(match[0])) currentStyle.underline = false;
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
tokens.push({
text: text.substring(lastIndex),
bold: currentStyle.bold,
italic: currentStyle.italic,
underline: currentStyle.underline
});
}
let words: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] = [];
for (const token of tokens) {
let usedFont = font;
if (token.bold && token.italic) usedFont = fontBoldItalic;
else if (token.bold) usedFont = fontBold;
else if (token.italic) usedFont = fontItalic;
const split = token.text.split(/(\s+)/);
for (const part of split) {
if (part.length > 0) {
words.push({ text: part, font: usedFont, underline: token.underline, bold: token.bold, italic: token.italic });
}
}
}
let lines: { tokens: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] }[] = [];
let currentTokens: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] = [];
let currentWidth = 0;
for (const [i, word] of words.entries()) {
let recuo = 0;
if (indent && lines.length === 0 && currentTokens.length === 0) {
recuo = fontSize * 2;
}
const wordWidth = word.font.widthOfTextAtSize(word.text, fontSize);
if (currentWidth + wordWidth + recuo > maxWidth && currentTokens.length > 0) {
lines.push({ tokens: currentTokens });
currentTokens = [word];
currentWidth = wordWidth;
} else {
currentTokens.push(word);
currentWidth += wordWidth;
}
}
if (currentTokens.length > 0) {
lines.push({ tokens: currentTokens });
}
for (let i = 0; i < lines.length; i++) {
if (y < bottomMargin + fontSize * 1.5) {
return null;
}
let line = lines[i];
let drawX = x;
let totalWidth = line.tokens.reduce((acc, t) => acc + t.font.widthOfTextAtSize(t.text, fontSize), 0);
let recuo = 0;
if (indent && i === 0) {
recuo = fontSize * 2;
drawX += recuo;
}
if (align === 'center') {
drawX = x + (maxWidth - totalWidth) / 2;
} else if (align === 'justify' && i < lines.length - 1) {
const spaceCount = line.tokens.filter(t => /^\s+$/.test(t.text)).length;
const extraSpace = spaceCount > 0 ? (maxWidth - totalWidth) / spaceCount : 0;
for (const t of line.tokens) {
page.drawText(t.text, {
x: drawX,
y,
size: fontSize,
font: t.font,
color
});
if (t.underline && t.text.trim() !== '') {
const underlineY = y - 1.5;
page.drawLine({
start: { x: drawX, y: underlineY },
end: { x: drawX + t.font.widthOfTextAtSize(t.text, fontSize), y: underlineY },
thickness: 0.75,
color
});
}
drawX += t.font.widthOfTextAtSize(t.text, fontSize);
if (/^\s+$/.test(t.text)) {
drawX += extraSpace;
}
}
y -= fontSize * 1.5;
continue;
}
for (const t of line.tokens) {
page.drawText(t.text, {
x: drawX,
y,
size: fontSize,
font: t.font,
color
});
if (t.underline && t.text.trim() !== '') {
const underlineY = y - 1.5;
page.drawLine({
start: { x: drawX, y: underlineY },
end: { x: drawX + t.font.widthOfTextAtSize(t.text, fontSize), y: underlineY },
thickness: 0.75,
color
});
}
drawX += t.font.widthOfTextAtSize(t.text, fontSize);
}
y -= fontSize * 1.5;
}
return y;
}
const texto = decodeHtmlEntities(
stripUnsupportedTags(
this.processarTemplate(conteudo, escritorio)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<p[^>]*>/gi, '')
.replace(/<span[^>]*text-indent[^>]*>/gi, '§§INDENT§§')
.replace(/<span[^>]*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()
)
);
const paragrafos = texto.split(/\n{2,}/).map(p => p.trim()).filter(Boolean);
let timbradoPdf: PDFDocument | null = null;
let timbradoPageCount = 0;
let timbradoPages: any[] = [];
if (timbradoExists) {
const timbradoBytes = await fs.readFile(timbradoPath);
timbradoPdf = await PDFDocument.load(timbradoBytes);
timbradoPageCount = timbradoPdf.getPageCount();
timbradoPages = [];
for (let i = 0; i < timbradoPageCount; i++) {
const [embedded] = await pdfDoc.embedPages([timbradoPdf.getPage(i)]);
timbradoPages.push(embedded);
}
}
let currentPar = 0;
let pageIndex = 0;
while (currentPar < paragrafos.length) {
let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
if (timbradoPages.length > 0) {
const embedded = timbradoPages[pageIndex % timbradoPages.length];
page.drawPage(embedded, {
x: 0,
y: 0,
width: PAGE_WIDTH,
height: PAGE_HEIGHT
});
}
let y = PAGE_HEIGHT - margens.top;
while (currentPar < paragrafos.length) {
let indent = paragrafos[currentPar].startsWith('§§INDENT§§');
let center = paragrafos[currentPar].startsWith('§§CENTER§§');
let align = center ? 'center' : 'justify';
let text = paragrafos[currentPar].replace(/^§§INDENT§§/, '').replace(/^§§CENTER§§/, '').trim();
text = decodeHtmlEntities(text);
let x = margens.left;
let parWidth = MAX_WIDTH;
let yAfter = renderInlineFormattedParagraph(
page,
text,
x,
y,
font,
fontBold,
fontItalic,
fontBoldItalic,
FONT_SIZE,
rgb(0, 0, 0),
align,
parWidth,
indent,
margens.top,
margens.bottom,
PAGE_HEIGHT
);
if (yAfter === null) {
pageIndex++;
break;
}
y = yAfter - FONT_SIZE * 0.5;
currentPar++;
if (y < margens.bottom + FONT_SIZE * 1.5) break;
}
pageIndex++;
}
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;
});
}
}