Files
advx/src/server/services/PdfService.ts
T
2025-04-22 10:47:12 -03:00

737 lines
26 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 remover todas as tags HTML, exceto as de formatação suportadas
function stripUnsupportedTags(html: string): string {
// Primeiro, simplifica o HTML complexo removendo classes e atributos desnecessários
let simplified = html
// Remove comentários HTML
.replace(/<!--[\s\S]*?-->/g, '')
// Limpa atributos de estilo complexos, mas preserva text-indent e text-align
.replace(/style="[^"]*?text-indent:\s*(\d+(?:\.\d+)?(?:pt|px|em))[^"]*?"/gi, 'data-indent="$1"')
.replace(/style="[^"]*?text-align:\s*(center|justify|right)[^"]*?"/gi, 'data-align="$1"')
// Remove todos os outros atributos de estilo
.replace(/\s+style="[^"]*?"/gi, '')
// Substitui classes específicas por atributos de dados para preservar formatação
.replace(/class="[^"]*?MsoListParagraph[^"]*?"/gi, 'data-list-para="true"')
// Normaliza blockquote e indentações
.replace(/<blockquote[^>]*>/gi, '<p data-indent="40pt">')
.replace(/<\/blockquote>/gi, '</p>')
// Trata listas
.replace(/<li[^>]*>/gi, '<p data-list-item="true">• ')
.replace(/<\/li>/gi, '</p>');
// Remove todas as tags, exceto <b>, <strong>, <i>, <em>, <u>, <br>, <p>, <span>
// e mantém os atributos data-* que acabamos de criar para formatação
return simplified
.replace(/<(b|strong|i|em|u|p|span|br)([^>]*)>/gi, (match, tag, attrs) => {
// Preserva apenas data-* atributos
const dataAttrs = attrs.match(/\s+data-[^=]+="[^"]*"/gi) || [];
return `<${tag}${dataAttrs.join('')}>`;
})
// Remove todas as outras tags não suportadas
.replace(/<(?!\/?(?:b|strong|i|em|u|br|p|span))[^>]+>/gi, '')
// Converte números seguidos de parêntese (como em listas numeradas) em formato padronizado
.replace(/(\d+\))\s+/g, '$1 ');
}
// Função para decodificar entidades HTML comuns (acentuação, símbolos, º, etc)
function decodeHtmlEntities(str: string): string {
if (!str) return '';
// Como estamos em Node.js, usamos apenas o método de substituição
return str
.replace(/\r\n/g, '\n')
// 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;/g, 'º')
.replace(/&ordf;/g, 'ª')
.replace(/&sect;/g, '§')
.replace(/&deg;/g, '°')
.replace(/&copy;/g, '©')
.replace(/&reg;/g, '®')
.replace(/&para;/g, '¶')
.replace(/&bull;/g, '•')
.replace(/&ndash;/g, '–')
.replace(/&mdash;/g, '—')
.replace(/&laquo;/g, '«')
.replace(/&raquo;/g, '»')
.replace(/&lsquo;/g, '‘')
.replace(/&rsquo;/g, '’')
.replace(/&ldquo;/g, '“')
.replace(/&rdquo;/g, '”')
.replace(/&euro;/g, '€')
.replace(/&yen;/g, '¥')
.replace(/&pound;/g, '£')
.replace(/&cent;/g, '¢')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
// Numéricos
.replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code));
}
const texto = decodeHtmlEntities(
stripUnsupportedTags(
this.processarTemplate(conteudo, escritorio)
)
);
// Process paragraph alignment and formatting
const paragrafos = texto.split(/\n{2,}/).map(p => {
let paragraph = p.trim();
if (!paragraph) return '';
// Process paragraph alignment
let align = 'justify'; // default
const alignMatch = paragraph.match(/^§§ALIGN=(center|justify|right)§§/);
if (alignMatch) {
align = alignMatch[1];
paragraph = paragraph.substring(alignMatch[0].length);
}
// Process indentation
let indent = 0;
const indentMatch = paragraph.match(/^§§INDENT=([0-9.]+)(?:pt|px|em)§§/);
if (indentMatch) {
indent = parseFloat(indentMatch[1]);
paragraph = paragraph.substring(indentMatch[0].length);
}
// Mark paragraph with alignment and indentation
return `§§ALIGN=${align}§§§§INDENT=${indent}§§${paragraph}`;
}).filter(Boolean);
/**
* Renderiza um parágrafo com formatação inline (negrito, itálico, sublinhado)
*/
const renderInlineFormattedParagraph = (
page: any,
text: string,
x: number,
y: number,
normalFont: any,
boldFont: any,
italicFont: any,
boldItalicFont: any,
fontSize: number,
color: any,
align: 'left' | 'center' | 'justify' = 'left',
maxWidth: number = MAX_WIDTH,
isContinuation: boolean = false,
marginTop: number = margens.top,
marginBottom: number = margens.bottom,
pageHeight: number = PAGE_HEIGHT
): number | null => {
// Se não houver espaço suficiente na página atual para pelo menos uma linha de texto
if (y < marginBottom + fontSize) {
return null; // Indica que precisa criar nova página
}
const words = text.split(/\s+/);
let currentLine = '';
let currentX = x;
let currentY = y;
const lineHeight = fontSize * 1.2;
// Processamento de formatação inline básica
let formattedChunks: { text: string, isItalic: boolean, isBold: boolean }[] = [];
// Pré-processamento do texto para detectar formatação
let remaining = text;
let inBold = false;
let inItalic = false;
let currentChunk = '';
while (remaining.length > 0) {
if (remaining.startsWith('<b>') || remaining.startsWith('<strong>')) {
// Adiciona o texto acumulado antes da tag
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold });
currentChunk = '';
}
inBold = true;
remaining = remaining.replace(/^<(b|strong)>/, '');
}
else if (remaining.startsWith('</b>') || remaining.startsWith('</strong>')) {
// Adiciona o texto acumulado em negrito
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: true });
currentChunk = '';
}
inBold = false;
remaining = remaining.replace(/^<\/(b|strong)>/, '');
}
else if (remaining.startsWith('<i>') || remaining.startsWith('<em>')) {
// Adiciona o texto acumulado antes da tag
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold });
currentChunk = '';
}
inItalic = true;
remaining = remaining.replace(/^<(i|em)>/, '');
}
else if (remaining.startsWith('</i>') || remaining.startsWith('</em>')) {
// Adiciona o texto acumulado em itálico
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: true, isBold: inBold });
currentChunk = '';
}
inItalic = false;
remaining = remaining.replace(/^<\/(i|em)>/, '');
}
else if (remaining.startsWith('<br>') || remaining.startsWith('<br/>') || remaining.startsWith('<br />')) {
// Adiciona o texto acumulado e então adiciona uma quebra de linha
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold });
currentChunk = '';
}
formattedChunks.push({ text: '\n', isItalic: false, isBold: false });
remaining = remaining.replace(/^<br\s*\/?>/i, '');
}
else {
// Acumula caractere a caractere
currentChunk += remaining[0];
remaining = remaining.substring(1);
}
}
// Adiciona qualquer texto restante
if (currentChunk) {
formattedChunks.push({ text: currentChunk, isItalic: inItalic, isBold: inBold });
}
let lines: { chunks: { text: string, isItalic: boolean, isBold: boolean }[], width: number }[] = [{ chunks: [], width: 0 }];
let currentLineIndex = 0;
for (const chunk of formattedChunks) {
if (chunk.text === '\n') {
// Forçar quebra de linha
lines.push({ chunks: [], width: 0 });
currentLineIndex++;
continue;
}
const words = chunk.text.split(/\s+/);
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (!word) continue;
const font = chunk.isBold && chunk.isItalic ? boldItalicFont :
chunk.isBold ? boldFont :
chunk.isItalic ? italicFont : normalFont;
const wordWidth = font.widthOfTextAtSize(word, fontSize);
const spaceWidth = font.widthOfTextAtSize(' ', fontSize);
// Verificar se precisamos adicionar espaço antes da palavra
let wordWithSpace = word;
let totalWidth = wordWidth;
// Adicionar espaço se não for a primeira palavra da linha e não estivermos após uma quebra forçada
if (lines[currentLineIndex].chunks.length > 0) {
wordWithSpace = ' ' + word;
totalWidth = wordWidth + spaceWidth;
}
// Verificar se a palavra cabe na linha atual
if (lines[currentLineIndex].width + totalWidth <= maxWidth) {
// Adicionar à linha atual
lines[currentLineIndex].chunks.push({
text: wordWithSpace,
isItalic: chunk.isItalic,
isBold: chunk.isBold
});
lines[currentLineIndex].width += totalWidth;
} else {
// Criar nova linha
lines.push({
chunks: [{ text: word, isItalic: chunk.isItalic, isBold: chunk.isBold }],
width: wordWidth
});
currentLineIndex++;
// Verificar se temos espaço para mais uma linha
if (currentY - lineHeight < marginBottom) {
// Não há espaço suficiente para mais uma linha
return null; // Indica que precisa criar nova página
}
}
}
}
// Desenhar as linhas com formatação
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
currentY -= lineHeight;
// Verificar se temos espaço para esta linha
if (currentY < marginBottom) {
return null; // Indica que precisa criar nova página
}
// Ajustar posição X baseada no alinhamento
let startX = x;
if (align === 'center') {
startX = x + (maxWidth - line.width) / 2;
} else if (align === 'justify' && i < lines.length - 1 && line.chunks.length > 1) {
// Justificar texto (exceto última linha do parágrafo)
let wordCount = 0;
let textWidth = 0;
// Contar palavras e determinar largura total do texto
for (const chunk of line.chunks) {
const text = chunk.text.trim();
if (text) {
wordCount += text.split(/\s+/).length;
const font = chunk.isBold && chunk.isItalic ? boldItalicFont :
chunk.isBold ? boldFont :
chunk.isItalic ? italicFont : normalFont;
textWidth += font.widthOfTextAtSize(text, fontSize);
}
}
// Calcular espaçamento extra entre palavras para justificar
const totalSpaces = wordCount - 1;
const spaceWidth = totalSpaces > 0 ? (maxWidth - textWidth) / totalSpaces : 0;
// Desenhar cada chunk com espaçamento justificado
let runningX = x;
for (const chunk of line.chunks) {
const font = chunk.isBold && chunk.isItalic ? boldItalicFont :
chunk.isBold ? boldFont :
chunk.isItalic ? italicFont : normalFont;
// Desenhar texto do chunk
page.drawText(chunk.text, {
x: runningX,
y: currentY,
size: fontSize,
font: font,
color: color
});
// Avançar posição X
runningX += font.widthOfTextAtSize(chunk.text, fontSize) + spaceWidth;
}
// Pular para a próxima linha
continue;
}
// Desenhar cada chunk
let runningX = startX;
for (const chunk of line.chunks) {
const font = chunk.isBold && chunk.isItalic ? boldItalicFont :
chunk.isBold ? boldFont :
chunk.isItalic ? italicFont : normalFont;
page.drawText(chunk.text, {
x: runningX,
y: currentY,
size: fontSize,
font: font,
color: color
});
runningX += font.widthOfTextAtSize(chunk.text, fontSize);
}
}
return currentY; // Retorna a posição Y final
};
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) {
const paraText = paragrafos[currentPar];
// Extract alignment
let align: 'left' | 'center' | 'justify' = 'left';
const alignMatch = paraText.match(/^§§ALIGN=(center|justify|right)§§/);
if (alignMatch) {
align = alignMatch[1] as 'center' | 'justify' | 'left';
}
// Extract indentation
let indent = 0;
const indentMatch = paraText.match(/§§INDENT=([0-9.]+)§§/);
if (indentMatch) {
indent = parseFloat(indentMatch[1]);
}
// Clean text
let text = paraText
.replace(/^§§ALIGN=(center|justify|right)§§/, '')
.replace(/§§INDENT=[0-9.]+§§/, '')
.trim();
let x = margens.left + indent;
let parWidth = MAX_WIDTH - indent;
let yAfter = renderInlineFormattedParagraph(
page,
text,
x,
y,
font,
fontBold,
fontItalic,
fontBoldItalic,
FONT_SIZE,
rgb(0, 0, 0),
align,
parWidth,
false,
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;
});
}
}