PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
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
← All changes | assets/js/document-pdf.js +163 -28 2.3.42.4.1 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
@@ -194,12 +199,13 @@
194 199 * @param {number} sliceHeight
195 200 * @param {number} scale
196 201 * @returns {Promise<HTMLCanvasElement>}
197 202 */
198 - captureSlice(element, offsetY, sliceHeight, scale) {
203 + captureSlice(element, offsetY, sliceHeight, scale, box) {
199 204 // Lock the capture viewport to the element's own layout width so
200 205 // responsive CSS doesn't kick in and shrink columns mid-capture.
201 - const lockedWidth = Math.max(1, element.scrollWidth);
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);
202 208 return html2canvas(element, {
203 209 scale,
204 210 useCORS: true,
205 211 allowTaint: true,
@@ -206,10 +212,11 @@
206 212 backgroundColor: '#ffffff',
207 213 logging: false,
208 214 imageTimeout: 15000,
209 215 windowWidth: lockedWidth,
210 - width: lockedWidth,
216 + width: box.width,
211 217 onclone: (_doc, clone) => {
218 + this.frameClone(clone, box);
212 219 const node = clone;
213 220 node.style.overflow = 'hidden';
214 221 node.style.boxSizing = 'border-box';
215 222 node.style.marginTop = -offsetY + 'px';
@@ -244,9 +251,14 @@
244 251 fragmentHeightPx,
245 252 padTopCssPx,
246 253 scale
247 254 ) {
248 - const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width;
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 + }
249 261 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
250 262 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
251 263 .sort((a, b) => a - b);
252 264
@@ -319,8 +331,93 @@
319 331 y0mm = y1mm;
320 332 }
321 333 }
322 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 +
323 420 async generatePDF() {
324 421 try {
325 422 // Show loading state
326 423 this.showLoading();
@@ -332,8 +429,9 @@
332 429
333 430 if (!documentContent) {
334 431 throw new Error('Document content not found');
335 432 }
433 + const box = this.captureBox(documentContent);
336 434
337 435 // Get document data for filename
338 436 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
339 437 document.querySelector('.quote-title')?.textContent ||
@@ -341,8 +439,9 @@
341 439 this.documentType;
342 440 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
343 441 document.querySelector('.quote-number')?.textContent ||
344 442 document.querySelector('.receipt-number')?.textContent ||
443 + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
345 444 '';
346 445 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
347 446
348 447 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
@@ -350,12 +449,20 @@
350 449 throw new Error('jsPDF library not available');
351 450 }
352 451
353 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;
354 461 const pdf = new jsPDF({
355 462 orientation: 'p',
356 463 unit: 'mm',
357 - format: 'a4',
464 + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
358 465 // FlateDecode-compress the embedded bitmaps so a high-resolution
359 466 // capture doesn't bloat the file to 20MB.
360 467 compress: true,
361 468 precision: 4
@@ -360,9 +467,11 @@
360 467 compress: true,
361 468 precision: 4
362 469 });
363 470 const pageWidthMm = a4.width;
364 - const pageHeightMm = a4.height;
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;
365 474 const imgWidthMm = pageWidthMm;
366 475
367 476 // 2× the CSS pixel grid — matches retina display density, so text
368 477 // edges stay crisp instead of soft. (Previous 1.5× produced the
@@ -367,23 +476,33 @@
367 476 // 2× the CSS pixel grid — matches retina display density, so text
368 477 // edges stay crisp instead of soft. (Previous 1.5× produced the
369 478 // "PDF quality is bad" customer report.)
370 479 const baseScale = 2;
371 - const totalHeight = documentContent.scrollHeight;
372 - const totalWidth = documentContent.scrollWidth;
480 + const totalHeight = box.height;
481 + const totalWidth = box.width;
373 482 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
374 483 const needsSlices =
375 484 totalHeight * baseScale > maxSingleCanvasPx ||
376 485 totalWidth * baseScale > maxSingleCanvasPx;
377 486
378 - const rowBottomsPx = this.collectRowBottomsPx(documentContent);
379 - const padTopCssPx = this.getContentPaddingTopPx(documentContent);
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 + }
380 496
381 497 if (!needsSlices) {
382 498 const scale = this.computeSafeScale(documentContent, baseScale);
383 499 // Lock the capture viewport to the element's own layout width
384 500 // so responsive media queries don't shrink the design mid-capture.
385 - const lockedWidth = Math.max(1, documentContent.scrollWidth);
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);
386 505 const canvas = await html2canvas(documentContent, {
387 506 scale,
388 507 useCORS: true,
389 508 allowTaint: true,
@@ -390,9 +509,12 @@
390 509 backgroundColor: '#ffffff',
391 510 logging: false,
392 511 imageTimeout: 15000,
393 512 windowWidth: lockedWidth,
394 - width: lockedWidth
513 + windowHeight: window.innerHeight,
514 + width: box.width,
515 + height: box.height,
516 + onclone: (_doc, clone) => this.frameClone(clone, box)
395 517 });
396 518 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
397 519 const breakMm = this.breakPointsMmForSlice(
398 520 rowBottomsPx,
@@ -423,9 +545,9 @@
423 545
424 546 for (let i = 0; i < slices.length; i++) {
425 547 const { start, end } = slices[i];
426 548 const sliceH = end - start;
427 - const canvas = await this.captureSlice(documentContent, start, sliceH, scale);
549 + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
428 550 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
429 551 const breakMm = this.breakPointsMmForSlice(
430 552 rowBottomsPx,
431 553 start,
@@ -444,8 +566,21 @@
444 566 sliceH,
445 567 padTopCssPx,
446 568 scale
447 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' });
448 583 }
449 584 }
450 585
451 586 // Download the PDF