PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
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.3, at assets/js/document-pdf.js

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