PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.2
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 +187 -28 2.1.18 → 2.4.2 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,9 +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) {
204 + // Lock the capture viewport to the element's own layout width so
205 + // responsive CSS doesn't kick in and shrink columns mid-capture.
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);
199 208 return html2canvas(element, {
200 209 scale,
201 210 useCORS: true,
202 211 allowTaint: true,
@@ -201,9 +210,13 @@
201 210 useCORS: true,
202 211 allowTaint: true,
203 212 backgroundColor: '#ffffff',
204 213 logging: false,
214 + imageTimeout: 15000,
215 + windowWidth: lockedWidth,
216 + width: box.width,
205 217 onclone: (_doc, clone) => {
218 + this.frameClone(clone, box);
206 219 const node = clone;
207 220 node.style.overflow = 'hidden';
208 221 node.style.boxSizing = 'border-box';
209 222 node.style.marginTop = -offsetY + 'px';
@@ -238,9 +251,14 @@
238 251 fragmentHeightPx,
239 252 padTopCssPx,
240 253 scale
241 254 ) {
242 - 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 + }
243 261 const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
244 262 .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
245 263 .sort((a, b) => a - b);
246 264
@@ -295,9 +313,14 @@
295 313 pctx.drawImage(crop, 0, padTopCanvasPx);
296 314 outCanvas = padded;
297 315 }
298 316
299 - const cropData = outCanvas.toDataURL('image/jpeg', 0.92);
317 + // PNG is lossless — invoices are mostly text + tables, where
318 + // JPEG produces fuzzy edges and colour-fringe around glyphs.
319 + // The 'SLOW' compression flag tells jsPDF to use the better
320 + // (slower) inflate algorithm so the embedded bitmap retains
321 + // its full resolution.
322 + const cropData = outCanvas.toDataURL('image/png');
300 323 const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
301 324
302 325 if (!firstCrop) {
303 326 this.constructor.addA4Page(pdf);
@@ -302,14 +325,99 @@
302 325 if (!firstCrop) {
303 326 this.constructor.addA4Page(pdf);
304 327 }
305 328 firstCrop = false;
306 - pdf.addImage(cropData, 'JPEG', 0, 0, pageWidthMm, segMm);
329 + pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW');
307 330
308 331 y0mm = y1mm;
309 332 }
310 333 }
311 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 +
312 420 async generatePDF() {
313 421 try {
314 422 // Show loading state
315 423 this.showLoading();
@@ -321,8 +429,9 @@
321 429
322 430 if (!documentContent) {
323 431 throw new Error('Document content not found');
324 432 }
433 + const box = this.captureBox(documentContent);
325 434
326 435 // Get document data for filename
327 436 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
328 437 document.querySelector('.quote-title')?.textContent ||
@@ -330,8 +439,9 @@
330 439 this.documentType;
331 440 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
332 441 document.querySelector('.quote-number')?.textContent ||
333 442 document.querySelector('.receipt-number')?.textContent ||
443 + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
334 444 '';
335 445 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
336 446
337 447 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
@@ -339,36 +449,72 @@
339 449 throw new Error('jsPDF library not available');
340 450 }
341 451
342 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;
343 461 const pdf = new jsPDF({
344 462 orientation: 'p',
345 463 unit: 'mm',
346 - format: 'a4'
464 + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
465 + // FlateDecode-compress the embedded bitmaps so a high-resolution
466 + // capture doesn't bloat the file to 20MB.
467 + compress: true,
468 + precision: 4
347 469 });
348 470 const pageWidthMm = a4.width;
349 - 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;
350 474 const imgWidthMm = pageWidthMm;
351 475
352 - const baseScale = 1.5;
353 - const totalHeight = documentContent.scrollHeight;
354 - const totalWidth = documentContent.scrollWidth;
476 + // 2× the CSS pixel grid — matches retina display density, so text
477 + // edges stay crisp instead of soft. (Previous 1.5× produced the
478 + // "PDF quality is bad" customer report.)
479 + const baseScale = 2;
480 + const totalHeight = box.height;
481 + const totalWidth = box.width;
355 482 const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
356 483 const needsSlices =
357 484 totalHeight * baseScale > maxSingleCanvasPx ||
358 485 totalWidth * baseScale > maxSingleCanvasPx;
359 486
360 - const rowBottomsPx = this.collectRowBottomsPx(documentContent);
361 - 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 + }
362 496
363 497 if (!needsSlices) {
364 498 const scale = this.computeSafeScale(documentContent, baseScale);
499 + // Lock the capture viewport to the element's own layout width
500 + // so responsive media queries don't shrink the design mid-capture.
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);
365 505 const canvas = await html2canvas(documentContent, {
366 506 scale,
367 507 useCORS: true,
368 508 allowTaint: true,
369 509 backgroundColor: '#ffffff',
370 - logging: false
510 + logging: false,
511 + imageTimeout: 15000,
512 + windowWidth: lockedWidth,
513 + windowHeight: window.innerHeight,
514 + width: box.width,
515 + height: box.height,
516 + onclone: (_doc, clone) => this.frameClone(clone, box)
371 517 });
372 518 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
373 519 const breakMm = this.breakPointsMmForSlice(
374 520 rowBottomsPx,
@@ -399,9 +545,9 @@
399 545
400 546 for (let i = 0; i < slices.length; i++) {
401 547 const { start, end } = slices[i];
402 548 const sliceH = end - start;
403 - const canvas = await this.captureSlice(documentContent, start, sliceH, scale);
549 + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
404 550 const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
405 551 const breakMm = this.breakPointsMmForSlice(
406 552 rowBottomsPx,
407 553 start,
@@ -420,8 +566,21 @@
420 566 sliceH,
421 567 padTopCssPx,
422 568 scale
423 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' });
424 583 }
425 584 }
426 585
427 586 // Download the PDF