PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.2
2.4.3 2.4.4 2.4.2 2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 All 60 releases
easy-invoice / assets / js / document-pdf.js

document-pdf.js in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.2, at assets/js/document-pdf.js

688 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Easy Invoice Document PDF Generation using html2canvas
3 * Generates PDF from the actual rendered invoice/quote HTML
4 *
5 * @package Easy_Invoice
6 * @version 1.0.0
7 */
8
9 (function() {
10 'use strict';
11
12 /** ISO 216 A4 (portrait) — explicit mm so PDF page size is always A4 in viewers. */
13 const A4_MM = { width: 210, height: 297 };
14
15 // Global Document PDF Generator Class
16 window.DocumentPdfGenerator = class DocumentPdfGenerator {
17 constructor(documentType = 'invoice') {
18 this.documentType = documentType; // 'invoice' or 'quote'
19 this.init();
20 }
21
22 init() {
23 // html2canvas and jsPDF are bundled with the plugin and loaded ahead of
24 // this file by whoever renders the page (PdfHelper::enqueuePdfScripts(),
25 // AdminAssets, or an explicit <script> in the single/preview templates).
26 //
27 // This used to inject both libraries from cdnjs when they were missing.
28 // That is no longer permitted — WordPress.org requires every asset to
29 // ship inside the plugin — so a missing library is now a setup error we
30 // report rather than something we silently fetch over the network.
31 // Same resolution the render path uses below — the UMD build exposes
32 // window.jspdf.jsPDF, older/standalone builds expose window.jsPDF.
33 const hasJsPdf = !!(window.jsPDF || (window.jspdf && window.jspdf.jsPDF));
34
35 if (typeof html2canvas === 'undefined' || !hasJsPdf) {
36 console.error(
37 'Easy Invoice: PDF generation unavailable — html2canvas and/or jsPDF ' +
38 'were not loaded before document-pdf.js. Enqueue the "html2canvas" and ' +
39 '"jspdf" handles (or include assets/js/vendors/) on this page.'
40 );
41 return;
42 }
43
44 this.setupPDFButtons();
45 }
46
47 setupPDFButtons() {
48 // Find all PDF download buttons
49 const pdfButtons = document.querySelectorAll('.download-pdf-btn, .ei-download-pdf');
50
51 pdfButtons.forEach(button => {
52 button.addEventListener('click', (e) => {
53 e.preventDefault();
54 this.generatePDF();
55 });
56 });
57 }
58
59 /**
60 * Max canvas edge length (px) — browsers cap canvas size; long invoices exceed this in one shot.
61 */
62 static get MAX_CANVAS_EDGE() {
63 return 10000;
64 }
65
66 /**
67 * Vertical slice height (CSS px) per html2canvas capture when content is very tall.
68 */
69 static get SLICE_HEIGHT_CSS() {
70 return 3200;
71 }
72
73 /**
74 * @returns {{width:number,height:number}} A4 size in mm (portrait).
75 */
76 static getA4SizeMm() {
77 return { width: A4_MM.width, height: A4_MM.height };
78 }
79
80 /**
81 * Append an A4 portrait page (matches initial document format).
82 *
83 * @param {Object} pdf jsPDF instance
84 */
85 static addA4Page(pdf) {
86 pdf.addPage('a4', 'p');
87 }
88
89 /**
90 * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset.
91 *
92 * @param {HTMLElement} el
93 * @returns {number}
94 */
95 getContentPaddingTopPx(el) {
96 const s = window.getComputedStyle(el);
97 return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.borderTopWidth) || 0);
98 }
99
100 /**
101 * Collect bottom Y positions (px from top of root) for table rows and summary blocks
102 * so PDF page breaks avoid cutting through a line item row.
103 *
104 * @param {HTMLElement} root
105 * @returns {number[]} Sorted unique pixel positions (content-relative, 0 .. scrollHeight)
106 */
107 collectRowBottomsPx(root) {
108 const rr = root.getBoundingClientRect();
109 const bottoms = [];
110
111 const addEl = (el) => {
112 if (!el || !el.getBoundingClientRect) {
113 return;
114 }
115 const r = el.getBoundingClientRect();
116 const bottom = r.bottom - rr.top + root.scrollTop;
117 if (bottom > 0) {
118 bottoms.push(bottom);
119 }
120 };
121
122 root.querySelectorAll('table tbody > tr').forEach(addEl);
123
124 root.querySelectorAll(
125 '.invoice-summary, .quote-summary, [class*="invoice-summary"], [class*="quote-summary"]'
126 ).forEach(addEl);
127
128 const sorted = [...new Set(bottoms)].sort((a, b) => a - b);
129 return sorted;
130 }
131
132 /**
133 * Build non-overlapping vertical slices [start,end) in px that stay under maxChunkPx
134 * and end on the nearest row bottom when possible (avoids slicing mid-row in html2canvas).
135 *
136 * @param {number} totalHeightPx
137 * @param {number[]} rowBottomsPx
138 * @param {number} maxChunkPx
139 * @returns {Array<{start:number,end:number}>}
140 */
141 buildRowAlignedSlices(totalHeightPx, rowBottomsPx, maxChunkPx) {
142 const slices = [];
143 let start = 0;
144 while (start < totalHeightPx) {
145 let end = Math.min(start + maxChunkPx, totalHeightPx);
146 const backs = rowBottomsPx.filter((b) => b > start && b <= end);
147 if (backs.length) {
148 end = Math.max(...backs);
149 }
150 if (end <= start) {
151 end = Math.min(start + maxChunkPx, totalHeightPx);
152 }
153 slices.push({ start, end });
154 start = end;
155 }
156 return slices;
157 }
158
159 /**
160 * Row / block bottoms within a vertical slice, expressed in mm from the top of that slice's image.
161 *
162 * @param {number[]} rowBottomsPx
163 * @param {number} sliceStartPx
164 * @param {number} sliceEndPx
165 * @param {number} sliceHeightPx
166 * @param {number} imgHeightMm
167 * @returns {number[]}
168 */
169 breakPointsMmForSlice(rowBottomsPx, sliceStartPx, sliceEndPx, sliceHeightPx, imgHeightMm) {
170 const pts = rowBottomsPx
171 .filter((b) => b > sliceStartPx && b <= sliceEndPx)
172 .map((b) => ((b - sliceStartPx) / sliceHeightPx) * imgHeightMm);
173 if (imgHeightMm > 0) {
174 pts.push(imgHeightMm);
175 }
176 return [...new Set(pts)].sort((a, b) => a - b);
177 }
178
179 /**
180 * Compute html2canvas scale so width/height stay under browser canvas limits.
181 *
182 * @param {HTMLElement} el
183 * @param {number} baseScale
184 * @returns {number}
185 */
186 computeSafeScale(el, baseScale) {
187 const w = Math.max(1, el.scrollWidth);
188 const h = Math.max(1, el.scrollHeight);
189 const maxEdge = this.constructor.MAX_CANVAS_EDGE;
190 const maxScale = Math.min(maxEdge / w, maxEdge / h, baseScale);
191 return Math.max(0.2, Math.min(baseScale, maxScale));
192 }
193
194 /**
195 * Capture one vertical slice of an element (for very long content).
196 *
197 * @param {HTMLElement} element
198 * @param {number} offsetY
199 * @param {number} sliceHeight
200 * @param {number} scale
201 * @returns {Promise<HTMLCanvasElement>}
202 */
203 captureSlice(element, offsetY, sliceHeight, scale, box) {
204 // Lock the capture viewport to the element's own layout width so
205 // responsive CSS doesn't kick in and shrink columns mid-capture.
206 box = box || { width: Math.max(1, element.scrollWidth), height: element.scrollHeight, card: null };
207 const lockedWidth = box.card ? window.innerWidth : Math.max(1, element.scrollWidth);
208 return html2canvas(element, {
209 scale,
210 useCORS: true,
211 allowTaint: true,
212 backgroundColor: '#ffffff',
213 logging: false,
214 imageTimeout: 15000,
215 windowWidth: lockedWidth,
216 width: box.width,
217 onclone: (_doc, clone) => {
218 this.frameClone(clone, box);
219 const node = clone;
220 node.style.overflow = 'hidden';
221 node.style.boxSizing = 'border-box';
222 node.style.marginTop = -offsetY + 'px';
223 node.style.height = sliceHeight + 'px';
224 }
225 });
226 }
227
228 /**
229 * Append canvas to PDF with row-aware vertical crops.
230 * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin.
231 *
232 * @param {jsPDF} pdf
233 * @param {HTMLCanvasElement} canvas
234 * @param {number} pageWidthMm
235 * @param {number} pageHeightMm
236 * @param {number[]} breakPointsMm
237 * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation).
238 * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture).
239 * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height).
240 * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px).
241 * @param {number} scale html2canvas scale factor.
242 */
243 addImagePagesRowAware(
244 pdf,
245 canvas,
246 pageWidthMm,
247 pageHeightMm,
248 breakPointsMm,
249 prependPage,
250 fragmentTopPx,
251 fragmentHeightPx,
252 padTopCssPx,
253 scale
254 ) {
255 let imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
256 // A sheet that is one page tall bar a rounding hair must not spill a
257 // blank sliver onto a second page.
258 if (imgHeightMm > pageHeightMm && imgHeightMm - pageHeightMm < 3) {
259 imgHeightMm = pageHeightMm;
260 }
261 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
262 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
263 .sort((a, b) => a - b);
264
265 if (prependPage) {
266 this.constructor.addA4Page(pdf);
267 }
268
269 let y0mm = 0;
270 let firstCrop = true;
271
272 while (y0mm < imgHeightMm - 0.0001) {
273 const limit = y0mm + pageHeightMm;
274 const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit);
275 let y1mm;
276 if (inRange.length) {
277 y1mm = Math.max(...inRange);
278 } else {
279 const nextB = fullBreaks.find((b) => b > y0mm);
280 if (nextB !== undefined && nextB - y0mm <= pageHeightMm) {
281 y1mm = nextB;
282 } else {
283 y1mm = Math.min(limit, imgHeightMm);
284 }
285 }
286 if (y1mm <= y0mm) {
287 y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm);
288 }
289
290 const py0 = (y0mm / imgHeightMm) * canvas.height;
291 const py1 = (y1mm / imgHeightMm) * canvas.height;
292 const ch = Math.max(1, Math.round(py1 - py0));
293
294 const crop = document.createElement('canvas');
295 crop.width = canvas.width;
296 crop.height = ch;
297 const ctx = crop.getContext('2d');
298 ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch);
299
300 /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */
301 const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx;
302 const padTopCanvasPx =
303 docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0;
304
305 let outCanvas = crop;
306 if (padTopCanvasPx > 0) {
307 const padded = document.createElement('canvas');
308 padded.width = crop.width;
309 padded.height = ch + padTopCanvasPx;
310 const pctx = padded.getContext('2d');
311 pctx.fillStyle = '#ffffff';
312 pctx.fillRect(0, 0, padded.width, padded.height);
313 pctx.drawImage(crop, 0, padTopCanvasPx);
314 outCanvas = padded;
315 }
316
317 // PNG is lossless — invoices are mostly text + tables, where
318 // JPEG produces fuzzy edges and colour-fringe around glyphs.
319 // The 'SLOW' compression flag tells jsPDF to use the better
320 // (slower) inflate algorithm so the embedded bitmap retains
321 // its full resolution.
322 const cropData = outCanvas.toDataURL('image/png');
323 const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
324
325 if (!firstCrop) {
326 this.constructor.addA4Page(pdf);
327 }
328 firstCrop = false;
329 pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW');
330
331 y0mm = y1mm;
332 }
333 }
334
335 /**
336 * The PDF reproduces the single page: the design's card, with the page
337 * background around it as on screen. The live page is never touched;
338 * html2canvas renders a clone, and the clone is where the content box
339 * is narrowed to the card and given that margin.
340 */
341 static get FRAME_PX() {
342 return 24;
343 }
344
345 /**
346 * The design's card inside the content box, if there is one.
347 *
348 * @param {HTMLElement} content
349 * @returns {HTMLElement|null}
350 */
351 findCard(content) {
352 return content.querySelector(':scope > .template, :scope > [class*="template-"]')
353 || content.querySelector('.template, [class*="template-"]');
354 }
355
356 /**
357 * Width/height the capture will have: the card plus the frame.
358 *
359 * @param {HTMLElement} content
360 * @returns {{width:number,height:number,card:HTMLElement|null}}
361 */
362 /** Width of an A4 sheet in CSS pixels (210 mm at 96 dpi). */
363 static get A4_CSS_WIDTH() {
364 return 794;
365 }
366
367 /**
368 * Whether the card is the A4 sheet the document page lays out: then it
369 * is captured edge to edge and the PDF is A4, one sheet per page.
370 *
371 * @param {HTMLElement|null} card
372 * @returns {boolean}
373 */
374 isA4Sheet(card) {
375 if (!card) { return false; }
376 return Math.abs(card.getBoundingClientRect().width - this.constructor.A4_CSS_WIDTH) <= 2;
377 }
378
379 captureBox(content) {
380 const card = this.findCard(content);
381 if (!card) {
382 return { width: content.scrollWidth, height: content.scrollHeight, card: null, pad: 0, a4: false };
383 }
384 const a4 = this.isA4Sheet(card);
385 const pad = a4 ? 0 : this.constructor.FRAME_PX;
386 const r = card.getBoundingClientRect();
387 return { width: Math.round(r.width) + pad * 2, height: Math.round(r.height) + pad * 2, card, pad, a4 };
388 }
389
390 /**
391 * Apply the frame inside the cloned document: content box as wide as
392 * the card plus margin, page background behind it, card centred.
393 *
394 * @param {HTMLElement} cloneContent
395 * @param {{width:number,card:HTMLElement|null}} box
396 */
397 frameClone(cloneContent, box) {
398 if (!box.card) { return; }
399 const pad = box.pad;
400 const bodyBg = getComputedStyle(document.body).backgroundColor;
401 cloneContent.style.display = 'block';
402 cloneContent.style.boxSizing = 'border-box';
403 cloneContent.style.width = box.width + 'px';
404 cloneContent.style.maxWidth = box.width + 'px';
405 cloneContent.style.padding = pad + 'px';
406 cloneContent.style.background = pad > 0 && bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#ffffff';
407 cloneContent.style.flex = 'none';
408 const cloneCard = cloneContent.querySelector(':scope > .template, :scope > [class*="template-"]') || cloneContent.querySelector('.template, [class*="template-"]');
409 if (cloneCard) {
410 cloneCard.style.boxSizing = 'border-box';
411 cloneCard.style.marginLeft = '0';
412 cloneCard.style.marginRight = '0';
413 cloneCard.style.width = Math.round(box.card.getBoundingClientRect().width) + 'px';
414 cloneCard.style.maxWidth = 'none';
415 }
416 // Admin-only chrome that is not part of the document.
417 cloneContent.querySelectorAll('#additional-css-btn, .ei-css-toggle').forEach((n) => n.remove());
418 }
419
420 async generatePDF() {
421 try {
422 // Show loading state
423 this.showLoading();
424
425 // Find the document content
426 const documentContent = document.querySelector('.invoice-content') ||
427 document.querySelector('.quote-content') ||
428 document.querySelector('.receipt-container');
429
430 if (!documentContent) {
431 throw new Error('Document content not found');
432 }
433 const box = this.captureBox(documentContent);
434
435 // Get document data for filename
436 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
437 document.querySelector('.quote-title')?.textContent ||
438 document.querySelector('.receipt-title')?.textContent ||
439 this.documentType;
440 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
441 document.querySelector('.quote-number')?.textContent ||
442 document.querySelector('.receipt-number')?.textContent ||
443 (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
444 '';
445 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
446
447 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
448 if (!jsPDF) {
449 throw new Error('jsPDF library not available');
450 }
451
452 const a4 = this.constructor.getA4SizeMm();
453 // One page, sized to the document, when it is not much taller
454 // than an A4 sheet: the PDF is then exactly the page as drawn,
455 // with nothing split across a page break. Much longer documents
456 // (many line items) fall back to A4 pages broken between rows.
457 const contentHeightMm = (box.height * a4.width) / Math.max(1, box.width);
458 // An A4 sheet is paged as A4; anything else that is not much
459 // taller than a sheet becomes one page sized to fit.
460 const singlePage = !box.a4 && contentHeightMm <= a4.height * 2;
461 const pdf = new jsPDF({
462 orientation: 'p',
463 unit: 'mm',
464 format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
465 // FlateDecode-compress the embedded bitmaps so a high-resolution
466 // capture doesn't bloat the file to 20MB.
467 compress: true,
468 precision: 4
469 });
470 const pageWidthMm = a4.width;
471 // A4 pages keep a strip clear at the foot for the page label.
472 const FOOTER_MM = 9;
473 const pageHeightMm = singlePage ? Math.max(a4.height, contentHeightMm) : a4.height - FOOTER_MM;
474 const imgWidthMm = pageWidthMm;
475
476 // 2× the CSS pixel grid — matches retina display density, so text
477 // edges stay crisp instead of soft. (Previous 1.5× produced the
478 // "PDF quality is bad" customer report.)
479 const baseScale = 2;
480 const totalHeight = box.height;
481 const totalWidth = box.width;
482 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
483 const needsSlices =
484 totalHeight * baseScale > maxSingleCanvasPx ||
485 totalWidth * baseScale > maxSingleCanvasPx;
486
487 let rowBottomsPx = this.collectRowBottomsPx(documentContent);
488 let padTopCssPx = this.getContentPaddingTopPx(documentContent);
489 if (box.card) {
490 // Rows were measured on the live page; in the framed clone the
491 // card starts FRAME_PX below the top of the capture.
492 const shift = box.pad - (box.card.getBoundingClientRect().top - documentContent.getBoundingClientRect().top);
493 rowBottomsPx = rowBottomsPx.map((b) => b + shift);
494 padTopCssPx = box.pad;
495 }
496
497 if (!needsSlices) {
498 const scale = this.computeSafeScale(documentContent, baseScale);
499 // Lock the capture viewport to the element's own layout width
500 // so responsive media queries don't shrink the design mid-capture.
501 // The clone must lay out exactly as the live page does — the
502 // crop origin html2canvas uses is the live element's position —
503 // so the viewport stays the real one when the card is framed.
504 const lockedWidth = box.card ? window.innerWidth : Math.max(1, documentContent.scrollWidth);
505 const canvas = await html2canvas(documentContent, {
506 scale,
507 useCORS: true,
508 allowTaint: true,
509 backgroundColor: '#ffffff',
510 logging: false,
511 imageTimeout: 15000,
512 windowWidth: lockedWidth,
513 windowHeight: window.innerHeight,
514 width: box.width,
515 height: box.height,
516 onclone: (_doc, clone) => this.frameClone(clone, box)
517 });
518 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
519 const breakMm = this.breakPointsMmForSlice(
520 rowBottomsPx,
521 0,
522 totalHeight,
523 totalHeight,
524 imgHeightMm
525 );
526 this.addImagePagesRowAware(
527 pdf,
528 canvas,
529 imgWidthMm,
530 pageHeightMm,
531 breakMm,
532 false,
533 0,
534 totalHeight,
535 padTopCssPx,
536 scale
537 );
538 } else {
539 const sliceCss = this.constructor.SLICE_HEIGHT_CSS;
540 let scale = this.computeSafeScale(documentContent, baseScale);
541 scale = Math.min(scale, maxSingleCanvasPx / sliceCss);
542 scale = Math.max(0.2, scale);
543
544 const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss);
545
546 for (let i = 0; i < slices.length; i++) {
547 const { start, end } = slices[i];
548 const sliceH = end - start;
549 const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
550 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
551 const breakMm = this.breakPointsMmForSlice(
552 rowBottomsPx,
553 start,
554 end,
555 sliceH,
556 imgHeightMm
557 );
558 this.addImagePagesRowAware(
559 pdf,
560 canvas,
561 imgWidthMm,
562 pageHeightMm,
563 breakMm,
564 i > 0,
565 start,
566 sliceH,
567 padTopCssPx,
568 scale
569 );
570 }
571 }
572
573 // "INV-000123 · Page 2 of 3" on every page of a multi-page file,
574 // in the strip kept clear below the content.
575 const pageCount = pdf.getNumberOfPages();
576 if (pageCount > 1) {
577 const label = (documentNumber || documentTitle || '').trim();
578 for (let n = 1; n <= pageCount; n++) {
579 pdf.setPage(n);
580 pdf.setFontSize(8);
581 pdf.setTextColor(120, 120, 120);
582 pdf.text(`${label ? label + ' · ' : ''}${n} / ${pageCount}`, a4.width - 12, a4.height - 4.5, { align: 'right' });
583 }
584 }
585
586 // Download the PDF
587 pdf.save(filename);
588
589 // Hide loading state
590 this.hideLoading();
591
592 } catch (error) {
593 console.error('PDF generation failed:', error);
594 this.hideLoading();
595
596 // Show error message
597 if (typeof EasyInvoiceToast !== 'undefined') {
598 EasyInvoiceToast.show('error', 'Failed to generate PDF. Please try again.');
599 } else if (typeof showMessage !== 'undefined') {
600 showMessage('Failed to generate PDF. Please try again.', 'error');
601 } else {
602 alert('Failed to generate PDF. Please try again.');
603 }
604 }
605 }
606
607 showLoading() {
608 // Create loading overlay
609 const overlay = document.createElement('div');
610 overlay.id = 'pdf-loading-overlay';
611 overlay.style.cssText = `
612 position: fixed;
613 top: 0;
614 left: 0;
615 width: 100%;
616 height: 100%;
617 background: rgba(0, 0, 0, 0.5);
618 display: flex;
619 justify-content: center;
620 align-items: center;
621 z-index: 9999;
622 `;
623
624 const spinner = document.createElement('div');
625 spinner.style.cssText = `
626 background: white;
627 padding: 20px;
628 border-radius: 8px;
629 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
630 text-align: center;
631 `;
632
633 spinner.innerHTML = `
634 <div style="margin-bottom: 10px;">
635 <svg width="40" height="40" viewBox="0 0 40 40">
636 <circle cx="20" cy="20" r="18" stroke="#e3e3e3" stroke-width="4" fill="none"/>
637 <circle cx="20" cy="20" r="18" stroke="#007cba" stroke-width="4" fill="none"
638 stroke-dasharray="113" stroke-dashoffset="113"
639 style="animation: spin 1s linear infinite; transform-origin: 50% 50%;">
640 <animate attributeName="stroke-dashoffset" values="113;0" dur="1s" repeatCount="indefinite"/>
641 </circle>
642 </svg>
643 </div>
644 <div style="color: #333; font-weight: 500;">Generating PDF...</div>
645 `;
646
647 overlay.appendChild(spinner);
648 document.body.appendChild(overlay);
649
650 // Add CSS animation
651 const style = document.createElement('style');
652 style.textContent = `
653 @keyframes spin {
654 0% { stroke-dashoffset: 113; }
655 100% { stroke-dashoffset: 0; }
656 }
657 `;
658 document.head.appendChild(style);
659 }
660
661 hideLoading() {
662 const overlay = document.getElementById('pdf-loading-overlay');
663 if (overlay) {
664 overlay.remove();
665 }
666 }
667 };
668
669 // Backward compatibility - keep InvoicePdfGenerator for existing code
670 window.InvoicePdfGenerator = window.DocumentPdfGenerator;
671
672 // Initialize when DOM is ready
673 if (document.readyState === 'loading') {
674 document.addEventListener('DOMContentLoaded', () => {
675 // Auto-detect document type based on page content
676 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
677 const documentType = isQuote ? 'quote' : 'invoice';
678 new DocumentPdfGenerator(documentType);
679 });
680 } else {
681 // Auto-detect document type based on page content
682 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
683 const documentType = isQuote ? 'quote' : 'invoice';
684 new DocumentPdfGenerator(documentType);
685 }
686
687 })();
688