PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.4
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.4
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 +546 -48 2.1.13 → 2.4.4 View file →
@@ -8,8 +8,11 @@
8 8
9 9 (function() {
10 10 'use strict';
11 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 +
12 15 // Global Document PDF Generator Class
13 16 window.DocumentPdfGenerator = class DocumentPdfGenerator {
14 17 constructor(documentType = 'invoice') {
15 18 this.documentType = documentType; // 'invoice' or 'quote'
@@ -16,25 +19,30 @@
16 19 this.init();
17 20 }
18 21
19 22 init() {
20 - // Add html2canvas script if not already loaded
21 - if (typeof html2canvas === 'undefined') {
22 - this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', () => {
23 - this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', () => {
24 - this.setupPDFButtons();
25 - });
26 - });
27 - } else {
28 - 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;
29 42 }
30 - }
31 43
32 - loadScript(src, callback) {
33 - const script = document.createElement('script');
34 - script.src = src;
35 - script.onload = callback;
36 - document.head.appendChild(script);
44 + this.setupPDFButtons();
37 45 }
38 46
39 47 setupPDFButtons() {
40 48 // Find all PDF download buttons
@@ -47,8 +55,386 @@
47 55 });
48 56 });
49 57 }
50 58
59 + /**
60 + * Max canvas edge length (px) — browsers cap canvas size; long invoices exceed this in one shot.
61 + */
62 + static get MAX_CANVAS_EDGE() {
63 + return 10000;
64 + }
65 +
66 + /**
67 + * Vertical slice height (CSS px) per html2canvas capture when content is very tall.
68 + */
69 + static get SLICE_HEIGHT_CSS() {
70 + return 3200;
71 + }
72 +
73 + /**
74 + * @returns {{width:number,height:number}} A4 size in mm (portrait).
75 + */
76 + static getA4SizeMm() {
77 + return { width: A4_MM.width, height: A4_MM.height };
78 + }
79 +
80 + /**
81 + * Append an A4 portrait page (matches initial document format).
82 + *
83 + * @param {Object} pdf jsPDF instance
84 + */
85 + static addA4Page(pdf) {
86 + pdf.addPage('a4', 'p');
87 + this.paintPageWhite(pdf);
88 + }
89 +
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 + /**
107 + * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset.
108 + *
109 + * @param {HTMLElement} el
110 + * @returns {number}
111 + */
112 + getContentPaddingTopPx(el) {
113 + const s = window.getComputedStyle(el);
114 + return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.borderTopWidth) || 0);
115 + }
116 +
117 + /**
118 + * Collect bottom Y positions (px from top of root) for table rows and summary blocks
119 + * so PDF page breaks avoid cutting through a line item row.
120 + *
121 + * @param {HTMLElement} root
122 + * @returns {number[]} Sorted unique pixel positions (content-relative, 0 .. scrollHeight)
123 + */
124 + collectRowBottomsPx(root) {
125 + const rr = root.getBoundingClientRect();
126 + const bottoms = [];
127 +
128 + const addEl = (el) => {
129 + if (!el || !el.getBoundingClientRect) {
130 + return;
131 + }
132 + const r = el.getBoundingClientRect();
133 + const bottom = r.bottom - rr.top + root.scrollTop;
134 + if (bottom > 0) {
135 + bottoms.push(bottom);
136 + }
137 + };
138 +
139 + root.querySelectorAll('table tbody > tr').forEach(addEl);
140 +
141 + root.querySelectorAll(
142 + '.invoice-summary, .quote-summary, [class*="invoice-summary"], [class*="quote-summary"]'
143 + ).forEach(addEl);
144 +
145 + const sorted = [...new Set(bottoms)].sort((a, b) => a - b);
146 + return sorted;
147 + }
148 +
149 + /**
150 + * Build non-overlapping vertical slices [start,end) in px that stay under maxChunkPx
151 + * and end on the nearest row bottom when possible (avoids slicing mid-row in html2canvas).
152 + *
153 + * @param {number} totalHeightPx
154 + * @param {number[]} rowBottomsPx
155 + * @param {number} maxChunkPx
156 + * @returns {Array<{start:number,end:number}>}
157 + */
158 + buildRowAlignedSlices(totalHeightPx, rowBottomsPx, maxChunkPx) {
159 + const slices = [];
160 + let start = 0;
161 + while (start < totalHeightPx) {
162 + let end = Math.min(start + maxChunkPx, totalHeightPx);
163 + const backs = rowBottomsPx.filter((b) => b > start && b <= end);
164 + if (backs.length) {
165 + end = Math.max(...backs);
166 + }
167 + if (end <= start) {
168 + end = Math.min(start + maxChunkPx, totalHeightPx);
169 + }
170 + slices.push({ start, end });
171 + start = end;
172 + }
173 + return slices;
174 + }
175 +
176 + /**
177 + * Row / block bottoms within a vertical slice, expressed in mm from the top of that slice's image.
178 + *
179 + * @param {number[]} rowBottomsPx
180 + * @param {number} sliceStartPx
181 + * @param {number} sliceEndPx
182 + * @param {number} sliceHeightPx
183 + * @param {number} imgHeightMm
184 + * @returns {number[]}
185 + */
186 + breakPointsMmForSlice(rowBottomsPx, sliceStartPx, sliceEndPx, sliceHeightPx, imgHeightMm) {
187 + const pts = rowBottomsPx
188 + .filter((b) => b > sliceStartPx && b <= sliceEndPx)
189 + .map((b) => ((b - sliceStartPx) / sliceHeightPx) * imgHeightMm);
190 + if (imgHeightMm > 0) {
191 + pts.push(imgHeightMm);
192 + }
193 + return [...new Set(pts)].sort((a, b) => a - b);
194 + }
195 +
196 + /**
197 + * Compute html2canvas scale so width/height stay under browser canvas limits.
198 + *
199 + * @param {HTMLElement} el
200 + * @param {number} baseScale
201 + * @returns {number}
202 + */
203 + computeSafeScale(el, baseScale) {
204 + const w = Math.max(1, el.scrollWidth);
205 + const h = Math.max(1, el.scrollHeight);
206 + const maxEdge = this.constructor.MAX_CANVAS_EDGE;
207 + const maxScale = Math.min(maxEdge / w, maxEdge / h, baseScale);
208 + return Math.max(0.2, Math.min(baseScale, maxScale));
209 + }
210 +
211 + /**
212 + * Capture one vertical slice of an element (for very long content).
213 + *
214 + * @param {HTMLElement} element
215 + * @param {number} offsetY
216 + * @param {number} sliceHeight
217 + * @param {number} scale
218 + * @returns {Promise<HTMLCanvasElement>}
219 + */
220 + captureSlice(element, offsetY, sliceHeight, scale, box) {
221 + // Lock the capture viewport to the element's own layout width so
222 + // responsive CSS doesn't kick in and shrink columns mid-capture.
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);
225 + return html2canvas(element, {
226 + scale,
227 + useCORS: true,
228 + allowTaint: true,
229 + backgroundColor: '#ffffff',
230 + logging: false,
231 + imageTimeout: 15000,
232 + windowWidth: lockedWidth,
233 + width: box.width,
234 + onclone: (_doc, clone) => {
235 + this.frameClone(clone, box);
236 + const node = clone;
237 + node.style.overflow = 'hidden';
238 + node.style.boxSizing = 'border-box';
239 + node.style.marginTop = -offsetY + 'px';
240 + node.style.height = sliceHeight + 'px';
241 + }
242 + });
243 + }
244 +
245 + /**
246 + * Append canvas to PDF with row-aware vertical crops.
247 + * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin.
248 + *
249 + * @param {jsPDF} pdf
250 + * @param {HTMLCanvasElement} canvas
251 + * @param {number} pageWidthMm
252 + * @param {number} pageHeightMm
253 + * @param {number[]} breakPointsMm
254 + * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation).
255 + * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture).
256 + * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height).
257 + * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px).
258 + * @param {number} scale html2canvas scale factor.
259 + */
260 + addImagePagesRowAware(
261 + pdf,
262 + canvas,
263 + pageWidthMm,
264 + pageHeightMm,
265 + breakPointsMm,
266 + prependPage,
267 + fragmentTopPx,
268 + fragmentHeightPx,
269 + padTopCssPx,
270 + scale
271 + ) {
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 + }
278 + const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
279 + .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
280 + .sort((a, b) => a - b);
281 +
282 + if (prependPage) {
283 + this.constructor.addA4Page(pdf);
284 + }
285 +
286 + let y0mm = 0;
287 + let firstCrop = true;
288 +
289 + while (y0mm < imgHeightMm - 0.0001) {
290 + const limit = y0mm + pageHeightMm;
291 + const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit);
292 + let y1mm;
293 + if (inRange.length) {
294 + y1mm = Math.max(...inRange);
295 + } else {
296 + const nextB = fullBreaks.find((b) => b > y0mm);
297 + if (nextB !== undefined && nextB - y0mm <= pageHeightMm) {
298 + y1mm = nextB;
299 + } else {
300 + y1mm = Math.min(limit, imgHeightMm);
301 + }
302 + }
303 + if (y1mm <= y0mm) {
304 + y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm);
305 + }
306 +
307 + const py0 = (y0mm / imgHeightMm) * canvas.height;
308 + const py1 = (y1mm / imgHeightMm) * canvas.height;
309 + const ch = Math.max(1, Math.round(py1 - py0));
310 +
311 + const crop = document.createElement('canvas');
312 + crop.width = canvas.width;
313 + crop.height = ch;
314 + const ctx = crop.getContext('2d');
315 + ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch);
316 +
317 + /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */
318 + const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx;
319 + const padTopCanvasPx =
320 + docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0;
321 +
322 + let outCanvas = crop;
323 + if (padTopCanvasPx > 0) {
324 + const padded = document.createElement('canvas');
325 + padded.width = crop.width;
326 + padded.height = ch + padTopCanvasPx;
327 + const pctx = padded.getContext('2d');
328 + pctx.fillStyle = '#ffffff';
329 + pctx.fillRect(0, 0, padded.width, padded.height);
330 + pctx.drawImage(crop, 0, padTopCanvasPx);
331 + outCanvas = padded;
332 + }
333 +
334 + // PNG is lossless — invoices are mostly text + tables, where
335 + // JPEG produces fuzzy edges and colour-fringe around glyphs.
336 + // The 'SLOW' compression flag tells jsPDF to use the better
337 + // (slower) inflate algorithm so the embedded bitmap retains
338 + // its full resolution.
339 + const cropData = outCanvas.toDataURL('image/png');
340 + const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
341 +
342 + if (!firstCrop) {
343 + this.constructor.addA4Page(pdf);
344 + }
345 + firstCrop = false;
346 + pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW');
347 +
348 + y0mm = y1mm;
349 + }
350 + }
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 +
51 437 async generatePDF() {
52 438 try {
53 439 // Show loading state
54 440 this.showLoading();
@@ -54,61 +440,173 @@
54 440 this.showLoading();
55 441
56 442 // Find the document content
57 443 const documentContent = document.querySelector('.invoice-content') ||
58 - document.querySelector('.quote-content');
444 + document.querySelector('.quote-content') ||
445 + document.querySelector('.receipt-container');
59 446
60 447 if (!documentContent) {
61 448 throw new Error('Document content not found');
62 449 }
450 + const box = this.captureBox(documentContent);
63 451
64 452 // Get document data for filename
65 453 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
66 454 document.querySelector('.quote-title')?.textContent ||
455 + document.querySelector('.receipt-title')?.textContent ||
67 456 this.documentType;
68 457 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
69 458 document.querySelector('.quote-number')?.textContent ||
459 + document.querySelector('.receipt-number')?.textContent ||
460 + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
70 461 '';
71 462 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
72 463
73 - // Simple html2canvas configuration
74 - const canvas = await html2canvas(documentContent, {
75 - scale: 1.5,
76 - useCORS: true,
77 - allowTaint: true,
78 - backgroundColor: '#ffffff',
79 - logging: false
80 - });
81 -
82 - // Convert canvas to image
83 - const imgData = canvas.toDataURL('image/jpeg');
84 -
85 - // Create PDF
86 464 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
87 465 if (!jsPDF) {
88 466 throw new Error('jsPDF library not available');
89 467 }
90 -
91 - const pdf = new jsPDF('p', 'mm', 'a4');
92 -
93 - // Calculate dimensions
94 - const imgWidth = 210; // A4 width in mm
95 - const pageHeight = 295; // A4 height in mm
96 - const imgHeight = (canvas.height * imgWidth) / canvas.width;
97 -
98 - let heightLeft = imgHeight;
99 - let position = 0;
100 468
101 - // Add first page
102 - pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
103 - heightLeft -= pageHeight;
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;
478 + const pdf = new jsPDF({
479 + orientation: 'p',
480 + unit: 'mm',
481 + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4',
482 + // FlateDecode-compress the embedded bitmaps so a high-resolution
483 + // capture doesn't bloat the file to 20MB.
484 + compress: true,
485 + precision: 4
486 + });
487 + this.constructor.paintPageWhite(pdf);
488 + const pageWidthMm = a4.width;
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);
499 + const imgWidthMm = pageWidthMm;
104 500
105 - // Add additional pages if content is longer than one page
106 - while (heightLeft >= 0) {
107 - position = heightLeft - imgHeight;
108 - pdf.addPage();
109 - pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
110 - heightLeft -= pageHeight;
501 + // 2× the CSS pixel grid — matches retina display density, so text
502 + // edges stay crisp instead of soft. (Previous 1.5× produced the
503 + // "PDF quality is bad" customer report.)
504 + const baseScale = 2;
505 + const totalHeight = box.height;
506 + const totalWidth = box.width;
507 + const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
508 + const needsSlices =
509 + totalHeight * baseScale > maxSingleCanvasPx ||
510 + totalWidth * baseScale > maxSingleCanvasPx;
511 +
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 + }
521 +
522 + if (!needsSlices) {
523 + const scale = this.computeSafeScale(documentContent, baseScale);
524 + // Lock the capture viewport to the element's own layout width
525 + // so responsive media queries don't shrink the design mid-capture.
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);
530 + const canvas = await html2canvas(documentContent, {
531 + scale,
532 + useCORS: true,
533 + allowTaint: true,
534 + backgroundColor: '#ffffff',
535 + logging: false,
536 + imageTimeout: 15000,
537 + windowWidth: lockedWidth,
538 + windowHeight: window.innerHeight,
539 + width: box.width,
540 + height: box.height,
541 + onclone: (_doc, clone) => this.frameClone(clone, box)
542 + });
543 + const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
544 + const breakMm = this.breakPointsMmForSlice(
545 + rowBottomsPx,
546 + 0,
547 + totalHeight,
548 + totalHeight,
549 + imgHeightMm
550 + );
551 + this.addImagePagesRowAware(
552 + pdf,
553 + canvas,
554 + imgWidthMm,
555 + pageHeightMm,
556 + breakMm,
557 + false,
558 + 0,
559 + totalHeight,
560 + padTopCssPx,
561 + scale
562 + );
563 + } else {
564 + const sliceCss = this.constructor.SLICE_HEIGHT_CSS;
565 + let scale = this.computeSafeScale(documentContent, baseScale);
566 + scale = Math.min(scale, maxSingleCanvasPx / sliceCss);
567 + scale = Math.max(0.2, scale);
568 +
569 + const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss);
570 +
571 + for (let i = 0; i < slices.length; i++) {
572 + const { start, end } = slices[i];
573 + const sliceH = end - start;
574 + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
575 + const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
576 + const breakMm = this.breakPointsMmForSlice(
577 + rowBottomsPx,
578 + start,
579 + end,
580 + sliceH,
581 + imgHeightMm
582 + );
583 + this.addImagePagesRowAware(
584 + pdf,
585 + canvas,
586 + imgWidthMm,
587 + pageHeightMm,
588 + breakMm,
589 + i > 0,
590 + start,
591 + sliceH,
592 + padTopCssPx,
593 + scale
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' });
608 + }
111 609 }
112 610
113 611 // Download the PDF
114 612 pdf.save(filename);