# easy-invoice/2.3.3/assets/js/document-pdf.js

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.3.3. 553 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.3.3/code/assets/js/document-pdf.js
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.3.3/raw/assets/js/document-pdf.js
- Modified: 2026-05-21T08:29:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.3.3/code/assets/js/document-pdf.js#L10-L20`.

```javascript
/**
 * 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<HTMLCanvasElement>}
         */
        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 = `
                <div style="margin-bottom: 10px;">
                    <svg width="40" height="40" viewBox="0 0 40 40">
                        <circle cx="20" cy="20" r="18" stroke="#e3e3e3" stroke-width="4" fill="none"/>
                        <circle cx="20" cy="20" r="18" stroke="#007cba" stroke-width="4" fill="none" 
                                stroke-dasharray="113" stroke-dashoffset="113" 
                                style="animation: spin 1s linear infinite; transform-origin: 50% 50%;">
                            <animate attributeName="stroke-dashoffset" values="113;0" dur="1s" repeatCount="indefinite"/>
                        </circle>
                    </svg>
                </div>
                <div style="color: #333; font-weight: 500;">Generating PDF...</div>
            `;
            
            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);
    }

})();

```
