/** * Easy Invoice Document PDF Generation using html2canvas * Generates PDF from the actual rendered invoice/quote HTML * * @package Easy_Invoice * @version 1.0.0 */ (function() { 'use strict'; /** ISO 216 A4 (portrait) — explicit mm so PDF page size is always A4 in viewers. */ const A4_MM = { width: 210, height: 297 }; // Global Document PDF Generator Class window.DocumentPdfGenerator = class DocumentPdfGenerator { constructor(documentType = 'invoice') { this.documentType = documentType; // 'invoice' or 'quote' this.init(); } init() { // Add html2canvas script if not already loaded if (typeof html2canvas === 'undefined') { this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', () => { this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', () => { this.setupPDFButtons(); }); }); } else { this.setupPDFButtons(); } } loadScript(src, callback) { const script = document.createElement('script'); script.src = src; script.onload = callback; document.head.appendChild(script); } setupPDFButtons() { // Find all PDF download buttons const pdfButtons = document.querySelectorAll('.download-pdf-btn, .ei-download-pdf'); pdfButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); this.generatePDF(); }); }); } /** * Max canvas edge length (px) — browsers cap canvas size; long invoices exceed this in one shot. */ static get MAX_CANVAS_EDGE() { return 10000; } /** * Vertical slice height (CSS px) per html2canvas capture when content is very tall. */ static get SLICE_HEIGHT_CSS() { return 3200; } /** * @returns {{width:number,height:number}} A4 size in mm (portrait). */ static getA4SizeMm() { return { width: A4_MM.width, height: A4_MM.height }; } /** * Append an A4 portrait page (matches initial document format). * * @param {Object} pdf jsPDF instance */ static addA4Page(pdf) { pdf.addPage('a4', 'p'); } /** * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset. * * @param {HTMLElement} el * @returns {number} */ getContentPaddingTopPx(el) { const s = window.getComputedStyle(el); return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.borderTopWidth) || 0); } /** * Collect bottom Y positions (px from top of root) for table rows and summary blocks * so PDF page breaks avoid cutting through a line item row. * * @param {HTMLElement} root * @returns {number[]} Sorted unique pixel positions (content-relative, 0 .. scrollHeight) */ collectRowBottomsPx(root) { const rr = root.getBoundingClientRect(); const bottoms = []; const addEl = (el) => { if (!el || !el.getBoundingClientRect) { return; } const r = el.getBoundingClientRect(); const bottom = r.bottom - rr.top + root.scrollTop; if (bottom > 0) { bottoms.push(bottom); } }; root.querySelectorAll('table tbody > tr').forEach(addEl); root.querySelectorAll( '.invoice-summary, .quote-summary, [class*="invoice-summary"], [class*="quote-summary"]' ).forEach(addEl); const sorted = [...new Set(bottoms)].sort((a, b) => a - b); return sorted; } /** * Build non-overlapping vertical slices [start,end) in px that stay under maxChunkPx * and end on the nearest row bottom when possible (avoids slicing mid-row in html2canvas). * * @param {number} totalHeightPx * @param {number[]} rowBottomsPx * @param {number} maxChunkPx * @returns {Array<{start:number,end:number}>} */ buildRowAlignedSlices(totalHeightPx, rowBottomsPx, maxChunkPx) { const slices = []; let start = 0; while (start < totalHeightPx) { let end = Math.min(start + maxChunkPx, totalHeightPx); const backs = rowBottomsPx.filter((b) => b > start && b <= end); if (backs.length) { end = Math.max(...backs); } if (end <= start) { end = Math.min(start + maxChunkPx, totalHeightPx); } slices.push({ start, end }); start = end; } return slices; } /** * Row / block bottoms within a vertical slice, expressed in mm from the top of that slice's image. * * @param {number[]} rowBottomsPx * @param {number} sliceStartPx * @param {number} sliceEndPx * @param {number} sliceHeightPx * @param {number} imgHeightMm * @returns {number[]} */ breakPointsMmForSlice(rowBottomsPx, sliceStartPx, sliceEndPx, sliceHeightPx, imgHeightMm) { const pts = rowBottomsPx .filter((b) => b > sliceStartPx && b <= sliceEndPx) .map((b) => ((b - sliceStartPx) / sliceHeightPx) * imgHeightMm); if (imgHeightMm > 0) { pts.push(imgHeightMm); } return [...new Set(pts)].sort((a, b) => a - b); } /** * Compute html2canvas scale so width/height stay under browser canvas limits. * * @param {HTMLElement} el * @param {number} baseScale * @returns {number} */ computeSafeScale(el, baseScale) { const w = Math.max(1, el.scrollWidth); const h = Math.max(1, el.scrollHeight); const maxEdge = this.constructor.MAX_CANVAS_EDGE; const maxScale = Math.min(maxEdge / w, maxEdge / h, baseScale); return Math.max(0.2, Math.min(baseScale, maxScale)); } /** * Capture one vertical slice of an element (for very long content). * * @param {HTMLElement} element * @param {number} offsetY * @param {number} sliceHeight * @param {number} scale * @returns {Promise} */ captureSlice(element, offsetY, sliceHeight, scale) { // Lock the capture viewport to the element's own layout width so // responsive CSS doesn't kick in and shrink columns mid-capture. const lockedWidth = Math.max(1, element.scrollWidth); return html2canvas(element, { scale, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', logging: false, imageTimeout: 15000, windowWidth: lockedWidth, width: lockedWidth, onclone: (_doc, clone) => { const node = clone; node.style.overflow = 'hidden'; node.style.boxSizing = 'border-box'; node.style.marginTop = -offsetY + 'px'; node.style.height = sliceHeight + 'px'; } }); } /** * Append canvas to PDF with row-aware vertical crops. * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin. * * @param {jsPDF} pdf * @param {HTMLCanvasElement} canvas * @param {number} pageWidthMm * @param {number} pageHeightMm * @param {number[]} breakPointsMm * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation). * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture). * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height). * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px). * @param {number} scale html2canvas scale factor. */ addImagePagesRowAware( pdf, canvas, pageWidthMm, pageHeightMm, breakPointsMm, prependPage, fragmentTopPx, fragmentHeightPx, padTopCssPx, scale ) { const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width; const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])] .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001) .sort((a, b) => a - b); if (prependPage) { this.constructor.addA4Page(pdf); } let y0mm = 0; let firstCrop = true; while (y0mm < imgHeightMm - 0.0001) { const limit = y0mm + pageHeightMm; const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit); let y1mm; if (inRange.length) { y1mm = Math.max(...inRange); } else { const nextB = fullBreaks.find((b) => b > y0mm); if (nextB !== undefined && nextB - y0mm <= pageHeightMm) { y1mm = nextB; } else { y1mm = Math.min(limit, imgHeightMm); } } if (y1mm <= y0mm) { y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm); } const py0 = (y0mm / imgHeightMm) * canvas.height; const py1 = (y1mm / imgHeightMm) * canvas.height; const ch = Math.max(1, Math.round(py1 - py0)); const crop = document.createElement('canvas'); crop.width = canvas.width; crop.height = ch; const ctx = crop.getContext('2d'); ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch); /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */ const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx; const padTopCanvasPx = docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0; let outCanvas = crop; if (padTopCanvasPx > 0) { const padded = document.createElement('canvas'); padded.width = crop.width; padded.height = ch + padTopCanvasPx; const pctx = padded.getContext('2d'); pctx.fillStyle = '#ffffff'; pctx.fillRect(0, 0, padded.width, padded.height); pctx.drawImage(crop, 0, padTopCanvasPx); outCanvas = padded; } // PNG is lossless — invoices are mostly text + tables, where // JPEG produces fuzzy edges and colour-fringe around glyphs. // The 'SLOW' compression flag tells jsPDF to use the better // (slower) inflate algorithm so the embedded bitmap retains // its full resolution. const cropData = outCanvas.toDataURL('image/png'); const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width; if (!firstCrop) { this.constructor.addA4Page(pdf); } firstCrop = false; pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW'); y0mm = y1mm; } } async generatePDF() { try { // Show loading state this.showLoading(); // Find the document content const documentContent = document.querySelector('.invoice-content') || document.querySelector('.quote-content') || document.querySelector('.receipt-container'); if (!documentContent) { throw new Error('Document content not found'); } // Get document data for filename const documentTitle = document.querySelector('.invoice-title')?.textContent || document.querySelector('.quote-title')?.textContent || document.querySelector('.receipt-title')?.textContent || this.documentType; const documentNumber = document.querySelector('.invoice-number')?.textContent || document.querySelector('.quote-number')?.textContent || document.querySelector('.receipt-number')?.textContent || ''; const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`; const jsPDF = window.jsPDF || window.jspdf?.jsPDF; if (!jsPDF) { throw new Error('jsPDF library not available'); } const a4 = this.constructor.getA4SizeMm(); const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', // FlateDecode-compress the embedded bitmaps so a high-resolution // capture doesn't bloat the file to 20MB. compress: true, precision: 4 }); const pageWidthMm = a4.width; const pageHeightMm = a4.height; const imgWidthMm = pageWidthMm; // 2× the CSS pixel grid — matches retina display density, so text // edges stay crisp instead of soft. (Previous 1.5× produced the // "PDF quality is bad" customer report.) const baseScale = 2; const totalHeight = documentContent.scrollHeight; const totalWidth = documentContent.scrollWidth; const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE; const needsSlices = totalHeight * baseScale > maxSingleCanvasPx || totalWidth * baseScale > maxSingleCanvasPx; const rowBottomsPx = this.collectRowBottomsPx(documentContent); const padTopCssPx = this.getContentPaddingTopPx(documentContent); if (!needsSlices) { const scale = this.computeSafeScale(documentContent, baseScale); // Lock the capture viewport to the element's own layout width // so responsive media queries don't shrink the design mid-capture. const lockedWidth = Math.max(1, documentContent.scrollWidth); const canvas = await html2canvas(documentContent, { scale, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', logging: false, imageTimeout: 15000, windowWidth: lockedWidth, width: lockedWidth }); const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; const breakMm = this.breakPointsMmForSlice( rowBottomsPx, 0, totalHeight, totalHeight, imgHeightMm ); this.addImagePagesRowAware( pdf, canvas, imgWidthMm, pageHeightMm, breakMm, false, 0, totalHeight, padTopCssPx, scale ); } else { const sliceCss = this.constructor.SLICE_HEIGHT_CSS; let scale = this.computeSafeScale(documentContent, baseScale); scale = Math.min(scale, maxSingleCanvasPx / sliceCss); scale = Math.max(0.2, scale); const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss); for (let i = 0; i < slices.length; i++) { const { start, end } = slices[i]; const sliceH = end - start; const canvas = await this.captureSlice(documentContent, start, sliceH, scale); const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; const breakMm = this.breakPointsMmForSlice( rowBottomsPx, start, end, sliceH, imgHeightMm ); this.addImagePagesRowAware( pdf, canvas, imgWidthMm, pageHeightMm, breakMm, i > 0, start, sliceH, padTopCssPx, scale ); } } // Download the PDF pdf.save(filename); // Hide loading state this.hideLoading(); } catch (error) { console.error('PDF generation failed:', error); this.hideLoading(); // Show error message if (typeof EasyInvoiceToast !== 'undefined') { EasyInvoiceToast.show('error', 'Failed to generate PDF. Please try again.'); } else if (typeof showMessage !== 'undefined') { showMessage('Failed to generate PDF. Please try again.', 'error'); } else { alert('Failed to generate PDF. Please try again.'); } } } showLoading() { // Create loading overlay const overlay = document.createElement('div'); overlay.id = 'pdf-loading-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 9999; `; const spinner = document.createElement('div'); spinner.style.cssText = ` background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); text-align: center; `; spinner.innerHTML = `
Generating PDF...
`; overlay.appendChild(spinner); document.body.appendChild(overlay); // Add CSS animation const style = document.createElement('style'); style.textContent = ` @keyframes spin { 0% { stroke-dashoffset: 113; } 100% { stroke-dashoffset: 0; } } `; document.head.appendChild(style); } hideLoading() { const overlay = document.getElementById('pdf-loading-overlay'); if (overlay) { overlay.remove(); } } }; // Backward compatibility - keep InvoicePdfGenerator for existing code window.InvoicePdfGenerator = window.DocumentPdfGenerator; // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { // Auto-detect document type based on page content const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number'); const documentType = isQuote ? 'quote' : 'invoice'; new DocumentPdfGenerator(documentType); }); } else { // Auto-detect document type based on page content const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number'); const documentType = isQuote ? 'quote' : 'invoice'; new DocumentPdfGenerator(documentType); } })();