refactor: replace Puppeteer with PDFKit for PDF generation
- Updated package.json to remove Puppeteer and add PDFKit and its types. - Refactored generate-pdf.ts to utilize PDFKit for generating PDFs instead of Puppeteer. - Implemented functions to add headers, summaries, expense tables, and receipt images using PDFKit. - Removed HTML content generation and related functions, streamlining the PDF generation process. - Added error handling for receipt image fetching and improved logging.
This commit is contained in:
@@ -3,7 +3,8 @@ import { getExpenseById } from '@/server/utils/nocodb';
|
||||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||
import { createError } from 'h3';
|
||||
import { formatDate } from '@/utils/dateUtils';
|
||||
import puppeteer from 'puppeteer';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { getMinioClient } from '@/server/utils/minio';
|
||||
|
||||
interface PDFOptions {
|
||||
documentName: string;
|
||||
@@ -79,11 +80,8 @@ export default defineEventHandler(async (event) => {
|
||||
console.log('[expenses/generate-pdf] Successfully calculated totals:', totals);
|
||||
console.log('[expenses/generate-pdf] Options received:', options);
|
||||
|
||||
// Generate HTML content
|
||||
const htmlContent = generateHTMLContent(expenses, options, totals);
|
||||
|
||||
// Convert HTML to PDF using Puppeteer
|
||||
const pdfBuffer = await generatePDFFromHTML(htmlContent, options);
|
||||
// Generate PDF using PDFKit
|
||||
const pdfBuffer = await generatePDFWithPDFKit(expenses, options, totals);
|
||||
|
||||
// Return PDF as base64 for download
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
@@ -99,11 +97,6 @@ export default defineEventHandler(async (event) => {
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
// If it's our intentional error, re-throw it
|
||||
if (error.statusCode === 501) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('[expenses/generate-pdf] Error generating PDF:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
@@ -112,7 +105,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean) {
|
||||
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false) {
|
||||
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
||||
const usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0);
|
||||
|
||||
@@ -137,127 +130,297 @@ function getGroupingLabel(groupBy: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function generateHTMLContent(expenses: Expense[], options: PDFOptions, totals: any): string {
|
||||
// Generate HTML content that can be converted to PDF
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${options.documentName}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.header { text-align: center; margin-bottom: 30px; border-bottom: 2px solid #333; padding-bottom: 20px; }
|
||||
.document-title { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
|
||||
.subheader { font-size: 16px; color: #666; }
|
||||
.summary { background-color: #f5f5f5; padding: 15px; margin: 20px 0; border-radius: 5px; }
|
||||
.expense-table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
.expense-table th, .expense-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
.expense-table th { background-color: #f2f2f2; font-weight: bold; }
|
||||
.expense-table tr:nth-child(even) { background-color: #f9f9f9; }
|
||||
.group-header { background-color: #e7f3ff; font-weight: bold; }
|
||||
.total-row { background-color: #d4edda; font-weight: bold; }
|
||||
.processing-fee { background-color: #fff3cd; }
|
||||
.final-total { background-color: #d1ecf1; font-weight: bold; font-size: 1.1em; }
|
||||
.date-generated { text-align: right; color: #666; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="document-title">${options.documentName}</div>
|
||||
${options.subheader ? `<div class="subheader">${options.subheader}</div>` : ''}
|
||||
</div>
|
||||
|
||||
${options.includeSummary ? `
|
||||
<div class="summary">
|
||||
<h3>Summary</h3>
|
||||
<p><strong>Total Expenses:</strong> ${totals.count}</p>
|
||||
<p><strong>Subtotal:</strong> €${totals.originalTotal.toFixed(2)}</p>
|
||||
<p><strong>USD Equivalent:</strong> $${totals.usdTotal.toFixed(2)}</p>
|
||||
${options.includeProcessingFee ? `<p><strong>Processing Fee (5%):</strong> €${totals.processingFee.toFixed(2)}</p>` : ''}
|
||||
<p><strong>Final Total:</strong> €${totals.finalTotal.toFixed(2)}</p>
|
||||
<p><strong>Grouping:</strong> ${getGroupingLabel(options.groupBy)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${options.includeDetails ? generateExpenseTable(expenses, options) : ''}
|
||||
|
||||
<div class="date-generated">
|
||||
Generated on: ${new Date().toLocaleString()}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
function getPageDimensions(pageFormat: string) {
|
||||
switch (pageFormat) {
|
||||
case 'Letter':
|
||||
return { width: 612, height: 792 }; // 8.5" x 11"
|
||||
case 'Legal':
|
||||
return { width: 612, height: 1008 }; // 8.5" x 14"
|
||||
case 'A4':
|
||||
default:
|
||||
return { width: 595, height: 842 }; // A4
|
||||
}
|
||||
}
|
||||
|
||||
function generateExpenseTable(expenses: Expense[], options: PDFOptions): string {
|
||||
let tableHTML = `
|
||||
<table class="expense-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Establishment</th>
|
||||
<th>Category</th>
|
||||
<th>Payer</th>
|
||||
<th>Amount</th>
|
||||
<th>Payment Method</th>
|
||||
${options.includeReceiptContents ? '<th>Description</th>' : ''}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
async function generatePDFWithPDFKit(expenses: Expense[], options: PDFOptions, totals: any): Promise<Buffer> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
console.log('[expenses/generate-pdf] Generating PDF with PDFKit...');
|
||||
|
||||
const pageDimensions = getPageDimensions(options.pageFormat);
|
||||
const doc = new PDFDocument({
|
||||
size: [pageDimensions.width, pageDimensions.height],
|
||||
margins: { top: 60, bottom: 60, left: 60, right: 60 }
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => {
|
||||
const pdfBuffer = Buffer.concat(chunks);
|
||||
console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes');
|
||||
resolve(pdfBuffer);
|
||||
});
|
||||
doc.on('error', reject);
|
||||
|
||||
// Add header
|
||||
addHeader(doc, options);
|
||||
|
||||
// Add summary if requested
|
||||
if (options.includeSummary) {
|
||||
addSummary(doc, totals, options);
|
||||
}
|
||||
|
||||
// Add expense details if requested
|
||||
if (options.includeDetails) {
|
||||
await addExpenseTable(doc, expenses, options);
|
||||
}
|
||||
|
||||
// Add receipt images if requested
|
||||
if (options.includeReceipts) {
|
||||
await addReceiptImages(doc, expenses);
|
||||
}
|
||||
|
||||
// Add footer
|
||||
addFooter(doc);
|
||||
|
||||
doc.end();
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[expenses/generate-pdf] PDFKit error:', error);
|
||||
reject(new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addHeader(doc: PDFKit.PDFDocument, options: PDFOptions) {
|
||||
doc.fontSize(24)
|
||||
.font('Helvetica-Bold')
|
||||
.text(options.documentName, { align: 'center' });
|
||||
|
||||
if (options.subheader) {
|
||||
doc.fontSize(16)
|
||||
.font('Helvetica')
|
||||
.fillColor('#666666')
|
||||
.text(options.subheader, { align: 'center' });
|
||||
}
|
||||
|
||||
// Add line separator
|
||||
const y = doc.y + 10;
|
||||
doc.moveTo(60, y)
|
||||
.lineTo(doc.page.width - 60, y)
|
||||
.strokeColor('#333333')
|
||||
.lineWidth(2)
|
||||
.stroke();
|
||||
|
||||
doc.y = y + 20;
|
||||
doc.fillColor('#000000'); // Reset color
|
||||
}
|
||||
|
||||
function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
|
||||
doc.fontSize(18)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Summary', { continued: false });
|
||||
|
||||
doc.y += 10;
|
||||
|
||||
// Summary box
|
||||
const boxY = doc.y;
|
||||
const boxHeight = options.includeProcessingFee ? 140 : 120;
|
||||
|
||||
doc.rect(60, boxY, doc.page.width - 120, boxHeight)
|
||||
.fillColor('#f5f5f5')
|
||||
.fill()
|
||||
.strokeColor('#dddddd')
|
||||
.stroke();
|
||||
|
||||
doc.fillColor('#000000');
|
||||
|
||||
// Summary content
|
||||
doc.y = boxY + 15;
|
||||
doc.fontSize(12)
|
||||
.font('Helvetica');
|
||||
|
||||
const leftX = 80;
|
||||
const rightX = doc.page.width - 200;
|
||||
|
||||
doc.text(`Total Expenses:`, leftX, doc.y, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` ${totals.count}`, { align: 'left' });
|
||||
|
||||
doc.font('Helvetica')
|
||||
.text(`Subtotal:`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` €${totals.originalTotal.toFixed(2)}`, { align: 'left' });
|
||||
|
||||
doc.font('Helvetica')
|
||||
.text(`USD Equivalent:`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` $${totals.usdTotal.toFixed(2)}`, { align: 'left' });
|
||||
|
||||
if (options.includeProcessingFee) {
|
||||
doc.font('Helvetica')
|
||||
.text(`Processing Fee (5%):`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` €${totals.processingFee.toFixed(2)}`, { align: 'left' });
|
||||
}
|
||||
|
||||
doc.font('Helvetica')
|
||||
.text(`Final Total:`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(14)
|
||||
.text(` €${totals.finalTotal.toFixed(2)}`, { align: 'left' });
|
||||
|
||||
doc.fontSize(12)
|
||||
.font('Helvetica')
|
||||
.text(`Grouping:`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` ${getGroupingLabel(options.groupBy)}`, { align: 'left' });
|
||||
|
||||
doc.y = boxY + boxHeight + 20;
|
||||
}
|
||||
|
||||
async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], options: PDFOptions) {
|
||||
doc.fontSize(18)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Expense Details', { continued: false });
|
||||
|
||||
doc.y += 15;
|
||||
|
||||
const tableTop = doc.y;
|
||||
const rowHeight = 25;
|
||||
const fontSize = 9;
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{ header: 'Date', width: 70, x: 60 },
|
||||
{ header: 'Establishment', width: 120, x: 130 },
|
||||
{ header: 'Category', width: 60, x: 250 },
|
||||
{ header: 'Payer', width: 60, x: 310 },
|
||||
{ header: 'Amount', width: 60, x: 370 },
|
||||
{ header: 'Payment', width: 50, x: 430 }
|
||||
];
|
||||
|
||||
if (options.includeReceiptContents) {
|
||||
columns.push({ header: 'Description', width: 85, x: 480 });
|
||||
}
|
||||
|
||||
// Draw table header
|
||||
doc.fontSize(fontSize + 1)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor('#000000');
|
||||
|
||||
// Header background
|
||||
doc.rect(60, tableTop, doc.page.width - 120, rowHeight)
|
||||
.fillColor('#f2f2f2')
|
||||
.fill()
|
||||
.strokeColor('#dddddd')
|
||||
.stroke();
|
||||
|
||||
doc.fillColor('#000000');
|
||||
|
||||
columns.forEach(col => {
|
||||
doc.text(col.header, col.x, tableTop + 8, { width: col.width, align: 'left' });
|
||||
});
|
||||
|
||||
let currentY = tableTop + rowHeight;
|
||||
|
||||
// Group expenses if needed
|
||||
if (options.groupBy === 'none') {
|
||||
// No grouping - just list all expenses
|
||||
expenses.forEach(expense => {
|
||||
tableHTML += generateExpenseRow(expense, options);
|
||||
});
|
||||
currentY = await drawExpenseRows(doc, expenses, columns, currentY, rowHeight, fontSize, options);
|
||||
} else {
|
||||
// Group expenses
|
||||
const groups = groupExpenses(expenses, options.groupBy);
|
||||
|
||||
Object.keys(groups).forEach(groupKey => {
|
||||
const groupExpenses = groups[groupKey];
|
||||
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
||||
for (const [groupKey, groupExpenses] of Object.entries(groups)) {
|
||||
// Check if we need a new page
|
||||
if (currentY > doc.page.height - 100) {
|
||||
doc.addPage();
|
||||
currentY = 60;
|
||||
}
|
||||
|
||||
// Group header
|
||||
tableHTML += `
|
||||
<tr class="group-header">
|
||||
<td colspan="${options.includeReceiptContents ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
|
||||
</tr>
|
||||
`;
|
||||
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
||||
doc.fontSize(fontSize + 1)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor('#000000');
|
||||
|
||||
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
|
||||
.fillColor('#e7f3ff')
|
||||
.fill()
|
||||
.strokeColor('#dddddd')
|
||||
.stroke();
|
||||
|
||||
doc.fillColor('#000000')
|
||||
.text(`${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})`,
|
||||
65, currentY + 8, { width: doc.page.width - 130 });
|
||||
|
||||
currentY += rowHeight;
|
||||
|
||||
// Group expenses
|
||||
groupExpenses.forEach(expense => {
|
||||
tableHTML += generateExpenseRow(expense, options);
|
||||
});
|
||||
});
|
||||
currentY = await drawExpenseRows(doc, groupExpenses, columns, currentY, rowHeight, fontSize, options);
|
||||
}
|
||||
}
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
return tableHTML;
|
||||
}
|
||||
|
||||
function generateExpenseRow(expense: Expense, options: PDFOptions): string {
|
||||
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
|
||||
const description = expense.Contents || 'N/A';
|
||||
async function drawExpenseRows(
|
||||
doc: PDFKit.PDFDocument,
|
||||
expenses: Expense[],
|
||||
columns: any[],
|
||||
startY: number,
|
||||
rowHeight: number,
|
||||
fontSize: number,
|
||||
options: PDFOptions
|
||||
): Promise<number> {
|
||||
let currentY = startY;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${date}</td>
|
||||
<td>${expense['Establishment Name'] || 'N/A'}</td>
|
||||
<td>${expense.Category || 'N/A'}</td>
|
||||
<td>${expense.Payer || 'N/A'}</td>
|
||||
<td>€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}</td>
|
||||
<td>${expense['Payment Method'] || 'N/A'}</td>
|
||||
${options.includeReceiptContents ? `<td>${description}</td>` : ''}
|
||||
</tr>
|
||||
`;
|
||||
doc.fontSize(fontSize)
|
||||
.font('Helvetica');
|
||||
|
||||
expenses.forEach((expense, index) => {
|
||||
// Check if we need a new page
|
||||
if (currentY > doc.page.height - 100) {
|
||||
doc.addPage();
|
||||
currentY = 60;
|
||||
}
|
||||
|
||||
// Alternate row colors
|
||||
if (index % 2 === 0) {
|
||||
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
|
||||
.fillColor('#f9f9f9')
|
||||
.fill();
|
||||
}
|
||||
|
||||
doc.fillColor('#000000');
|
||||
|
||||
// Draw row data
|
||||
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
|
||||
const establishment = expense['Establishment Name'] || 'N/A';
|
||||
const category = expense.Category || 'N/A';
|
||||
const payer = expense.Payer || 'N/A';
|
||||
const amount = `€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}`;
|
||||
const payment = expense['Payment Method'] || 'N/A';
|
||||
|
||||
const rowData = [date, establishment, category, payer, amount, payment];
|
||||
|
||||
if (options.includeReceiptContents) {
|
||||
const description = expense.Contents || 'N/A';
|
||||
rowData.push(description.length > 30 ? description.substring(0, 27) + '...' : description);
|
||||
}
|
||||
|
||||
rowData.forEach((data, colIndex) => {
|
||||
if (colIndex < columns.length) {
|
||||
doc.text(data, columns[colIndex].x, currentY + 8, {
|
||||
width: columns[colIndex].width - 5,
|
||||
align: 'left',
|
||||
ellipsis: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
currentY += rowHeight;
|
||||
});
|
||||
|
||||
return currentY;
|
||||
}
|
||||
|
||||
function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Expense[]> {
|
||||
@@ -287,77 +450,124 @@ function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Exp
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function generatePDFFromHTML(htmlContent: string, options: PDFOptions): Promise<Buffer> {
|
||||
let browser;
|
||||
async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||
console.log('[expenses/generate-pdf] Adding receipt images...');
|
||||
|
||||
try {
|
||||
console.log('[expenses/generate-pdf] Launching Puppeteer browser...');
|
||||
|
||||
// Launch browser with optimized settings
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set content with proper encoding
|
||||
await page.setContent(htmlContent, {
|
||||
waitUntil: 'networkidle0',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// Get page format dimensions
|
||||
const format = getPageFormat(options.pageFormat);
|
||||
|
||||
console.log('[expenses/generate-pdf] Generating PDF with format:', format);
|
||||
|
||||
// Generate PDF with proper options
|
||||
const pdfUint8Array = await page.pdf({
|
||||
format: format.format,
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
},
|
||||
preferCSSPageSize: true
|
||||
});
|
||||
|
||||
// Convert Uint8Array to Buffer
|
||||
const pdfBuffer = Buffer.from(pdfUint8Array);
|
||||
|
||||
console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes');
|
||||
|
||||
return pdfBuffer;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[expenses/generate-pdf] Puppeteer error:', error);
|
||||
throw new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
const expensesWithReceipts = expenses.filter(expense =>
|
||||
expense.Receipt && Array.isArray(expense.Receipt) && expense.Receipt.length > 0
|
||||
);
|
||||
|
||||
if (expensesWithReceipts.length === 0) {
|
||||
console.log('[expenses/generate-pdf] No receipts found to include');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new page for receipts
|
||||
doc.addPage();
|
||||
|
||||
doc.fontSize(18)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Receipt Images', { align: 'center' });
|
||||
|
||||
doc.y += 20;
|
||||
|
||||
for (const expense of expensesWithReceipts) {
|
||||
try {
|
||||
// Add expense header
|
||||
doc.fontSize(14)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`Receipt for: ${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
||||
{ align: 'left' });
|
||||
|
||||
doc.fontSize(12)
|
||||
.font('Helvetica')
|
||||
.text(`Date: ${expense.Time ? formatDate(expense.Time) : 'N/A'}`, { align: 'left' });
|
||||
|
||||
doc.y += 10;
|
||||
|
||||
// Process receipt images
|
||||
if (expense.Receipt) {
|
||||
for (const receipt of expense.Receipt) {
|
||||
if (receipt.url || receipt.directus_files_id?.filename_download) {
|
||||
try {
|
||||
const imageBuffer = await fetchReceiptImage(receipt);
|
||||
|
||||
if (imageBuffer) {
|
||||
// Check if we need a new page
|
||||
if (doc.y > doc.page.height - 400) {
|
||||
doc.addPage();
|
||||
doc.y = 60;
|
||||
}
|
||||
|
||||
// Add image
|
||||
const maxWidth = 400;
|
||||
const maxHeight = 300;
|
||||
|
||||
doc.image(imageBuffer, {
|
||||
fit: [maxWidth, maxHeight],
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
doc.y += 20;
|
||||
}
|
||||
} catch (imageError) {
|
||||
console.error('[expenses/generate-pdf] Error adding receipt image:', imageError);
|
||||
doc.fontSize(10)
|
||||
.fillColor('#666666')
|
||||
.text('Receipt image could not be loaded', { align: 'center' });
|
||||
doc.fillColor('#000000');
|
||||
doc.y += 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.y += 20;
|
||||
} catch (error) {
|
||||
console.error('[expenses/generate-pdf] Error processing receipt for expense:', expense.Id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPageFormat(pageFormat: string): { format: any } {
|
||||
switch (pageFormat) {
|
||||
case 'Letter':
|
||||
return { format: 'letter' };
|
||||
case 'Legal':
|
||||
return { format: 'legal' };
|
||||
case 'A4':
|
||||
default:
|
||||
return { format: 'a4' };
|
||||
async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||
try {
|
||||
const client = getMinioClient();
|
||||
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||
|
||||
// Determine the file path
|
||||
let filePath = receipt.url;
|
||||
if (!filePath && receipt.directus_files_id?.filename_download) {
|
||||
filePath = receipt.directus_files_id.filename_download;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
console.log('[expenses/generate-pdf] No file path found for receipt');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[expenses/generate-pdf] Fetching receipt image:', filePath);
|
||||
|
||||
// Get the object from MinIO
|
||||
const dataStream = await client.getObject(bucketName, filePath);
|
||||
|
||||
// Convert stream to buffer
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
dataStream.on('data', (chunk) => chunks.push(chunk));
|
||||
dataStream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
dataStream.on('error', reject);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[expenses/generate-pdf] Error fetching receipt image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addFooter(doc: PDFKit.PDFDocument) {
|
||||
doc.fontSize(10)
|
||||
.fillColor('#666666')
|
||||
.text(`Generated on: ${new Date().toLocaleString()}`,
|
||||
60, doc.page.height - 40, { align: 'right' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user