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

529 lines 20.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 // 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 return html2canvas(element, {
200 scale,
201 useCORS: true,
202 allowTaint: true,
203 backgroundColor: '#ffffff',
204 logging: false,
205 onclone: (_doc, clone) => {
206 const node = clone;
207 node.style.overflow = 'hidden';
208 node.style.boxSizing = 'border-box';
209 node.style.marginTop = -offsetY + 'px';
210 node.style.height = sliceHeight + 'px';
211 }
212 });
213 }
214
215 /**
216 * Append canvas to PDF with row-aware vertical crops.
217 * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin.
218 *
219 * @param {jsPDF} pdf
220 * @param {HTMLCanvasElement} canvas
221 * @param {number} pageWidthMm
222 * @param {number} pageHeightMm
223 * @param {number[]} breakPointsMm
224 * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation).
225 * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture).
226 * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height).
227 * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px).
228 * @param {number} scale html2canvas scale factor.
229 */
230 addImagePagesRowAware(
231 pdf,
232 canvas,
233 pageWidthMm,
234 pageHeightMm,
235 breakPointsMm,
236 prependPage,
237 fragmentTopPx,
238 fragmentHeightPx,
239 padTopCssPx,
240 scale
241 ) {
242 const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
243 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
244 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
245 .sort((a, b) => a - b);
246
247 if (prependPage) {
248 this.constructor.addA4Page(pdf);
249 }
250
251 let y0mm = 0;
252 let firstCrop = true;
253
254 while (y0mm < imgHeightMm - 0.0001) {
255 const limit = y0mm + pageHeightMm;
256 const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit);
257 let y1mm;
258 if (inRange.length) {
259 y1mm = Math.max(...inRange);
260 } else {
261 const nextB = fullBreaks.find((b) => b > y0mm);
262 if (nextB !== undefined && nextB - y0mm <= pageHeightMm) {
263 y1mm = nextB;
264 } else {
265 y1mm = Math.min(limit, imgHeightMm);
266 }
267 }
268 if (y1mm <= y0mm) {
269 y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm);
270 }
271
272 const py0 = (y0mm / imgHeightMm) * canvas.height;
273 const py1 = (y1mm / imgHeightMm) * canvas.height;
274 const ch = Math.max(1, Math.round(py1 - py0));
275
276 const crop = document.createElement('canvas');
277 crop.width = canvas.width;
278 crop.height = ch;
279 const ctx = crop.getContext('2d');
280 ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch);
281
282 /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */
283 const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx;
284 const padTopCanvasPx =
285 docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0;
286
287 let outCanvas = crop;
288 if (padTopCanvasPx > 0) {
289 const padded = document.createElement('canvas');
290 padded.width = crop.width;
291 padded.height = ch + padTopCanvasPx;
292 const pctx = padded.getContext('2d');
293 pctx.fillStyle = '#ffffff';
294 pctx.fillRect(0, 0, padded.width, padded.height);
295 pctx.drawImage(crop, 0, padTopCanvasPx);
296 outCanvas = padded;
297 }
298
299 const cropData = outCanvas.toDataURL('image/jpeg', 0.92);
300 const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
301
302 if (!firstCrop) {
303 this.constructor.addA4Page(pdf);
304 }
305 firstCrop = false;
306 pdf.addImage(cropData, 'JPEG', 0, 0, pageWidthMm, segMm);
307
308 y0mm = y1mm;
309 }
310 }
311
312 async generatePDF() {
313 try {
314 // Show loading state
315 this.showLoading();
316
317 // Find the document content
318 const documentContent = document.querySelector('.invoice-content') ||
319 document.querySelector('.quote-content') ||
320 document.querySelector('.receipt-container');
321
322 if (!documentContent) {
323 throw new Error('Document content not found');
324 }
325
326 // Get document data for filename
327 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
328 document.querySelector('.quote-title')?.textContent ||
329 document.querySelector('.receipt-title')?.textContent ||
330 this.documentType;
331 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
332 document.querySelector('.quote-number')?.textContent ||
333 document.querySelector('.receipt-number')?.textContent ||
334 '';
335 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
336
337 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
338 if (!jsPDF) {
339 throw new Error('jsPDF library not available');
340 }
341
342 const a4 = this.constructor.getA4SizeMm();
343 const pdf = new jsPDF({
344 orientation: 'p',
345 unit: 'mm',
346 format: 'a4'
347 });
348 const pageWidthMm = a4.width;
349 const pageHeightMm = a4.height;
350 const imgWidthMm = pageWidthMm;
351
352 const baseScale = 1.5;
353 const totalHeight = documentContent.scrollHeight;
354 const totalWidth = documentContent.scrollWidth;
355 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
356 const needsSlices =
357 totalHeight * baseScale > maxSingleCanvasPx ||
358 totalWidth * baseScale > maxSingleCanvasPx;
359
360 const rowBottomsPx = this.collectRowBottomsPx(documentContent);
361 const padTopCssPx = this.getContentPaddingTopPx(documentContent);
362
363 if (!needsSlices) {
364 const scale = this.computeSafeScale(documentContent, baseScale);
365 const canvas = await html2canvas(documentContent, {
366 scale,
367 useCORS: true,
368 allowTaint: true,
369 backgroundColor: '#ffffff',
370 logging: false
371 });
372 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
373 const breakMm = this.breakPointsMmForSlice(
374 rowBottomsPx,
375 0,
376 totalHeight,
377 totalHeight,
378 imgHeightMm
379 );
380 this.addImagePagesRowAware(
381 pdf,
382 canvas,
383 imgWidthMm,
384 pageHeightMm,
385 breakMm,
386 false,
387 0,
388 totalHeight,
389 padTopCssPx,
390 scale
391 );
392 } else {
393 const sliceCss = this.constructor.SLICE_HEIGHT_CSS;
394 let scale = this.computeSafeScale(documentContent, baseScale);
395 scale = Math.min(scale, maxSingleCanvasPx / sliceCss);
396 scale = Math.max(0.2, scale);
397
398 const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss);
399
400 for (let i = 0; i < slices.length; i++) {
401 const { start, end } = slices[i];
402 const sliceH = end - start;
403 const canvas = await this.captureSlice(documentContent, start, sliceH, scale);
404 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
405 const breakMm = this.breakPointsMmForSlice(
406 rowBottomsPx,
407 start,
408 end,
409 sliceH,
410 imgHeightMm
411 );
412 this.addImagePagesRowAware(
413 pdf,
414 canvas,
415 imgWidthMm,
416 pageHeightMm,
417 breakMm,
418 i > 0,
419 start,
420 sliceH,
421 padTopCssPx,
422 scale
423 );
424 }
425 }
426
427 // Download the PDF
428 pdf.save(filename);
429
430 // Hide loading state
431 this.hideLoading();
432
433 } catch (error) {
434 console.error('PDF generation failed:', error);
435 this.hideLoading();
436
437 // Show error message
438 if (typeof EasyInvoiceToast !== 'undefined') {
439 EasyInvoiceToast.show('error', 'Failed to generate PDF. Please try again.');
440 } else if (typeof showMessage !== 'undefined') {
441 showMessage('Failed to generate PDF. Please try again.', 'error');
442 } else {
443 alert('Failed to generate PDF. Please try again.');
444 }
445 }
446 }
447
448 showLoading() {
449 // Create loading overlay
450 const overlay = document.createElement('div');
451 overlay.id = 'pdf-loading-overlay';
452 overlay.style.cssText = `
453 position: fixed;
454 top: 0;
455 left: 0;
456 width: 100%;
457 height: 100%;
458 background: rgba(0, 0, 0, 0.5);
459 display: flex;
460 justify-content: center;
461 align-items: center;
462 z-index: 9999;
463 `;
464
465 const spinner = document.createElement('div');
466 spinner.style.cssText = `
467 background: white;
468 padding: 20px;
469 border-radius: 8px;
470 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
471 text-align: center;
472 `;
473
474 spinner.innerHTML = `
475 <div style="margin-bottom: 10px;">
476 <svg width="40" height="40" viewBox="0 0 40 40">
477 <circle cx="20" cy="20" r="18" stroke="#e3e3e3" stroke-width="4" fill="none"/>
478 <circle cx="20" cy="20" r="18" stroke="#007cba" stroke-width="4" fill="none"
479 stroke-dasharray="113" stroke-dashoffset="113"
480 style="animation: spin 1s linear infinite; transform-origin: 50% 50%;">
481 <animate attributeName="stroke-dashoffset" values="113;0" dur="1s" repeatCount="indefinite"/>
482 </circle>
483 </svg>
484 </div>
485 <div style="color: #333; font-weight: 500;">Generating PDF...</div>
486 `;
487
488 overlay.appendChild(spinner);
489 document.body.appendChild(overlay);
490
491 // Add CSS animation
492 const style = document.createElement('style');
493 style.textContent = `
494 @keyframes spin {
495 0% { stroke-dashoffset: 113; }
496 100% { stroke-dashoffset: 0; }
497 }
498 `;
499 document.head.appendChild(style);
500 }
501
502 hideLoading() {
503 const overlay = document.getElementById('pdf-loading-overlay');
504 if (overlay) {
505 overlay.remove();
506 }
507 }
508 };
509
510 // Backward compatibility - keep InvoicePdfGenerator for existing code
511 window.InvoicePdfGenerator = window.DocumentPdfGenerator;
512
513 // Initialize when DOM is ready
514 if (document.readyState === 'loading') {
515 document.addEventListener('DOMContentLoaded', () => {
516 // Auto-detect document type based on page content
517 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
518 const documentType = isQuote ? 'quote' : 'invoice';
519 new DocumentPdfGenerator(documentType);
520 });
521 } else {
522 // Auto-detect document type based on page content
523 const isQuote = document.querySelector('.quote-content, .quote-title, .quote-number');
524 const documentType = isQuote ? 'quote' : 'invoice';
525 new DocumentPdfGenerator(documentType);
526 }
527
528 })();
529