PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.8
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.8
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 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / assets / js / document-pdf.js

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

553 lines 22.1 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 // Add html2canvas script if not already loaded
24 if (typeof html2canvas === 'undefined') {
25 this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', () => {
26 this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', () => {
27 this.setupPDFButtons();
28 });
29 });
30 } else {
31 this.setupPDFButtons();
32 }
33 }
34
35 loadScript(src, callback) {
36 const script = document.createElement('script');
37 script.src = src;
38 script.onload = callback;
39 document.head.appendChild(script);
40 }
41
42 setupPDFButtons() {
43 // Find all PDF download buttons
44 const pdfButtons = document.querySelectorAll('.download-pdf-btn, .ei-download-pdf');
45
46 pdfButtons.forEach(button => {
47 button.addEventListener('click', (e) => {
48 e.preventDefault();
49 this.generatePDF();
50 });
51 });
52 }
53
54 /**
55 * Max canvas edge length (px) — browsers cap canvas size; long invoices exceed this in one shot.
56 */
57 static get MAX_CANVAS_EDGE() {
58 return 10000;
59 }
60
61 /**
62 * Vertical slice height (CSS px) per html2canvas capture when content is very tall.
63 */
64 static get SLICE_HEIGHT_CSS() {
65 return 3200;
66 }
67
68 /**
69 * @returns {{width:number,height:number}} A4 size in mm (portrait).
70 */
71 static getA4SizeMm() {
72 return { width: A4_MM.width, height: A4_MM.height };
73 }
74
75 /**
76 * Append an A4 portrait page (matches initial document format).
77 *
78 * @param {Object} pdf jsPDF instance
79 */
80 static addA4Page(pdf) {
81 pdf.addPage('a4', 'p');
82 }
83
84 /**
85 * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset.
86 *
87 * @param {HTMLElement} el
88 * @returns {number}
89 */
90 getContentPaddingTopPx(el) {
91 const s = window.getComputedStyle(el);
92 return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.borderTopWidth) || 0);
93 }
94
95 /**
96 * Collect bottom Y positions (px from top of root) for table rows and summary blocks
97 * so PDF page breaks avoid cutting through a line item row.
98 *
99 * @param {HTMLElement} root
100 * @returns {number[]} Sorted unique pixel positions (content-relative, 0 .. scrollHeight)
101 */
102 collectRowBottomsPx(root) {
103 const rr = root.getBoundingClientRect();
104 const bottoms = [];
105
106 const addEl = (el) => {
107 if (!el || !el.getBoundingClientRect) {
108 return;
109 }
110 const r = el.getBoundingClientRect();
111 const bottom = r.bottom - rr.top + root.scrollTop;
112 if (bottom > 0) {
113 bottoms.push(bottom);
114 }
115 };
116
117 root.querySelectorAll('table tbody > tr').forEach(addEl);
118
119 root.querySelectorAll(
120 '.invoice-summary, .quote-summary, [class*="invoice-summary"], [class*="quote-summary"]'
121 ).forEach(addEl);
122
123 const sorted = [...new Set(bottoms)].sort((a, b) => a - b);
124 return sorted;
125 }
126
127 /**
128 * Build non-overlapping vertical slices [start,end) in px that stay under maxChunkPx
129 * and end on the nearest row bottom when possible (avoids slicing mid-row in html2canvas).
130 *
131 * @param {number} totalHeightPx
132 * @param {number[]} rowBottomsPx
133 * @param {number} maxChunkPx
134 * @returns {Array<{start:number,end:number}>}
135 */
136 buildRowAlignedSlices(totalHeightPx, rowBottomsPx, maxChunkPx) {
137 const slices = [];
138 let start = 0;
139 while (start < totalHeightPx) {
140 let end = Math.min(start + maxChunkPx, totalHeightPx);
141 const backs = rowBottomsPx.filter((b) => b > start && b <= end);
142 if (backs.length) {
143 end = Math.max(...backs);
144 }
145 if (end <= start) {
146 end = Math.min(start + maxChunkPx, totalHeightPx);
147 }
148 slices.push({ start, end });
149 start = end;
150 }
151 return slices;
152 }
153
154 /**
155 * Row / block bottoms within a vertical slice, expressed in mm from the top of that slice's image.
156 *
157 * @param {number[]} rowBottomsPx
158 * @param {number} sliceStartPx
159 * @param {number} sliceEndPx
160 * @param {number} sliceHeightPx
161 * @param {number} imgHeightMm
162 * @returns {number[]}
163 */
164 breakPointsMmForSlice(rowBottomsPx, sliceStartPx, sliceEndPx, sliceHeightPx, imgHeightMm) {
165 const pts = rowBottomsPx
166 .filter((b) => b > sliceStartPx && b <= sliceEndPx)
167 .map((b) => ((b - sliceStartPx) / sliceHeightPx) * imgHeightMm);
168 if (imgHeightMm > 0) {
169 pts.push(imgHeightMm);
170 }
171 return [...new Set(pts)].sort((a, b) => a - b);
172 }
173
174 /**
175 * Compute html2canvas scale so width/height stay under browser canvas limits.
176 *
177 * @param {HTMLElement} el
178 * @param {number} baseScale
179 * @returns {number}
180 */
181 computeSafeScale(el, baseScale) {
182 const w = Math.max(1, el.scrollWidth);
183 const h = Math.max(1, el.scrollHeight);
184 const maxEdge = this.constructor.MAX_CANVAS_EDGE;
185 const maxScale = Math.min(maxEdge / w, maxEdge / h, baseScale);
186 return Math.max(0.2, Math.min(baseScale, maxScale));
187 }
188
189 /**
190 * Capture one vertical slice of an element (for very long content).
191 *
192 * @param {HTMLElement} element
193 * @param {number} offsetY
194 * @param {number} sliceHeight
195 * @param {number} scale
196 * @returns {Promise<HTMLCanvasElement>}
197 */
198 captureSlice(element, offsetY, sliceHeight, scale) {
199 // Lock the capture viewport to the element's own layout width so
200 // responsive CSS doesn't kick in and shrink columns mid-capture.
201 const lockedWidth = Math.max(1, element.scrollWidth);
202 return html2canvas(element, {
203 scale,
204 useCORS: true,
205 allowTaint: true,
206 backgroundColor: '#ffffff',
207 logging: false,
208 imageTimeout: 15000,
209 windowWidth: lockedWidth,
210 width: lockedWidth,
211 onclone: (_doc, clone) => {
212 const node = clone;
213 node.style.overflow = 'hidden';
214 node.style.boxSizing = 'border-box';
215 node.style.marginTop = -offsetY + 'px';
216 node.style.height = sliceHeight + 'px';
217 }
218 });
219 }
220
221 /**
222 * Append canvas to PDF with row-aware vertical crops.
223 * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin.
224 *
225 * @param {jsPDF} pdf
226 * @param {HTMLCanvasElement} canvas
227 * @param {number} pageWidthMm
228 * @param {number} pageHeightMm
229 * @param {number[]} breakPointsMm
230 * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation).
231 * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture).
232 * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height).
233 * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px).
234 * @param {number} scale html2canvas scale factor.
235 */
236 addImagePagesRowAware(
237 pdf,
238 canvas,
239 pageWidthMm,
240 pageHeightMm,
241 breakPointsMm,
242 prependPage,
243 fragmentTopPx,
244 fragmentHeightPx,
245 padTopCssPx,
246 scale
247 ) {
248 const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
249 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
250 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
251 .sort((a, b) => a - b);
252
253 if (prependPage) {
254 this.constructor.addA4Page(pdf);
255 }
256
257 let y0mm = 0;
258 let firstCrop = true;
259
260 while (y0mm < imgHeightMm - 0.0001) {
261 const limit = y0mm + pageHeightMm;
262 const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit);
263 let y1mm;
264 if (inRange.length) {
265 y1mm = Math.max(...inRange);
266 } else {
267 const nextB = fullBreaks.find((b) => b > y0mm);
268 if (nextB !== undefined && nextB - y0mm <= pageHeightMm) {
269 y1mm = nextB;
270 } else {
271 y1mm = Math.min(limit, imgHeightMm);
272 }
273 }
274 if (y1mm <= y0mm) {
275 y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm);
276 }
277
278 const py0 = (y0mm / imgHeightMm) * canvas.height;
279 const py1 = (y1mm / imgHeightMm) * canvas.height;
280 const ch = Math.max(1, Math.round(py1 - py0));
281
282 const crop = document.createElement('canvas');
283 crop.width = canvas.width;
284 crop.height = ch;
285 const ctx = crop.getContext('2d');
286 ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch);
287
288 /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */
289 const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx;
290 const padTopCanvasPx =
291 docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0;
292
293 let outCanvas = crop;
294 if (padTopCanvasPx > 0) {
295 const padded = document.createElement('canvas');
296 padded.width = crop.width;
297 padded.height = ch + padTopCanvasPx;
298 const pctx = padded.getContext('2d');
299 pctx.fillStyle = '#ffffff';
300 pctx.fillRect(0, 0, padded.width, padded.height);
301 pctx.drawImage(crop, 0, padTopCanvasPx);
302 outCanvas = padded;
303 }
304
305 // PNG is lossless — invoices are mostly text + tables, where
306 // JPEG produces fuzzy edges and colour-fringe around glyphs.
307 // The 'SLOW' compression flag tells jsPDF to use the better
308 // (slower) inflate algorithm so the embedded bitmap retains
309 // its full resolution.
310 const cropData = outCanvas.toDataURL('image/png');
311 const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
312
313 if (!firstCrop) {
314 this.constructor.addA4Page(pdf);
315 }
316 firstCrop = false;
317 pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW');
318
319 y0mm = y1mm;
320 }
321 }
322
323 async generatePDF() {
324 try {
325 // Show loading state
326 this.showLoading();
327
328 // Find the document content
329 const documentContent = document.querySelector('.invoice-content') ||
330 document.querySelector('.quote-content') ||
331 document.querySelector('.receipt-container');
332
333 if (!documentContent) {
334 throw new Error('Document content not found');
335 }
336
337 // Get document data for filename
338 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
339 document.querySelector('.quote-title')?.textContent ||
340 document.querySelector('.receipt-title')?.textContent ||
341 this.documentType;
342 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
343 document.querySelector('.quote-number')?.textContent ||
344 document.querySelector('.receipt-number')?.textContent ||
345 '';
346 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
347
348 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
349 if (!jsPDF) {
350 throw new Error('jsPDF library not available');
351 }
352
353 const a4 = this.constructor.getA4SizeMm();
354 const pdf = new jsPDF({
355 orientation: 'p',
356 unit: 'mm',
357 format: 'a4',
358 // FlateDecode-compress the embedded bitmaps so a high-resolution
359 // capture doesn't bloat the file to 20MB.
360 compress: true,
361 precision: 4
362 });
363 const pageWidthMm = a4.width;
364 const pageHeightMm = a4.height;
365 const imgWidthMm = pageWidthMm;
366
367 // 2× the CSS pixel grid — matches retina display density, so text
368 // edges stay crisp instead of soft. (Previous 1.5× produced the
369 // "PDF quality is bad" customer report.)
370 const baseScale = 2;
371 const totalHeight = documentContent.scrollHeight;
372 const totalWidth = documentContent.scrollWidth;
373 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
374 const needsSlices =
375 totalHeight * baseScale > maxSingleCanvasPx ||
376 totalWidth * baseScale > maxSingleCanvasPx;
377
378 const rowBottomsPx = this.collectRowBottomsPx(documentContent);
379 const padTopCssPx = this.getContentPaddingTopPx(documentContent);
380
381 if (!needsSlices) {
382 const scale = this.computeSafeScale(documentContent, baseScale);
383 // Lock the capture viewport to the element's own layout width
384 // so responsive media queries don't shrink the design mid-capture.
385 const lockedWidth = Math.max(1, documentContent.scrollWidth);
386 const canvas = await html2canvas(documentContent, {
387 scale,
388 useCORS: true,
389 allowTaint: true,
390 backgroundColor: '#ffffff',
391 logging: false,
392 imageTimeout: 15000,
393 windowWidth: lockedWidth,
394 width: lockedWidth
395 });
396 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
397 const breakMm = this.breakPointsMmForSlice(
398 rowBottomsPx,
399 0,
400 totalHeight,
401 totalHeight,
402 imgHeightMm
403 );
404 this.addImagePagesRowAware(
405 pdf,
406 canvas,
407 imgWidthMm,
408 pageHeightMm,
409 breakMm,
410 false,
411 0,
412 totalHeight,
413 padTopCssPx,
414 scale
415 );
416 } else {
417 const sliceCss = this.constructor.SLICE_HEIGHT_CSS;
418 let scale = this.computeSafeScale(documentContent, baseScale);
419 scale = Math.min(scale, maxSingleCanvasPx / sliceCss);
420 scale = Math.max(0.2, scale);
421
422 const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss);
423
424 for (let i = 0; i < slices.length; i++) {
425 const { start, end } = slices[i];
426 const sliceH = end - start;
427 const canvas = await this.captureSlice(documentContent, start, sliceH, scale);
428 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
429 const breakMm = this.breakPointsMmForSlice(
430 rowBottomsPx,
431 start,
432 end,
433 sliceH,
434 imgHeightMm
435 );
436 this.addImagePagesRowAware(
437 pdf,
438 canvas,
439 imgWidthMm,
440 pageHeightMm,
441 breakMm,
442 i > 0,
443 start,
444 sliceH,
445 padTopCssPx,
446 scale
447 );
448 }
449 }
450
451 // Download the PDF
452 pdf.save(filename);
453
454 // Hide loading state
455 this.hideLoading();
456
457 } catch (error) {
458 console.error('PDF generation failed:', error);
459 this.hideLoading();
460
461 // Show error message
462 if (typeof EasyInvoiceToast !== 'undefined') {
463 EasyInvoiceToast.show('error', 'Failed to generate PDF. Please try again.');
464 } else if (typeof showMessage !== 'undefined') {
465 showMessage('Failed to generate PDF. Please try again.', 'error');
466 } else {
467 alert('Failed to generate PDF. Please try again.');
468 }
469 }
470 }
471
472 showLoading() {
473 // Create loading overlay
474 const overlay = document.createElement('div');
475 overlay.id = 'pdf-loading-overlay';
476 overlay.style.cssText = `
477 position: fixed;
478 top: 0;
479 left: 0;
480 width: 100%;
481 height: 100%;
482 background: rgba(0, 0, 0, 0.5);
483 display: flex;
484 justify-content: center;
485 align-items: center;
486 z-index: 9999;
487 `;
488
489 const spinner = document.createElement('div');
490 spinner.style.cssText = `
491 background: white;
492 padding: 20px;
493 border-radius: 8px;
494 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
495 text-align: center;
496 `;
497
498 spinner.innerHTML = `
499 <div style="margin-bottom: 10px;">
500 <svg width="40" height="40" viewBox="0 0 40 40">
501 <circle cx="20" cy="20" r="18" stroke="#e3e3e3" stroke-width="4" fill="none"/>
502 <circle cx="20" cy="20" r="18" stroke="#007cba" stroke-width="4" fill="none"
503 stroke-dasharray="113" stroke-dashoffset="113"
504 style="animation: spin 1s linear infinite; transform-origin: 50% 50%;">
505 <animate attributeName="stroke-dashoffset" values="113;0" dur="1s" repeatCount="indefinite"/>
506 </circle>
507 </svg>
508 </div>
509 <div style="color: #333; font-weight: 500;">Generating PDF...</div>
510 `;
511
512 overlay.appendChild(spinner);
513 document.body.appendChild(overlay);
514
515 // Add CSS animation
516 const style = document.createElement('style');
517 style.textContent = `
518 @keyframes spin {
519 0% { stroke-dashoffset: 113; }
520 100% { stroke-dashoffset: 0; }
521 }
522 `;
523 document.head.appendChild(style);
524 }
525
526 hideLoading() {
527 const overlay = document.getElementById('pdf-loading-overlay');
528 if (overlay) {
529 overlay.remove();
530 }
531 }
532 };
533
534 // Backward compatibility - keep InvoicePdfGenerator for existing code
535 window.InvoicePdfGenerator = window.DocumentPdfGenerator;
536
537 // Initialize when DOM is ready
538 if (document.readyState === 'loading') {
539 document.addEventListener('DOMContentLoaded', () => {
540 // Auto-detect document type based on page content
541 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
542 const documentType = isQuote ? 'quote' : 'invoice';
543 new DocumentPdfGenerator(documentType);
544 });
545 } else {
546 // Auto-detect document type based on page content
547 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
548 const documentType = isQuote ? 'quote' : 'invoice';
549 new DocumentPdfGenerator(documentType);
550 }
551
552 })();
553