@pagamio/frontend-commons-lib
Version:
Pagamio library for Frontend reusable components like the form engine and table container
783 lines (782 loc) • 31.4 kB
JavaScript
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
// Helper function to convert RGB string to RGB values
const parseRgbString = (rgbString) => {
// Handle rgb(r, g, b) format
const rgbRegex = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/;
const rgbMatch = rgbRegex.exec(rgbString);
if (rgbMatch) {
return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
}
// Handle hex format
if (rgbString.startsWith('#')) {
const hex = rgbString.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return [r, g, b];
}
// Default fallback
return [33, 37, 41]; // Dark gray
};
// Helper function to load image and convert to base64
const loadImageAsBase64 = async (url) => {
try {
if (url.startsWith('data:')) {
return url;
}
const response = await fetch(url);
if (!response.ok)
return null;
const blob = await response.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
}
catch {
return null;
}
};
// Helper functions for formatting different data types
const formatStatusObject = (value) => {
if ('label' in value && typeof value.label === 'string') {
return value.label;
}
return String(value);
};
const formatDateValue = (value) => {
if (value instanceof Date) {
return value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// Handle ISO date strings - more comprehensive regex
if (typeof value === 'string') {
// Match various ISO date formats: YYYY-MM-DDTHH:mm:ss.sssZ, YYYY-MM-DDTHH:mm:ss.sss, etc.
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?$/;
if (isoDateRegex.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
// Also handle simple date formats like YYYY-MM-DD
const simpleDateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (simpleDateRegex.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
}
}
return String(value);
};
const formatImageString = (value) => {
const imageExtensions = ['.jpg', '.png', '.gif', '.jpeg', '.webp'];
const hasImageExtension = imageExtensions.some((ext) => value.toLowerCase().includes(ext));
if (hasImageExtension) {
try {
const parts = value.split('/');
const filename = parts[parts.length - 1];
if (filename?.includes('.')) {
return `[Image: ${filename}]`;
}
}
catch {
// Fallback to generic image label
}
return '[Image]';
}
return value;
};
const formatUrlString = (value) => {
if (value.startsWith('http')) {
try {
const url = new URL(value);
return url.pathname.split('/').pop() || url.hostname;
}
catch {
return value;
}
}
return value;
};
const formatNumberValue = (value, columnKey) => {
if (columnKey && (columnKey.toLowerCase().includes('amount') || columnKey.toLowerCase().includes('price'))) {
// Format as currency with proper symbol
return new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
return value.toString();
};
const formatObjectValue = (value) => {
// Handle arrays
if (Array.isArray(value)) {
return value.map((item) => formatCellContent(item)).join(', ');
}
// Handle Date objects and date strings
const dateResult = formatDateValue(value);
if (dateResult !== String(value)) {
return dateResult;
}
// Handle status objects
const statusResult = formatStatusObject(value);
if (statusResult !== String(value)) {
return statusResult;
}
// Handle other objects
if (value.toString && value.toString() !== '[object Object]') {
return value.toString();
}
return JSON.stringify(value);
};
const formatStringValue = (value, columnKey) => {
const cleanText = value.replace(/<[^<>]*>/g, '');
// Check if this looks like a date and the column suggests it's a date
if (columnKey && (columnKey.toLowerCase().includes('date') || columnKey.toLowerCase().includes('time'))) {
const formattedDate = formatDateValue(cleanText);
if (formattedDate !== cleanText) {
return formattedDate;
}
}
// Handle images
const imageResult = formatImageString(cleanText);
if (imageResult !== cleanText) {
return imageResult;
}
// Handle URLs
return formatUrlString(cleanText);
};
// Helper function to detect and handle different cell content types
const formatCellContent = (value, columnKey) => {
if (value === null || value === undefined)
return '';
if (typeof value === 'object' && value !== null) {
return formatObjectValue(value);
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'number') {
return formatNumberValue(value, columnKey);
}
if (typeof value === 'string') {
return formatStringValue(value, columnKey);
}
return String(value);
};
// Helper function to format cell content for Excel
const formatCellContentForExcel = (value, columnKey) => {
if (value === null || value === undefined)
return '';
if (typeof value === 'object' && value !== null) {
return formatObjectValue(value);
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'number') {
// Return raw number for Excel to preserve numeric type
return value;
}
if (typeof value === 'string') {
// Check if string represents a number
if (columnKey &&
(columnKey.toLowerCase().includes('amount') ||
columnKey.toLowerCase().includes('price') ||
columnKey.toLowerCase().includes('quantity') ||
columnKey.toLowerCase().includes('qty') ||
columnKey.toLowerCase().includes('count') ||
columnKey.toLowerCase().includes('total') ||
columnKey.toLowerCase().includes('sum'))) {
const numValue = parseFloat(value.replace(/[^\d.-]/g, ''));
if (!isNaN(numValue)) {
return numValue;
}
}
return formatStringValue(value, columnKey);
}
return String(value);
};
// CSV Export
export const exportToCsv = (data, columns, options = {}) => {
const { title = 'Data Export', includeTimestamp = true, includeHeaders = true, delimiter = ',', filename } = options;
const columnKeys = columns.map((col) => col.accessorKey);
const headers = columns.map((col) => formatHeaderText(col.header || ''));
// Prepare data with proper formatting
const formattedData = data.map((row) => columnKeys.reduce((acc, key) => {
const header = columns.find((col) => col.accessorKey === key)?.header || '';
acc[header] = formatCellContent(row[key], key);
return acc;
}, {}));
// Build CSV content
const csvRows = [];
// Add title as comment if provided
if (title) {
csvRows.push(`# ${title}`);
}
// Add timestamp as comment if enabled
if (includeTimestamp) {
const timestamp = `Generated on ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}`;
csvRows.push(`# ${timestamp}`);
}
// Add empty comment line if we have title/timestamp
if (title || includeTimestamp) {
csvRows.push('#');
}
// Configure Papa Parse options for high-quality output
const papaConfig = {
delimiter,
header: includeHeaders,
skipEmptyLines: 'greedy',
quotes: true, // Always quote fields to handle special characters
quoteChar: '"',
escapeChar: '"',
newline: '\n',
};
// Generate CSV content using Papa Parse
let csvContent;
if (includeHeaders) {
// Use Papa Parse to handle proper CSV formatting
csvContent = Papa.unparse(formattedData, papaConfig);
}
else {
// Generate data-only CSV
const dataRows = formattedData.map((row) => headers.map((header) => row[header] || ''));
csvContent = Papa.unparse(dataRows, papaConfig);
}
// Combine metadata comments with CSV content
const finalContent = csvRows.length > 0 ? csvRows.join('\n') + '\n' + csvContent : csvContent;
// Create and download file
const blob = new Blob([finalContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
// Generate filename with timestamp if not provided
if (filename) {
link.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
}
else {
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/[:\-T]/g, '');
link.download = `${title.replace(/\s+/g, '_').toLowerCase()}_${timestamp}.csv`;
}
link.click();
// Clean up
URL.revokeObjectURL(link.href);
};
// Helper function to calculate optimal column widths
const calculateColumnWidths = (headers, formattedData) => {
return headers.map((header) => {
let maxWidth = header.length;
// Check data rows for maximum width
for (const row of formattedData) {
const cellValue = String(row[header] || '');
maxWidth = Math.max(maxWidth, cellValue.length);
}
// Cap the width at reasonable limits
const width = Math.min(Math.max(maxWidth, 10), 50);
return { wch: width };
});
};
// Helper function to create worksheet data structure
const createWorksheetData = (title, subtitle, includeTimestamp, headers, formattedData) => {
const worksheetData = [];
let currentRow = 0;
// Add title if provided
if (title) {
worksheetData.push([title]);
currentRow++;
}
// Add subtitle or timestamp
if (subtitle) {
worksheetData.push([subtitle]);
currentRow++;
}
else if (includeTimestamp) {
const timestamp = `Generated on ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}`;
worksheetData.push([timestamp]);
currentRow++;
}
// Add empty row if we have title/subtitle
if (currentRow > 0) {
worksheetData.push([]);
currentRow++;
}
// Add headers
worksheetData.push(headers);
const headerRowIndex = currentRow;
currentRow++;
// Add data rows
for (const row of formattedData) {
const dataRow = headers.map((header) => row[header] || '');
worksheetData.push(dataRow);
}
return { data: worksheetData, headerRowIndex };
};
// Helper function to get base cell style
const getBaseCellStyle = () => ({
font: { name: 'Calibri', sz: 11 },
alignment: { vertical: 'center' },
border: {
top: { style: 'thin', color: { rgb: 'D1D5DB' } },
bottom: { style: 'thin', color: { rgb: 'D1D5DB' } },
left: { style: 'thin', color: { rgb: 'D1D5DB' } },
right: { style: 'thin', color: { rgb: 'D1D5DB' } },
},
});
// Helper function to style title row
const styleTitleRow = (worksheet, colors) => {
if (worksheet['A1']) {
worksheet['A1'].s = {
...getBaseCellStyle(),
font: { name: 'Calibri', sz: 16, bold: true },
alignment: { horizontal: 'center', vertical: 'center' },
fill: colors?.primary
? {
fgColor: { rgb: colors.primary[500].replace('#', '') },
}
: { fgColor: { rgb: 'F3F4F6' } },
border: {},
};
}
};
// Helper function to style subtitle row
const styleSubtitleRow = (worksheet) => {
if (worksheet['A2']) {
worksheet['A2'].s = {
...getBaseCellStyle(),
font: { name: 'Calibri', sz: 10, italic: true },
alignment: { horizontal: 'center', vertical: 'center' },
fill: { fgColor: { rgb: 'F9FAFB' } },
border: {},
};
}
};
// Helper function to style header row
const styleHeaderRow = (worksheet, headerRowIndex, headers, colors) => {
for (let col = 0; col < headers.length; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col });
if (worksheet[cellAddress]) {
worksheet[cellAddress].s = {
...getBaseCellStyle(),
font: { name: 'Calibri', sz: 11, bold: true, color: { rgb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'center' },
fill: colors?.primary
? {
fgColor: { rgb: colors.primary[600].replace('#', '') },
}
: { fgColor: { rgb: '6B7280' } },
};
}
}
};
// Helper function to style data rows
const styleDataRows = (worksheet, headerRowIndex, headers) => {
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
for (let row = headerRowIndex + 1; row <= range.e.r; row++) {
for (let col = 0; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
if (worksheet[cellAddress]) {
// Alternate row colors for better readability
const isEvenRow = (row - headerRowIndex - 1) % 2 === 0;
worksheet[cellAddress].s = {
...getBaseCellStyle(),
fill: { fgColor: { rgb: isEvenRow ? 'FFFFFF' : 'F9FAFB' } },
};
// Apply special formatting for different data types
const cellValue = worksheet[cellAddress].v;
if (typeof cellValue === 'number' &&
headers[col] &&
(headers[col].toLowerCase().includes('amount') || headers[col].toLowerCase().includes('price'))) {
worksheet[cellAddress].s.numFmt = '#,##0.00';
}
}
}
}
};
// Helper function to apply cell styling
const applyCellStyling = (worksheet, headerRowIndex, headers, colors, title, hasSubtitle) => {
// Style title row
if (title) {
styleTitleRow(worksheet, colors);
}
// Style subtitle/timestamp row
if (hasSubtitle) {
styleSubtitleRow(worksheet);
}
// Style header row
styleHeaderRow(worksheet, headerRowIndex, headers, colors);
// Style data rows
styleDataRows(worksheet, headerRowIndex, headers);
};
// Helper function to add merged cells
const addMergedCells = (worksheet, title, hasSubtitle, headers) => {
worksheet['!merges'] ??= [];
// Merge title cell across all columns if title exists
if (title && headers.length > 1) {
const titleMerge = {
s: { r: 0, c: 0 },
e: { r: 0, c: headers.length - 1 },
};
worksheet['!merges'].push(titleMerge);
}
// Merge subtitle cell across all columns if subtitle exists
if (hasSubtitle && headers.length > 1) {
const subtitleMerge = {
s: { r: 1, c: 0 },
e: { r: 1, c: headers.length - 1 },
};
worksheet['!merges'].push(subtitleMerge);
}
};
// XLSX Export
export const exportToXlsx = (data, columns, options = {}) => {
const { title = 'Data Export', subtitle, colors, sheetName = 'Data', includeTimestamp = true, autoFitColumns = true, } = options;
const columnKeys = columns.map((col) => col.accessorKey);
const headers = columns.map((col) => formatHeaderText(col.header || ''));
// Prepare data with proper formatting
const formattedData = data.map((row) => columnKeys.reduce((acc, key) => {
const header = columns.find((col) => col.accessorKey === key)?.header || '';
acc[header] = formatCellContentForExcel(row[key], key);
return acc;
}, {}));
// Create workbook
const workbook = XLSX.utils.book_new();
// Create worksheet data structure
const { data: worksheetData, headerRowIndex } = createWorksheetData(title, subtitle, includeTimestamp, headers, formattedData);
// Create worksheet from array
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
// Set column widths if autoFitColumns is enabled
if (autoFitColumns) {
worksheet['!cols'] = calculateColumnWidths(headers, formattedData);
}
// Apply styling and formatting
const hasSubtitle = Boolean(subtitle || includeTimestamp);
applyCellStyling(worksheet, headerRowIndex, headers, colors, title, hasSubtitle);
// Add merged cells
addMergedCells(worksheet, title, hasSubtitle, headers);
// Add worksheet to workbook
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// Generate filename with timestamp
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/[:\-T]/g, '');
const filename = `${title.replace(/\s+/g, '_').toLowerCase()}_${timestamp}.xlsx`;
// Write file
XLSX.writeFile(workbook, filename);
};
// Helper function to check if a column likely contains images
const isImageColumn = (columnKey, data) => {
const imageKeywords = ['image', 'photo', 'picture', 'avatar', 'logo'];
const keyLower = columnKey.toLowerCase();
// Check if column key suggests images
if (imageKeywords.some((keyword) => keyLower.includes(keyword))) {
return true;
}
// Check if sample data looks like image URLs
const sampleValues = data
.slice(0, 5)
.map((row) => row[columnKey])
.filter(Boolean);
if (sampleValues.length > 0) {
const imageExtensions = ['.jpg', '.png', '.gif', '.jpeg', '.webp'];
const imageUrls = sampleValues.filter((value) => typeof value === 'string' && imageExtensions.some((ext) => value.toLowerCase().includes(ext)));
return imageUrls.length > sampleValues.length * 0.5; // More than 50% are image URLs
}
return false;
};
// Helper function to format headers with proper word wrapping
const formatHeaderText = (header) => {
// Don't modify single words or headers that are already properly formatted
if (!header.includes(' ') && header.length <= 12) {
return header; // Keep single words as-is to prevent unnecessary wrapping
}
// Only format camelCase to space-separated if it's a compound word
// Use ReDoS-safe approach by avoiding greedy quantifiers in nested groups
let result = header;
// First handle camelCase: lowercase followed by uppercase
result = result.replace(/([a-z])([A-Z])/g, '$1 $2');
result = result.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2');
return result.trim();
};
// Enhanced table data preparation with image handling
const prepareTableDataForPdf = async (data, columns, doc) => {
const columnKeys = columns.map((col) => col.accessorKey);
// Identify image columns
const imageColumns = columnKeys.filter((key) => isImageColumn(key, data));
// Store image information for later embedding
const imageInfo = new Map();
const tableData = await Promise.all(data.map(async (row, rowIndex) => {
return Promise.all(columnKeys.map(async (key, colIndex) => {
const value = row[key];
// Handle image columns specially
if (imageColumns.includes(key) && typeof value === 'string') {
try {
const imageBase64 = await loadImageAsBase64(value);
if (imageBase64) {
// Store image info for embedding
const imageKey = `img_${rowIndex}_${colIndex}`;
// Calculate appropriate size for table cell (small thumbnail)
const cellImageSize = 8; // 8mm square for table cell
imageInfo.set(imageKey, {
base64: imageBase64,
width: cellImageSize,
height: cellImageSize,
});
return imageKey; // Return the key to identify where to place the image
}
}
catch (error) {
console.warn(`Failed to load image for column ${key}:`, error);
}
return '[Image Failed]';
}
return formatCellContent(value, key);
}));
}));
return { tableData, imageInfo };
};
// PDF Export
export const exportToPdf = async (data, columns, options = {}) => {
const { title = 'Data Export', subtitle = `Generated on ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}`, colors, logo, } = options;
// Create PDF document with better settings
const doc = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4',
compress: true,
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 10;
// Define colors with fallbacks
const primaryColor = colors?.core?.primary || colors?.primary?.['500'] || '#b45dae';
const [primaryR, primaryG, primaryB] = parseRgbString(primaryColor);
let currentY = margin;
// Add logo if provided
if (logo?.url) {
try {
const logoBase64 = await loadImageAsBase64(logo.url);
if (logoBase64) {
const logoWidth = logo.width || 30;
const logoHeight = logo.height || 15;
doc.addImage(logoBase64, 'PNG', margin, currentY, logoWidth, logoHeight);
currentY += logoHeight + 5;
}
}
catch (error) {
console.warn('Failed to load logo for PDF export:', error);
}
}
// Add title
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(primaryR, primaryG, primaryB);
doc.text(title, margin, currentY);
currentY += 8;
// Add subtitle
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(100, 100, 100);
doc.text(subtitle, margin, currentY);
currentY += 10;
// Add a decorative line
doc.setDrawColor(primaryR, primaryG, primaryB);
doc.setLineWidth(0.5);
doc.line(margin, currentY, pageWidth - margin, currentY);
currentY += 8;
// Prepare table data
const columnHeaders = columns.map((col) => formatHeaderText(col.header || String(col.accessorKey)));
// Format table data with enhanced handling for images, status, etc.
const { tableData, imageInfo } = await prepareTableDataForPdf(data, columns, doc);
console.log(`PDF export: Processing ${tableData.length} rows with ${imageInfo.size} images`);
// Calculate optimal column widths
const availableWidth = pageWidth - 2 * margin;
const numColumns = columnHeaders.length;
const baseColumnWidth = availableWidth / numColumns;
// Adjust column widths based on content
const columnWidths = columnHeaders.map((header, index) => {
// For single words, ensure minimum width to prevent wrapping
const isSimpleWord = !header.includes(' ') && header.length <= 12;
const minWordWidth = isSimpleWord ? header.length * 2.5 : 0; // Extra space for single words
// Calculate header length considering word boundaries
const headerWords = header.split(' ');
const longestWord = Math.max(...headerWords.map((word) => word.length));
const headerLength = Math.max(longestWord, header.length / 2); // Allow for wrapping
const maxContentLength = Math.max(...tableData.map((row) => String(row[index] || '').length));
const contentLength = Math.max(headerLength, maxContentLength, minWordWidth);
// Scale width based on content, but keep within reasonable bounds
const scaleFactor = Math.min(Math.max(contentLength / 12, 1.0), 2.5); // Increased minimum
return baseColumnWidth * scaleFactor;
});
// Normalize column widths to fit page
const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0);
const normalizedWidths = columnWidths.map((width) => (width / totalWidth) * availableWidth);
// Configure auto-table with professional styling
doc.autoTable({
head: [columnHeaders],
body: tableData,
startY: currentY,
margin: { left: margin, right: margin },
columnStyles: normalizedWidths.reduce((styles, width, index) => {
styles[index] = {
cellWidth: width,
overflow: 'linebreak',
fontSize: 8,
cellPadding: 2,
halign: 'left',
};
return styles;
}, {}),
headStyles: {
fillColor: [primaryR, primaryG, primaryB],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 9,
halign: 'center',
cellPadding: 3,
overflow: 'linebreak',
cellWidth: 'wrap',
},
bodyStyles: {
fontSize: 8,
cellPadding: 2,
textColor: [33, 37, 41],
},
alternateRowStyles: {
fillColor: [248, 249, 250],
},
styles: {
lineColor: [230, 230, 230],
lineWidth: 0.1,
overflow: 'linebreak',
cellWidth: 'wrap',
},
theme: 'grid',
tableLineColor: [200, 200, 200],
tableLineWidth: 0.1,
didDrawCell: function (data) {
// Check if this cell contains an image key
const cellText = data.cell.text.join('');
if (imageInfo.has(cellText) && cellText.startsWith('img_')) {
const imgData = imageInfo.get(cellText);
if (imgData) {
try {
// Completely clear the cell by overriding the drawing
const cellX = data.cell.x;
const cellY = data.cell.y;
const cellWidth = data.cell.width;
const cellHeight = data.cell.height;
// Fill the cell with white background to cover any text
doc.setFillColor(255, 255, 255);
doc.rect(cellX, cellY, cellWidth, cellHeight, 'F');
// Redraw cell border if needed
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(cellX, cellY, cellWidth, cellHeight, 'S');
// Calculate position to center the image in the cell
const imgX = cellX + (cellWidth - imgData.width) / 2;
const imgY = cellY + (cellHeight - imgData.height) / 2;
// Detect image format for better compatibility
let format = 'JPEG'; // Default
if (imgData.base64.includes('data:image/png')) {
format = 'PNG';
}
else if (imgData.base64.includes('data:image/gif')) {
format = 'GIF';
}
else if (imgData.base64.includes('data:image/webp')) {
format = 'WEBP';
}
// Add the image to the PDF
doc.addImage(imgData.base64, format, imgX, imgY, imgData.width, imgData.height);
}
catch (error) {
console.warn('Failed to embed image in PDF:', error);
// Clear the cell and show fallback text
const cellX = data.cell.x;
const cellY = data.cell.y;
const cellWidth = data.cell.width;
const cellHeight = data.cell.height;
// Fill with white background
doc.setFillColor(255, 255, 255);
doc.rect(cellX, cellY, cellWidth, cellHeight, 'F');
// Redraw border
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(cellX, cellY, cellWidth, cellHeight, 'S');
// Add fallback text
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.text('[Image]', cellX + 2, cellY + cellHeight / 2 + 1);
}
}
}
},
didDrawPage: function (data) {
// Add footer with page numbers and branding
const pageCount = doc.internal.pages.length - 1;
const currentPage = data.pageNumber;
// Footer line
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.line(margin, pageHeight - 15, pageWidth - margin, pageHeight - 15);
// Page number
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.text(`Page ${currentPage} of ${pageCount}`, pageWidth - margin - 20, pageHeight - 8);
// Export timestamp
doc.text(`Exported: ${new Date().toLocaleString()}`, margin, pageHeight - 8);
},
});
// Save the PDF with a meaningful filename
const timestamp = new Date().toISOString().split('T')[0];
const filename = `${title.toLowerCase().replace(/\s+/g, '_')}_${timestamp}.pdf`;
doc.save(filename);
};