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
← All changes | assets/js/document-pdf.js +188 -28 2.3.1 → 2.4.3 View file →
@@ -19,25 +19,30 @@
19 19 this.init();
20 20 }
21 21
22 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();
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;
32 42 }
33 - }
34 43
35 - loadScript(src, callback) {
36 - const script = document.createElement('script');
37 - script.src = src;
38 - script.onload = callback;
39 - document.head.appendChild(script);
44 + this.setupPDFButtons();
40 45 }
41 46
42 47 setupPDFButtons() {
43 48 // Find all PDF download buttons
@@ -78,11 +83,28 @@
78 83 * @param {Object} pdf jsPDF instance
79 84 */
80 85 static addA4Page(pdf) {
81 86 pdf.addPage('a4', 'p');
87 + this.paintPageWhite(pdf);
82 88 }
83 89
84 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 + /**
85 107 * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset.
86 108 *
87 109 * @param {HTMLElement} el
88 110 * @returns {number}
@@ -194,12 +216,13 @@
194 216 * @param {number} sliceHeight
195 217 * @param {number} scale
196 218 * @returns {Promise<HTMLCanvasElement>}
197 219 */
198 - captureSlice(element, offsetY, sliceHeight, scale) {
220 + captureSlice(element, offsetY, sliceHeight, scale, box) {
199 221 // Lock the capture viewport to the element's own layout width so
200 222 // responsive CSS doesn't kick in and shrink columns mid-capture.
201 - const lockedWidth = Math.max(1, element.scrollWidth);
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);
202 225 return html2canvas(element, {
203 226 scale,
204 227 useCORS: true,
205 228 allowTaint: true,
@@ -206,10 +229,11 @@
206 229 backgroundColor: '#ffffff',
207 230 logging: false,
208 231 imageTimeout: 15000,
209 232 windowWidth: lockedWidth,
210 - width: lockedWidth,
233 + width: box.width,
211 234 onclone: (_doc, clone) => {
235 + this.frameClone(clone, box);
212 236 const node = clone;
213 237 node.style.overflow = 'hidden';
214 238 node.style.boxSizing = 'border-box';
215 239 node.style.marginTop = -offsetY + 'px';
@@ -244,9 +268,14 @@
244 268 fragmentHeightPx,
245 269 padTopCssPx,
246 270 scale
247 271 ) {
248 - const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
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 + }
249 278 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
250 279 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
251 280 .sort((a, b) => a - b);
252 281
@@ -319,8 +348,93 @@
319 348 y0mm = y1mm;
320 349 }
321 350 }
322 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 +
323 437 async generatePDF() {
324 438 try {
325 439 // Show loading state
326 440 this.showLoading();
@@ -332,8 +446,9 @@
332 446
333 447 if (!documentContent) {
334 448 throw new Error('Document content not found');
335 449 }
450 + const box = this.captureBox(documentContent);
336 451
337 452 // Get document data for filename
338 453 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
339 454 document.querySelector('.quote-title')?.textContent ||
@@ -341,8 +456,9 @@
341 456 this.documentType;
342 457 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
343 458 document.querySelector('.quote-number')?.textContent ||
344 459 document.querySelector('.receipt-number')?.textContent ||
460 + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
345 461 '';
346 462 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
347 463
348 464 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
@@ -350,19 +466,37 @@
350 466 throw new Error('jsPDF library not available');
351 467 }
352 468
353 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;
354 478 const pdf = new jsPDF({
355 479 orientation: 'p',
356 480 unit: 'mm',
357 - format: 'a4',
481 + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
358 482 // FlateDecode-compress the embedded bitmaps so a high-resolution
359 483 // capture doesn't bloat the file to 20MB.
360 484 compress: true,
361 485 precision: 4
362 486 });
487 + this.constructor.paintPageWhite(pdf);
363 488 const pageWidthMm = a4.width;
364 - const pageHeightMm = a4.height;
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);
365 499 const imgWidthMm = pageWidthMm;
366 500
367 501 // 2× the CSS pixel grid — matches retina display density, so text
368 502 // edges stay crisp instead of soft. (Previous 1.5× produced the
@@ -367,23 +501,33 @@
367 501 // 2× the CSS pixel grid — matches retina display density, so text
368 502 // edges stay crisp instead of soft. (Previous 1.5× produced the
369 503 // "PDF quality is bad" customer report.)
370 504 const baseScale = 2;
371 - const totalHeight = documentContent.scrollHeight;
372 - const totalWidth = documentContent.scrollWidth;
505 + const totalHeight = box.height;
506 + const totalWidth = box.width;
373 507 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
374 508 const needsSlices =
375 509 totalHeight * baseScale > maxSingleCanvasPx ||
376 510 totalWidth * baseScale > maxSingleCanvasPx;
377 511
378 - const rowBottomsPx = this.collectRowBottomsPx(documentContent);
379 - const padTopCssPx = this.getContentPaddingTopPx(documentContent);
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 + }
380 521
381 522 if (!needsSlices) {
382 523 const scale = this.computeSafeScale(documentContent, baseScale);
383 524 // Lock the capture viewport to the element's own layout width
384 525 // so responsive media queries don't shrink the design mid-capture.
385 - const lockedWidth = Math.max(1, documentContent.scrollWidth);
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);
386 530 const canvas = await html2canvas(documentContent, {
387 531 scale,
388 532 useCORS: true,
389 533 allowTaint: true,
@@ -390,9 +534,12 @@
390 534 backgroundColor: '#ffffff',
391 535 logging: false,
392 536 imageTimeout: 15000,
393 537 windowWidth: lockedWidth,
394 - width: lockedWidth
538 + windowHeight: window.innerHeight,
539 + width: box.width,
540 + height: box.height,
541 + onclone: (_doc, clone) => this.frameClone(clone, box)
395 542 });
396 543 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
397 544 const breakMm = this.breakPointsMmForSlice(
398 545 rowBottomsPx,
@@ -423,9 +570,9 @@
423 570
424 571 for (let i = 0; i < slices.length; i++) {
425 572 const { start, end } = slices[i];
426 573 const sliceH = end - start;
427 - const canvas = await this.captureSlice(documentContent, start, sliceH, scale);
574 + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
428 575 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
429 576 const breakMm = this.breakPointsMmForSlice(
430 577 rowBottomsPx,
431 578 start,
@@ -444,8 +591,21 @@
444 591 sliceH,
445 592 padTopCssPx,
446 593 scale
447 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' });
448 608 }
449 609 }
450 610
451 611 // Download the PDF