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

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.3. 713 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.3/code/assets/js/document-pdf.js
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.3/raw/assets/js/document-pdf.js
- Modified: 2026-09-29T06:14:16+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.4.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() {
            // html2canvas and jsPDF are bundled with the plugin and loaded ahead of
            // this file by whoever renders the page (PdfHelper::enqueuePdfScripts(),
            // AdminAssets, or an explicit <script> in the single/preview templates).
            //
            // This used to inject both libraries from cdnjs when they were missing.
            // That is no longer permitted — WordPress.org requires every asset to
            // ship inside the plugin — so a missing library is now a setup error we
            // report rather than something we silently fetch over the network.
            // Same resolution the render path uses below — the UMD build exposes
            // window.jspdf.jsPDF, older/standalone builds expose window.jsPDF.
            const hasJsPdf = !!(window.jsPDF || (window.jspdf && window.jspdf.jsPDF));

            if (typeof html2canvas === 'undefined' || !hasJsPdf) {
                console.error(
                    'Easy Invoice: PDF generation unavailable — html2canvas and/or jsPDF ' +
                    'were not loaded before document-pdf.js. Enqueue the "html2canvas" and ' +
                    '"jspdf" handles (or include assets/js/vendors/) on this page.'
                );
                return;
            }

            this.setupPDFButtons();
        }

        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');
            this.paintPageWhite(pdf);
        }

        /**
         * Paint the current page white. A PDF page has no background of its
         * own: where the capture does not cover the sheet — the foot of the
         * last page, or of a page that broke early at a row — the area is
         * unpainted, and a renderer is free to show that as black.
         *
         * @param {Object} pdf jsPDF instance
         */
        static paintPageWhite(pdf) {
            const { width, height } = pdf.internal.pageSize;
            const w = typeof width === 'function' ? width() : width;
            const h = typeof height === 'function' ? height() : height;
            pdf.setFillColor(255, 255, 255);
            pdf.rect(0, 0, w, h, 'F');
        }

        /**
         * 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, box) {
            // Lock the capture viewport to the element's own layout width so
            // responsive CSS doesn't kick in and shrink columns mid-capture.
            box = box || { width: Math.max(1, element.scrollWidth), height: element.scrollHeight, card: null };
            const lockedWidth = box.card ? window.innerWidth : Math.max(1, element.scrollWidth);
            return html2canvas(element, {
                scale,
                useCORS: true,
                allowTaint: true,
                backgroundColor: '#ffffff',
                logging: false,
                imageTimeout: 15000,
                windowWidth: lockedWidth,
                width: box.width,
                onclone: (_doc, clone) => {
                    this.frameClone(clone, box);
                    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
        ) {
            let imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
            // A sheet that is one page tall bar a rounding hair must not spill a
            // blank sliver onto a second page.
            if (imgHeightMm > pageHeightMm && imgHeightMm - pageHeightMm < 3) {
                imgHeightMm = pageHeightMm;
            }
            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;
            }
        }

        /**
         * The PDF reproduces the single page: the design's card, with the page
         * background around it as on screen. The live page is never touched;
         * html2canvas renders a clone, and the clone is where the content box
         * is narrowed to the card and given that margin.
         */
        static get FRAME_PX() {
            return 24;
        }

        /**
         * The design's card inside the content box, if there is one.
         *
         * @param {HTMLElement} content
         * @returns {HTMLElement|null}
         */
        findCard(content) {
            return content.querySelector(':scope > .template, :scope > [class*="template-"]')
                || content.querySelector('.template, [class*="template-"]');
        }

        /**
         * Width/height the capture will have: the card plus the frame.
         *
         * @param {HTMLElement} content
         * @returns {{width:number,height:number,card:HTMLElement|null}}
         */
        /** Width of an A4 sheet in CSS pixels (210 mm at 96 dpi). */
        static get A4_CSS_WIDTH() {
            return 794;
        }

        /**
         * Whether the card is the A4 sheet the document page lays out: then it
         * is captured edge to edge and the PDF is A4, one sheet per page.
         *
         * @param {HTMLElement|null} card
         * @returns {boolean}
         */
        isA4Sheet(card) {
            if (!card) { return false; }
            return Math.abs(card.getBoundingClientRect().width - this.constructor.A4_CSS_WIDTH) <= 2;
        }

        captureBox(content) {
            const card = this.findCard(content);
            if (!card) {
                return { width: content.scrollWidth, height: content.scrollHeight, card: null, pad: 0, a4: false };
            }
            const a4 = this.isA4Sheet(card);
            const pad = a4 ? 0 : this.constructor.FRAME_PX;
            const r = card.getBoundingClientRect();
            return { width: Math.round(r.width) + pad * 2, height: Math.round(r.height) + pad * 2, card, pad, a4 };
        }

        /**
         * Apply the frame inside the cloned document: content box as wide as
         * the card plus margin, page background behind it, card centred.
         *
         * @param {HTMLElement} cloneContent
         * @param {{width:number,card:HTMLElement|null}} box
         */
        frameClone(cloneContent, box) {
            if (!box.card) { return; }
            const pad = box.pad;
            const bodyBg = getComputedStyle(document.body).backgroundColor;
            cloneContent.style.display = 'block';
            cloneContent.style.boxSizing = 'border-box';
            cloneContent.style.width = box.width + 'px';
            cloneContent.style.maxWidth = box.width + 'px';
            cloneContent.style.padding = pad + 'px';
            cloneContent.style.background = pad > 0 && bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#ffffff';
            cloneContent.style.flex = 'none';
            const cloneCard = cloneContent.querySelector(':scope > .template, :scope > [class*="template-"]') || cloneContent.querySelector('.template, [class*="template-"]');
            if (cloneCard) {
                cloneCard.style.boxSizing = 'border-box';
                cloneCard.style.marginLeft = '0';
                cloneCard.style.marginRight = '0';
                cloneCard.style.width = Math.round(box.card.getBoundingClientRect().width) + 'px';
                cloneCard.style.maxWidth = 'none';
            }
            // Admin-only chrome that is not part of the document.
            cloneContent.querySelectorAll('#additional-css-btn, .ei-css-toggle').forEach((n) => n.remove());
        }

        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');
                }
                const box = this.captureBox(documentContent);

                // 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 ||
                                     (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
                                     '';
                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();
                // One page, sized to the document, when it is not much taller
                // than an A4 sheet: the PDF is then exactly the page as drawn,
                // with nothing split across a page break. Much longer documents
                // (many line items) fall back to A4 pages broken between rows.
                const contentHeightMm = (box.height * a4.width) / Math.max(1, box.width);
                // An A4 sheet is paged as A4; anything else that is not much
                // taller than a sheet becomes one page sized to fit.
                const singlePage = !box.a4 && contentHeightMm <= a4.height * 2;
                const pdf = new jsPDF({
                    orientation: 'p',
                    unit: 'mm',
                    format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
                    // FlateDecode-compress the embedded bitmaps so a high-resolution
                    // capture doesn't bloat the file to 20MB.
                    compress: true,
                    precision: 4
                });
                this.constructor.paintPageWhite(pdf);
                const pageWidthMm = a4.width;
                // A4 pages keep a strip clear at the foot for the page label.
                const FOOTER_MM = 9;
                // When the capture is the A4 sheet the design lays out, one
                // sheet is one page: reserving the label strip left the last
                // 9mm of every sheet — blank or not — spilling onto a page of
                // its own. The label for a genuinely multi-sheet document is
                // drawn inside the sheet's own bottom margin instead.
                const pageHeightMm = singlePage
                    ? Math.max(a4.height, contentHeightMm)
                    : (box.a4 ? a4.height : a4.height - FOOTER_MM);
                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 = box.height;
                const totalWidth = box.width;
                const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
                const needsSlices =
                    totalHeight * baseScale > maxSingleCanvasPx ||
                    totalWidth * baseScale > maxSingleCanvasPx;

                let rowBottomsPx = this.collectRowBottomsPx(documentContent);
                let padTopCssPx = this.getContentPaddingTopPx(documentContent);
                if (box.card) {
                    // Rows were measured on the live page; in the framed clone the
                    // card starts FRAME_PX below the top of the capture.
                    const shift = box.pad - (box.card.getBoundingClientRect().top - documentContent.getBoundingClientRect().top);
                    rowBottomsPx = rowBottomsPx.map((b) => b + shift);
                    padTopCssPx = box.pad;
                }

                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.
                    // The clone must lay out exactly as the live page does — the
                    // crop origin html2canvas uses is the live element's position —
                    // so the viewport stays the real one when the card is framed.
                    const lockedWidth = box.card ? window.innerWidth : Math.max(1, documentContent.scrollWidth);
                    const canvas = await html2canvas(documentContent, {
                        scale,
                        useCORS: true,
                        allowTaint: true,
                        backgroundColor: '#ffffff',
                        logging: false,
                        imageTimeout: 15000,
                        windowWidth: lockedWidth,
                        windowHeight: window.innerHeight,
                        width: box.width,
                        height: box.height,
                        onclone: (_doc, clone) => this.frameClone(clone, box)
                    });
                    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, box);
                        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
                        );
                    }
                }

                // "INV-000123 · Page 2 of 3" on every page of a multi-page file,
                // in the strip kept clear below the content.
                const pageCount = pdf.getNumberOfPages();
                if (pageCount > 1) {
                    const label = (documentNumber || documentTitle || '').trim();
                    for (let n = 1; n <= pageCount; n++) {
                        pdf.setPage(n);
                        pdf.setFontSize(8);
                        pdf.setTextColor(120, 120, 120);
                        pdf.text(`${label ? label + '  ·  ' : ''}${n} / ${pageCount}`, a4.width - 12, a4.height - 4.5, { align: 'right' });
                    }
                }

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

})();

```
