383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
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;
|
|
|
|
// Processar variáveis do template
|
|
const texto = this.processarTemplate(conteudo, escritorio)
|
|
.replace(/<[^>]+>/g, '') // Remove tags HTML
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\r/g, '\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
|
|
// Quebra o texto em parágrafos
|
|
const paragrafos = texto.split(/\n{2,}/).map(p => p.trim()).filter(Boolean);
|
|
|
|
// Função para quebrar texto em linhas respeitando largura máxima
|
|
function wrapText(text: string, font: any, fontSize: number, maxWidth: number): string[] {
|
|
// Remove quebras de linha internas para evitar erro de encoding
|
|
const cleanText = text.replace(/\r?\n/g, ' ');
|
|
const words = cleanText.split(' ');
|
|
const lines: string[] = [];
|
|
let currentLine = '';
|
|
for (const word of words) {
|
|
const testLine = currentLine ? currentLine + ' ' + word : word;
|
|
const width = font.widthOfTextAtSize(testLine, fontSize);
|
|
if (width > maxWidth && currentLine) {
|
|
lines.push(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
currentLine = testLine;
|
|
}
|
|
}
|
|
if (currentLine) lines.push(currentLine);
|
|
return lines;
|
|
}
|
|
|
|
// Carregar o timbrado (se houver) e criar o PDF final
|
|
let pdfDoc: PDFDocument;
|
|
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();
|
|
pdfDoc = await PDFDocument.create();
|
|
// 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);
|
|
}
|
|
} else {
|
|
pdfDoc = await PDFDocument.create();
|
|
}
|
|
|
|
// Fonte
|
|
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
|
|
// Quebrar o texto em páginas
|
|
let currentPar = 0;
|
|
let currentLine = 0;
|
|
let pageIndex = 0;
|
|
|
|
while (currentPar < paragrafos.length) {
|
|
// Adicionar página do timbrado ou em branco
|
|
let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
|
// Desenhar o timbrado como fundo (usando embeddedPage)
|
|
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;
|
|
// Escrever texto na página
|
|
while (currentPar < paragrafos.length) {
|
|
const lines = wrapText(paragrafos[currentPar], font, FONT_SIZE, MAX_WIDTH);
|
|
let lineIdx = 0;
|
|
if (currentLine > 0) lineIdx = currentLine;
|
|
for (; lineIdx < lines.length; lineIdx++) {
|
|
if (y < margens.bottom + LINE_HEIGHT) {
|
|
currentLine = lineIdx;
|
|
break;
|
|
}
|
|
page.drawText(lines[lineIdx], {
|
|
x: margens.left,
|
|
y,
|
|
size: FONT_SIZE,
|
|
font
|
|
});
|
|
y -= LINE_HEIGHT;
|
|
currentLine = 0;
|
|
}
|
|
if (lineIdx < lines.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;
|
|
});
|
|
}
|
|
}
|