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

525 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;
// 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 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, '');
}
// Processar variáveis do template e decodificar entidades HTML
// Mantém tags <b>, <strong>, <i>, <em>, <u>, <br>, <p>, <span style="text-indent:2em;">, <span style="text-align:center;">
const texto = decodeHtmlEntities(
stripUnsupportedTags(
this.processarTemplate(conteudo, escritorio)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<p[^>]*>/gi, '') // Remove apenas a tag de abertura <p>
// Marca recuo de parágrafo
.replace(/<span[^>]*text-indent[^>]*>/gi, '§§INDENT§§')
// Marca centralização
.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()
)
);
// 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);
// Fonte Times New Roman (se disponível), senão Helvetica
let font = null, fontBold = null, fontItalic = null, fontBoldItalic = null;
try {
const timesPath = path.join(process.cwd(), 'public', 'fonts', 'TimesNewRoman.ttf');
const timesBoldPath = path.join(process.cwd(), 'public', 'fonts', 'TimesNewRoman-Bold.ttf');
const timesItalicPath = path.join(process.cwd(), 'public', 'fonts', 'TimesNewRoman-Italic.ttf');
const timesBoldItalicPath = path.join(process.cwd(), 'public', 'fonts', 'TimesNewRoman-BoldItalic.ttf');
const [times, timesBold, timesItalic, timesBoldItalic] = await Promise.all([
fs.readFile(timesPath).catch(() => null),
fs.readFile(timesBoldPath).catch(() => null),
fs.readFile(timesItalicPath).catch(() => null),
fs.readFile(timesBoldItalicPath).catch(() => null),
]);
if (times) font = await pdfDoc.embedFont(times);
if (timesBold) fontBold = await pdfDoc.embedFont(timesBold);
if (timesItalic) fontItalic = await pdfDoc.embedFont(timesItalic);
if (timesBoldItalic) fontBoldItalic = await pdfDoc.embedFont(timesBoldItalic);
} catch { /* fallback para Helvetica */ }
if (!font) font = await pdfDoc.embedFont(StandardFonts.TimesRoman);
if (!fontBold) fontBold = await pdfDoc.embedFont(StandardFonts.TimesBold);
if (!fontItalic) fontItalic = await pdfDoc.embedFont(StandardFonts.TimesItalic);
if (!fontBoldItalic) fontBoldItalic = fontBold;
// Carregar o timbrado (se houver) e criar o PDF final
let pdfDoc: PDFDocument = await PDFDocument.create();
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);
}
}
// Função para quebrar texto em linhas respeitando largura máxima e manter quebras de linha simples
function wrapTextStyled(text: string, font: any, fontBold: any, fontItalic: any, fontBoldItalic: any, fontSize: number, maxWidth: number, indent: boolean, align: string, bold: boolean, italic: boolean): { text: string, font: any, indent: boolean, align: string, bold: boolean, italic: boolean }[] {
// Mantém quebras de linha simples para espaçamento
const rawLines = text.split('\n');
const lines: { text: string, font: any, indent: boolean, align: string, bold: boolean, italic: boolean }[] = [];
for (const rawLine of rawLines) {
let line = rawLine;
// Detecta negrito e itálico inline
let isBold = bold, isItalic = italic;
if (/^<b>|^<strong>/.test(line)) { isBold = true; line = line.replace(/^<b>|^<strong>/, ''); }
if (/^<i>|^<em>/.test(line)) { isItalic = true; line = line.replace(/^<i>|^<em>/, ''); }
if (/<\/b>$|<\/strong>$/.test(line)) { isBold = true; line = line.replace(/<\/b>$|<\/strong>$/, ''); }
if (/<\/i>$|<\/em>$/.test(line)) { isItalic = true; line = line.replace(/<\/i>$|<\/em>$/, ''); }
line = line.replace(/<\/?(b|strong|i|em)>/gi, '');
// Seleciona fonte correta
let usedFont = font;
if (isBold && isItalic && fontBoldItalic) usedFont = fontBoldItalic;
else if (isBold && fontBold) usedFont = fontBold;
else if (isItalic && fontItalic) usedFont = fontItalic;
// Quebra em palavras para justificar
const words = line.split(' ');
let currentLine = '';
for (let i = 0; i < words.length; i++) {
const word = words[i];
const testLine = currentLine ? currentLine + ' ' + word : word;
const width = usedFont.widthOfTextAtSize(testLine, fontSize);
if (width > maxWidth && currentLine) {
lines.push({ text: currentLine, font: usedFont, indent, align, bold: isBold, italic: isItalic });
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push({ text: currentLine, font: usedFont, indent, align, bold: isBold, italic: isItalic });
}
return lines;
}
// Quebrar o texto em páginas
let currentPar = 0;
let currentLine = 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;
let firstLine = true;
while (currentPar < paragrafos.length) {
// Detecta recuo e alinhamento por marcação
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();
// Detecta negrito/itálico no parágrafo inteiro
let bold = /^<b>|^<strong>/.test(text);
let italic = /^<i>|^<em>/.test(text);
const styledLines = wrapTextStyled(text, font, fontBold, fontItalic, fontBoldItalic, FONT_SIZE, MAX_WIDTH - (indent ? FONT_SIZE * 2 : 0), indent, align, bold, italic);
let lineIdx = 0;
if (currentLine > 0) lineIdx = currentLine;
for (; lineIdx < styledLines.length; lineIdx++) {
if (y < margens.bottom + LINE_HEIGHT) {
currentLine = lineIdx;
break;
}
let { text, font: usedFont, indent, align, bold, italic } = styledLines[lineIdx];
let x = margens.left + (indent ? FONT_SIZE * 2 : 0);
// Centralizado
if (align === 'center') {
const textWidth = usedFont.widthOfTextAtSize(text, FONT_SIZE);
x = margens.left + ((MAX_WIDTH - (indent ? FONT_SIZE * 2 : 0) - textWidth) / 2) + (indent ? FONT_SIZE * 2 : 0);
page.drawText(text, {
x,
y,
size: FONT_SIZE,
font: usedFont,
color: rgb(0, 0, 0)
});
}
// Justificado (exceto última linha do parágrafo e centralizado)
else if (text.trim().length > 0 && lineIdx < styledLines.length - 1 && align === 'justify') {
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, { x, y, size: FONT_SIZE, font: usedFont });
}
} else {
page.drawText(text, { x, y, size: FONT_SIZE, font: usedFont });
}
y -= FONT_SIZE * 1.25; // espaçamento entre linhas 1,25
currentLine = 0;
firstLine = false;
}
if (lineIdx >= styledLines.length) {
y -= FONT_SIZE * 0.5;
}
if (lineIdx < styledLines.length) {
break;
}
currentPar++;
}
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;
});
}
}