feat: Add local logo image and improve PDF generation layout
This commit is contained in:
parent
1b2ee461a6
commit
20977e00bd
343
pdfGenerator.js
343
pdfGenerator.js
@ -1,14 +1,51 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
/**
|
||||
* Generates a PDF invoice based on Shopify order data.
|
||||
* Helper to draw a mock barcode in the top right corner
|
||||
*/
|
||||
function drawMockBarcode(doc, x, y, width, height) {
|
||||
doc.save();
|
||||
let currentX = x;
|
||||
const endX = x + width;
|
||||
|
||||
// Draw a series of random thin/thick black lines to look like a barcode
|
||||
while (currentX < endX) {
|
||||
const lineWidth = Math.random() * 2 + 0.8; // 0.8 to 2.8 pixels wide
|
||||
const spacing = Math.random() * 3 + 1.2; // 1.2 to 4.2 pixels spacing
|
||||
|
||||
if (currentX + lineWidth > endX) break;
|
||||
|
||||
doc.rect(currentX, y, lineWidth, height).fill('#000000');
|
||||
currentX += lineWidth + spacing;
|
||||
}
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch image buffer from URL safely
|
||||
*/
|
||||
async function fetchImageBuffer(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch image from ${url}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PDF invoice matching the user's custom layout.
|
||||
* @param {Object} orderData - The Shopify order payload
|
||||
* @returns {Promise<Buffer>} - A promise that resolves to a Buffer containing the PDF data
|
||||
*/
|
||||
const generateInvoicePDF = (orderData) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const generateInvoicePDF = async (orderData) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const doc = new PDFDocument({ margin: 40, size: 'A4' });
|
||||
let buffers = [];
|
||||
|
||||
doc.on('data', buffers.push.bind(buffers));
|
||||
@ -17,68 +54,268 @@ const generateInvoicePDF = (orderData) => {
|
||||
resolve(pdfData);
|
||||
});
|
||||
|
||||
// Invoice Header
|
||||
doc.fontSize(20).text('Invoice', { align: 'right' });
|
||||
doc.moveDown();
|
||||
// Colors
|
||||
const primaryColor = '#1e293b'; // Slate dark gray
|
||||
const textColor = '#334155';
|
||||
const lightGray = '#cbd5e1';
|
||||
const rowAltColor = '#f8fafc';
|
||||
|
||||
doc.fontSize(12)
|
||||
.text(`Order Number: ${orderData.name || orderData.order_number}`)
|
||||
.text(`Date: ${new Date(orderData.created_at).toLocaleDateString()}`)
|
||||
.moveDown();
|
||||
// --- 1. TITLE & BARCODE ---
|
||||
// Title (Left)
|
||||
const isQuote = orderData.gateway === 'Request Quote' || orderData.payment_gateway_names?.includes('Request Quote');
|
||||
const titleText = isQuote ? 'Request Quote' : 'Invoice';
|
||||
|
||||
// Store Info (You can customize this)
|
||||
const shopName = process.env.SHOP_NAME || 'Your Store Name';
|
||||
doc.fontSize(14).text(shopName);
|
||||
doc.moveDown();
|
||||
doc.fontSize(28)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor)
|
||||
.text(titleText, 40, 40);
|
||||
|
||||
// Customer Info
|
||||
doc.fontSize(14).text('Billed To:');
|
||||
doc.fontSize(12).text(`${orderData.billing_address?.first_name || ''} ${orderData.billing_address?.last_name || ''}`);
|
||||
doc.text(`${orderData.billing_address?.address1 || ''}`);
|
||||
if (orderData.billing_address?.address2) {
|
||||
doc.text(`${orderData.billing_address.address2}`);
|
||||
}
|
||||
doc.text(`${orderData.billing_address?.city || ''}, ${orderData.billing_address?.province || ''} ${orderData.billing_address?.zip || ''}`);
|
||||
doc.text(`${orderData.billing_address?.country || ''}`);
|
||||
// Barcode (Right)
|
||||
drawMockBarcode(doc, 380, 40, 175, 40);
|
||||
doc.moveDown(2);
|
||||
|
||||
// Line Items Table Header
|
||||
doc.fontSize(12).font('Helvetica-Bold');
|
||||
doc.text('Item', 50, doc.y, { continued: true });
|
||||
doc.text('Qty', 300, doc.y, { continued: true, width: 50, align: 'right' });
|
||||
doc.text('Price', 350, doc.y, { continued: true, width: 100, align: 'right' });
|
||||
doc.text('Total', 450, doc.y, { width: 100, align: 'right' });
|
||||
// --- 2. LOGO & FROM DETAILS ---
|
||||
const fromY = 110;
|
||||
|
||||
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
// Logo (Left)
|
||||
let logoLoaded = false;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const localLogoPath = path.join(__dirname, 'logo.png');
|
||||
|
||||
// Line Items Table Rows
|
||||
doc.font('Helvetica');
|
||||
let currentY = doc.y;
|
||||
if (fs.existsSync(localLogoPath)) {
|
||||
try {
|
||||
doc.image(localLogoPath, 40, fromY, { width: 130 });
|
||||
logoLoaded = true;
|
||||
} catch (e) {
|
||||
console.error('Error rendering local logo image:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoLoaded) {
|
||||
const logoUrl = process.env.SHOP_LOGO_URL;
|
||||
const logoBuffer = await fetchImageBuffer(logoUrl);
|
||||
if (logoBuffer) {
|
||||
try {
|
||||
doc.image(logoBuffer, 40, fromY, { width: 130 });
|
||||
logoLoaded = true;
|
||||
} catch (e) {
|
||||
console.error('Error rendering logo image from URL:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoLoaded) {
|
||||
// Stylish text fallback if image is missing
|
||||
doc.fontSize(20)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor('#db2777') // Pink accent
|
||||
.text('RAY AARI SHOP', 40, fromY);
|
||||
}
|
||||
|
||||
// From Address (Right)
|
||||
doc.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor)
|
||||
.text('From', 380, fromY)
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor)
|
||||
.text('Ray Aari Shop', 380, fromY + 15)
|
||||
.text('Sivanatham Salai', 380, fromY + 28)
|
||||
.text('Arappalayam Cross Rd, Near : Madura Coats pvt', 380, fromY + 41, { width: 175 })
|
||||
.text('Madurai 625016', 380, fromY + 67)
|
||||
.text('Tamil Nadu, India', 380, fromY + 80)
|
||||
.text('9994333548', 380, fromY + 93);
|
||||
|
||||
// --- 3. BILL TO & ORDER DETAILS ---
|
||||
const detailsY = 240;
|
||||
|
||||
// Bill To (Left)
|
||||
const billing = orderData.billing_address || {};
|
||||
doc.fontSize(11)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor)
|
||||
.text('Bill to', 40, detailsY);
|
||||
|
||||
doc.fontSize(10)
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor);
|
||||
|
||||
let billTextY = detailsY + 18;
|
||||
if (billing.first_name || billing.last_name) {
|
||||
doc.text(`${billing.first_name || ''} ${billing.last_name || ''}`, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (billing.address1) {
|
||||
doc.text(billing.address1, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (billing.address2) {
|
||||
doc.text(billing.address2, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (billing.city || billing.province || billing.zip) {
|
||||
doc.text(`${billing.city || ''} ${billing.province || ''} ${billing.zip || ''}`, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (billing.country) {
|
||||
doc.text(billing.country, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (orderData.email || orderData.contact_email) {
|
||||
doc.text(orderData.email || orderData.contact_email, 40, billTextY);
|
||||
billTextY += 13;
|
||||
}
|
||||
if (billing.phone || orderData.phone) {
|
||||
doc.text(billing.phone || orderData.phone, 40, billTextY);
|
||||
}
|
||||
|
||||
// Order Details (Right)
|
||||
const orderDateStr = new Date(orderData.created_at).toLocaleDateString('en-GB'); // DD-MM-YYYY
|
||||
const gatewayName = orderData.gateway || 'Request Quote';
|
||||
|
||||
doc.fontSize(14)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor)
|
||||
.text(`Order no: ${orderData.order_number}`, 380, detailsY)
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Order date: ', 380, detailsY + 22, { continued: true })
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor)
|
||||
.text(orderDateStr)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor)
|
||||
.text('Payment method: ', 380, detailsY + 37, { continued: true })
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor)
|
||||
.text(gatewayName);
|
||||
|
||||
// --- 4. PRODUCT TABLE ---
|
||||
let tableY = 380;
|
||||
|
||||
// Table Headers
|
||||
doc.fontSize(9)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor);
|
||||
|
||||
doc.text('S.No', 40, tableY, { width: 35 });
|
||||
doc.text('Image', 85, tableY, { width: 60 });
|
||||
doc.text('Product', 155, tableY, { width: 200 });
|
||||
doc.text('Quantity', 365, tableY, { width: 50, align: 'right' });
|
||||
doc.text('Unit price', 425, tableY, { width: 60, align: 'right' });
|
||||
doc.text('Total price', 495, tableY, { width: 60, align: 'right' });
|
||||
|
||||
// Line below header
|
||||
doc.moveTo(40, tableY + 12)
|
||||
.lineTo(555, tableY + 12)
|
||||
.lineWidth(0.8)
|
||||
.strokeColor(lightGray)
|
||||
.stroke();
|
||||
|
||||
tableY += 20;
|
||||
|
||||
// Currency formatting symbol
|
||||
const currencySymbol = orderData.currency === 'INR' ? 'Rs. ' : `${orderData.currency} `;
|
||||
|
||||
// Table Rows
|
||||
if (orderData.line_items && orderData.line_items.length > 0) {
|
||||
orderData.line_items.forEach(item => {
|
||||
doc.text(item.name, 50, currentY, { width: 250 });
|
||||
doc.text(item.quantity.toString(), 300, currentY, { width: 50, align: 'right' });
|
||||
doc.text(`${orderData.currency} ${parseFloat(item.price).toFixed(2)}`, 350, currentY, { width: 100, align: 'right' });
|
||||
const total = parseFloat(item.price) * item.quantity;
|
||||
doc.text(`${orderData.currency} ${total.toFixed(2)}`, 450, currentY, { width: 100, align: 'right' });
|
||||
for (let i = 0; i < orderData.line_items.length; i++) {
|
||||
const item = orderData.line_items[i];
|
||||
|
||||
doc.moveDown(0.5);
|
||||
currentY = doc.y;
|
||||
});
|
||||
// Fetch product image buffer
|
||||
let imageBuffer = null;
|
||||
if (item.image) {
|
||||
imageBuffer = await fetchImageBuffer(item.image);
|
||||
} else if (item.featured_image && item.featured_image.url) {
|
||||
imageBuffer = await fetchImageBuffer(item.featured_image.url);
|
||||
}
|
||||
|
||||
// Background color for alternating rows
|
||||
if (i % 2 === 1) {
|
||||
doc.rect(40, tableY - 4, 515, 38).fill(rowAltColor);
|
||||
}
|
||||
|
||||
doc.fontSize(9)
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor);
|
||||
|
||||
// S.No
|
||||
doc.text((i + 1).toString(), 40, tableY);
|
||||
|
||||
// Image
|
||||
if (imageBuffer) {
|
||||
try {
|
||||
doc.image(imageBuffer, 85, tableY - 2, { fit: [35, 30] });
|
||||
} catch (err) {
|
||||
console.error('Failed to draw item image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Product Name and Variant/Quantity details
|
||||
const productText = item.title || item.name;
|
||||
doc.text(productText, 155, tableY, { width: 200, height: 15, ellipsis: true });
|
||||
|
||||
// Display quantity/variant info if available
|
||||
if (item.variant_title) {
|
||||
doc.fontSize(8)
|
||||
.fillColor('#64748b')
|
||||
.text(item.variant_title, 155, tableY + 12, { width: 200 });
|
||||
}
|
||||
|
||||
// Reset font size
|
||||
doc.fontSize(9).fillColor(textColor);
|
||||
|
||||
// Quantity
|
||||
doc.text(item.quantity.toString(), 365, tableY, { width: 50, align: 'right' });
|
||||
|
||||
// Unit Price
|
||||
const unitPrice = parseFloat(item.price).toFixed(2);
|
||||
doc.text(`${currencySymbol}${unitPrice}`, 425, tableY, { width: 60, align: 'right' });
|
||||
|
||||
// Total Price
|
||||
const totalPrice = (parseFloat(item.price) * item.quantity).toFixed(2);
|
||||
doc.text(`${currencySymbol}${totalPrice}`, 495, tableY, { width: 60, align: 'right' });
|
||||
|
||||
// Divider Line
|
||||
doc.moveTo(40, tableY + 28)
|
||||
.lineTo(555, tableY + 28)
|
||||
.lineWidth(0.5)
|
||||
.strokeColor('#f1f5f9')
|
||||
.stroke();
|
||||
|
||||
tableY += 34;
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveTo(50, currentY).lineTo(550, currentY).stroke();
|
||||
doc.moveDown(1);
|
||||
// --- 5. TOTALS SECTION ---
|
||||
tableY += 10;
|
||||
|
||||
// Totals
|
||||
doc.text(`Subtotal: ${orderData.currency} ${orderData.subtotal_price}`, { align: 'right' });
|
||||
if (orderData.total_tax > 0) {
|
||||
doc.text(`Tax: ${orderData.currency} ${orderData.total_tax}`, { align: 'right' });
|
||||
}
|
||||
doc.text(`Shipping: ${orderData.currency} ${orderData.total_shipping_price_set?.shop_money?.amount || '0.00'}`, { align: 'right' });
|
||||
doc.font('Helvetica-Bold').text(`Total: ${orderData.currency} ${orderData.total_price}`, { align: 'right' });
|
||||
doc.fontSize(10)
|
||||
.font('Helvetica')
|
||||
.fillColor(textColor);
|
||||
|
||||
// Subtotal
|
||||
doc.text('Subtotal', 365, tableY, { width: 100, align: 'right' });
|
||||
doc.font('Helvetica-Bold')
|
||||
.text(`${currencySymbol}${parseFloat(orderData.subtotal_price).toFixed(2)}`, 480, tableY, { width: 75, align: 'right' });
|
||||
|
||||
// Divider
|
||||
doc.moveTo(365, tableY + 15)
|
||||
.lineTo(555, tableY + 15)
|
||||
.lineWidth(0.5)
|
||||
.strokeColor(lightGray)
|
||||
.stroke();
|
||||
|
||||
tableY += 22;
|
||||
|
||||
// Total
|
||||
doc.fontSize(11)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor(primaryColor);
|
||||
|
||||
doc.text('Total', 365, tableY, { width: 100, align: 'right' });
|
||||
doc.text(`${currencySymbol}${parseFloat(orderData.total_price).toFixed(2)}`, 480, tableY, { width: 75, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user