UNPKG

@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
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); };