Files
advx/src/server/services/PdfService.ts
T
rodrigo 89f1ad96eb fix atendimentos.ts
Signed-off-by: Rodrigo Martins <rodrigo.sousa.577@gmail.com>
2025-04-13 16:48:27 -03:00

818 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 e dados do escritório
* @param conteudo - Conteúdo HTML para o PDF
* @param escritorio - Dados do escritório para substituir no template
* @returns Buffer contendo o PDF gerado
*/
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;
console.log(`Iniciando geração de PDF ${temTimbrado ? 'com' : 'sem'} timbrado...`);
// Se temos timbrado, vamos primeiro verificar se ele existe
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));
}
console.log('Verificando timbrado em:', timbradoPath);
try {
await fs.access(timbradoPath);
timbradoExists = true;
console.log('Timbrado encontrado');
} catch (e) {
console.error('Timbrado não encontrado:', e);
}
}
// Margens definidas no template ou padrão
const margens = config.margensTemplate || {
top: config.timbradoMarginTop || 100,
left: config.timbradoMarginLeft || 50,
right: config.timbradoMarginRight || 50,
bottom: config.timbradoMarginBottom || 50
};
console.log('Usando margens:', margens);
// Abordagem focada apenas no pdf-lib para maximizar consistência
try {
console.log('Gerando PDF com pdf-lib e tratamento aprimorado de HTML...');
// 1. Primeiro carregar o timbrado se existir
let pdfDoc;
const fontSize = 11;
const lineHeight = fontSize * 1.4;
let font, boldFont;
const pageWidth = 595;
const pageHeight = 842;
if (timbradoExists) {
console.log('Usando timbrado como base do documento...');
// Carregar o arquivo de timbrado
const timbradoBytes = await fs.readFile(timbradoPath);
try {
pdfDoc = await PDFDocument.load(timbradoBytes);
console.log('Timbrado carregado com sucesso');
// Verificar se o timbrado tem páginas
if (pdfDoc.getPageCount() === 0) {
console.log('Timbrado não contém páginas, adicionando página padrão');
pdfDoc.addPage([pageWidth, pageHeight]);
}
} catch (loadError) {
console.error('Erro ao carregar timbrado:', loadError);
// Se falhar ao carregar o timbrado, criar novo documento
pdfDoc = await PDFDocument.create();
pdfDoc.addPage([pageWidth, pageHeight]);
}
} else {
// Criar documento em branco
console.log('Criando novo documento sem timbrado...');
pdfDoc = await PDFDocument.create();
pdfDoc.addPage([pageWidth, pageHeight]);
}
// 2. Carregar fontes
font = await pdfDoc.embedFont(StandardFonts.Helvetica);
boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// 3. Processar e formatar o HTML de forma mais precisa
console.log('Processando conteúdo HTML com tratamento aprimorado...');
// Processar variáveis de template
const processedContent = this.processarTemplate(conteudo, escritorio);
// Função para decodificar entidades HTML
const decodeHtmlEntities = (text) => {
const entities = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",
'&cent;': '¢',
'&pound;': '£',
'&yen;': '¥',
'&euro;': '€',
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',
'&Aacute;': 'Á',
'&aacute;': 'á',
'&Eacute;': 'É',
'&eacute;': 'é',
'&Iacute;': 'Í',
'&iacute;': 'í',
'&Oacute;': 'Ó',
'&oacute;': 'ó',
'&Uacute;': 'Ú',
'&uacute;': 'ú',
'&Ccedil;': 'Ç',
'&ccedil;': 'ç',
'&Atilde;': 'Ã',
'&atilde;': 'ã',
'&Otilde;': 'Õ',
'&otilde;': 'õ',
'&Acirc;': 'Â',
'&acirc;': 'â',
'&Ecirc;': 'Ê',
'&ecirc;': 'ê',
'&Ocirc;': 'Ô',
'&ocirc;': 'ô',
'&ndash;': '–',
'&mdash;': '—',
'&minus;': '−'
};
return text.replace(/&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-fA-F]{1,6});/gi, (match) => {
if (entities[match]) {
return entities[match];
} else if (match.startsWith('&#x')) {
// Hexadecimal Unicode
const hex = match.substring(3, match.length - 1);
return String.fromCodePoint(parseInt(hex, 16));
} else if (match.startsWith('&#')) {
// Decimal Unicode
const decimal = match.substring(2, match.length - 1);
return String.fromCodePoint(parseInt(decimal, 10));
}
return match; // Se não foi possível decodificar, manter original
});
};
// Processa o HTML de forma mais estruturada
const processHtml = (html) => {
// Substituir tags comuns por marcadores para melhor formatação
let processed = html
// Corrigir múltiplos &nbsp; para evitar espaços excessivos
.replace(/(&nbsp;){2,}/g, ' ')
.replace(/&nbsp;/g, ' ')
// Preservar quebras de linha
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<\/div>\s*<div[^>]*>/gi, '\n')
// Preservar parágrafos
.replace(/<p[^>]*>/gi, '')
.replace(/<\/p>/gi, '\n\n')
// Preservar divisões
.replace(/<div[^>]*>/gi, '')
.replace(/<\/div>/gi, '\n')
// Preservar cabeçalhos e listas
.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n')
.replace(/<li[^>]*>(.*?)<\/li>/gi, '• $1\n')
// Preservar negrito e itálico
.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '*$1*')
.replace(/<b[^>]*>(.*?)<\/b>/gi, '*$1*')
.replace(/<em[^>]*>(.*?)<\/em>/gi, '_$1_')
.replace(/<i[^>]*>(.*?)<\/i>/gi, '_$1_')
// Remover tags restantes
.replace(/<[^>]*>/g, '');
// Decodificar entidades HTML
processed = decodeHtmlEntities(processed);
// Tratar linhas em branco múltiplas
processed = processed.replace(/\n{3,}/g, '\n\n');
// Dividir em linhas
return processed.split('\n').map(line => line.trim());
};
// Processar linhas do HTML
const lines = processHtml(processedContent);
// 4. Renderizar o conteúdo no PDF
console.log(`Renderizando ${lines.length} linhas de conteúdo...`);
// Obter primeira página do documento
let currentPage = pdfDoc.getPage(0);
let y = pageHeight - margens.top;
let pageIndex = 0;
// Adicionar cabeçalho com título do documento e data
y -= 10;
currentPage.drawText(escritorio.nome || 'Documento', {
x: margens.left,
y,
font: boldFont,
size: fontSize * 1.3
});
y -= lineHeight * 1.5;
const today = new Date().toLocaleDateString('pt-BR');
currentPage.drawText(`Data: ${today}`, {
x: margens.left,
y,
font,
size: fontSize
});
y -= lineHeight * 2;
// Função para quebrar texto em linhas de acordo com a largura disponível
const wrapTextToLines = (text, maxWidth) => {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
// Calcular largura aproximada do texto (melhor estimativa possível)
const testWidth = testLine.length * (fontSize * 0.55);
if (testWidth > maxWidth) {
if (currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
// Palavra muito grande, não quebrar
lines.push(word);
}
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
// Função para adicionar nova página quando necessário
const addNewPageIfNeeded = (minSpace) => {
if (y - minSpace < margens.bottom) {
try {
// Criar nova página
const newPage = pdfDoc.addPage([pageWidth, pageHeight]);
currentPage = newPage;
pageIndex++;
y = pageHeight - margens.top;
return true;
} catch (error) {
console.error('Erro ao adicionar nova página:', error);
return false;
}
}
return false;
};
// Processar cada linha de texto
for (const line of lines) {
if (!line.trim()) {
// Linha vazia, adicionar espaço
y -= lineHeight * 0.5;
continue;
}
// Determinar formatação com base na linha
let currentFont = font;
let currentSize = fontSize;
let extraSpace = 0;
// Verificar se é título
if (line.startsWith('# ')) {
currentFont = boldFont;
currentSize = fontSize * 1.8;
extraSpace = lineHeight * 0.5;
// Desenhar texto sem o marcador
const titleText = line.substring(2).trim();
addNewPageIfNeeded(lineHeight * 2);
// Centralizar título
const titleWidth = titleText.length * (currentSize * 0.55);
const centerX = Math.max(margens.left, (pageWidth - titleWidth) / 2);
currentPage.drawText(titleText, {
x: centerX,
y,
font: currentFont,
size: currentSize
});
y -= lineHeight + extraSpace;
continue;
} else if (line.startsWith('## ')) {
currentFont = boldFont;
currentSize = fontSize * 1.5;
extraSpace = lineHeight * 0.3;
const titleText = line.substring(3).trim();
addNewPageIfNeeded(lineHeight * 1.8);
currentPage.drawText(titleText, {
x: margens.left,
y,
font: currentFont,
size: currentSize
});
y -= lineHeight + extraSpace;
continue;
} else if (line.startsWith('### ')) {
currentFont = boldFont;
currentSize = fontSize * 1.2;
const titleText = line.substring(4).trim();
addNewPageIfNeeded(lineHeight * 1.5);
currentPage.drawText(titleText, {
x: margens.left,
y,
font: currentFont,
size: currentSize
});
y -= lineHeight + extraSpace;
continue;
} else if (line.startsWith('• ')) {
// Lista com marcador
addNewPageIfNeeded(lineHeight);
const bulletText = line.trim();
const availableWidth = pageWidth - margens.left - margens.right - 10; // Espaço para o marcador
// Quebrar o texto em linhas
const bulletWrappedLines = wrapTextToLines(bulletText, availableWidth);
for (let i = 0; i < bulletWrappedLines.length; i++) {
if (i === 0) {
// Primeira linha com marcador
currentPage.drawText(bulletWrappedLines[i], {
x: margens.left,
y,
font: currentFont,
size: currentSize
});
} else {
// Linhas seguintes alinhadas com o texto após o marcador
currentPage.drawText(bulletWrappedLines[i], {
x: margens.left + 10,
y,
font: currentFont,
size: currentSize
});
}
y -= lineHeight;
if (i < bulletWrappedLines.length - 1) {
addNewPageIfNeeded(lineHeight);
}
}
continue;
}
// Verificar se texto contém formatação (negrito/itálico)
const formattedLine = line
.replace(/\*([^*]+)\*/g, '$1') // Remover negrito por enquanto
.replace(/_([^_]+)_/g, '$1'); // Remover itálico por enquanto
// Calcular largura disponível
const availableWidth = pageWidth - margens.left - margens.right;
// Quebrar texto em linhas ajustadas à largura
const wrappedLines = wrapTextToLines(formattedLine, availableWidth);
// Adicionar cada linha ao PDF
for (const wrappedLine of wrappedLines) {
addNewPageIfNeeded(lineHeight);
currentPage.drawText(wrappedLine, {
x: margens.left,
y,
font: currentFont,
size: currentSize
});
y -= lineHeight;
}
// Adicionar espaço extra após parágrafos
if (wrappedLines.length > 0) {
y -= lineHeight * 0.2;
}
}
// Adicionar rodapé na última página com dados do escritório
if (escritorio.nome || escritorio.endereco || escritorio.telefone || escritorio.email) {
const footerY = margens.bottom - 20;
try {
if (escritorio.nome) {
currentPage.drawText(escritorio.nome, {
x: margens.left,
y: footerY,
size: 8,
font
});
}
if (escritorio.endereco) {
currentPage.drawText(escritorio.endereco, {
x: margens.left,
y: footerY - 10,
size: 8,
font
});
}
if (escritorio.telefone || escritorio.email) {
let contactInfo = '';
if (escritorio.telefone) contactInfo += `Tel: ${escritorio.telefone}`;
if (escritorio.telefone && escritorio.email) contactInfo += ' | ';
if (escritorio.email) contactInfo += `Email: ${escritorio.email}`;
currentPage.drawText(contactInfo, {
x: margens.left,
y: footerY - 20,
size: 8,
font
});
}
} catch (footerError) {
console.error('Erro ao adicionar rodapé:', footerError);
}
}
console.log('Salvando documento final...');
const pdfBytes = await pdfDoc.save();
console.log(`PDF gerado com sucesso: ${pdfBytes.length} bytes`);
return Buffer.from(pdfBytes);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
// Fallback absolutamente mínimo e à prova de falhas
try {
console.log('Usando fallback ultra-minimalista...');
// Criar documento do zero da forma mais simples possível
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Desenhar texto mínimo
page.drawText('Documento', {
x: 50,
y: 800,
size: 14,
font
});
page.drawText('Gerado em: ' + new Date().toLocaleString('pt-BR'), {
x: 50,
y: 780,
size: 10,
font
});
// Extrair e adicionar apenas parte do texto
const plainText = conteudo.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
// Garantir que não exceda limite seguro
const safeText = plainText.substring(0, Math.min(plainText.length, 1000));
// Adicionar linhas de texto com posições absolutos
const maxLines = 30;
const lineLength = 60;
for (let i = 0; i < safeText.length && i/lineLength < maxLines; i += lineLength) {
const line = safeText.substring(i, i + lineLength);
const lineY = 750 - Math.floor(i/lineLength) * 12;
if (lineY < 50) break; // Evitar desenhar fora da página
page.drawText(line, {
x: 50,
y: lineY,
size: 9,
font
});
}
// Salvar PDF
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
} catch (fallbackError) {
console.error('Erro no fallback:', fallbackError);
// Criar um PDF manualmente como último recurso
return Buffer.from('%PDF-1.4\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n3 0 obj\n<</Type/Page/MediaBox[0 0 595 842]/Parent 2 0 R/Resources<<>>>>\nendobj\nxref\n0 4\n0000000000 65535 f\n0000000015 00000 n\n0000000060 00000 n\n0000000111 00000 n\ntrailer\n<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF');
}
}
} catch (error) {
console.error('Erro geral ao gerar PDF:', error);
// Método de fallback extremamente básico
try {
console.log('Usando método de fallback simples...');
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595, 842]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Título
page.drawText('Documento de Emergência', {
x: 50,
y: 800,
size: 16,
font
});
// Mensagem de erro
page.drawText('Este documento foi gerado em modo de emergência.', {
x: 50,
y: 760,
size: 10,
font
});
// Adicionar parte do conteúdo como texto plano
const plainText = conteudo.replace(/<[^>]*>/g, ' ').slice(0, 1000);
// Quebrar em linhas
const lines = [];
for (let i = 0; i < plainText.length; i += 70) {
lines.push(plainText.substring(i, i + 70));
}
// Adicionar no máximo 30 linhas
lines.slice(0, 30).forEach((line, index) => {
page.drawText(line, {
x: 50,
y: 720 - (index * 12),
size: 8,
font
});
});
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
} catch (fallbackError) {
console.error('Falha no fallback:', fallbackError);
throw new Error('Falha completa ao gerar PDF');
}
}
}
/**
* Processa um template HTML substituindo variáveis pelos valores do objeto
* @param template - Template HTML com variáveis no formato {{variavel}}
* @param dados - Objeto com valores para substituir
* @returns Template processado
*/
private processarTemplate(template: string, dados: any): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const keyParts = key.trim().split('.');
let value = dados;
// Acessar propriedades aninhadas (ex: escritorio.nome)
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;
});
}
}