PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.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
← All changes | assets/js/document-pdf.js +521 -48 2.1.12.4.0 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,369 @@
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 + }
88 +
89 + /**
90 + * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset.
91 + *
92 + * @param {HTMLElement} el
93 + * @returns {number}
94 + */
95 + getContentPaddingTopPx(el) {
96 + const s = window.getComputedStyle(el);
97 + return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.borderTopWidth) || 0);
98 + }
99 +
100 + /**
101 + * Collect bottom Y positions (px from top of root) for table rows and summary blocks
102 + * so PDF page breaks avoid cutting through a line item row.
103 + *
104 + * @param {HTMLElement} root
105 + * @returns {number[]} Sorted unique pixel positions (content-relative, 0 .. scrollHeight)
106 + */
107 + collectRowBottomsPx(root) {
108 + const rr = root.getBoundingClientRect();
109 + const bottoms = [];
110 +
111 + const addEl = (el) => {
112 + if (!el || !el.getBoundingClientRect) {
113 + return;
114 + }
115 + const r = el.getBoundingClientRect();
116 + const bottom = r.bottom - rr.top + root.scrollTop;
117 + if (bottom > 0) {
118 + bottoms.push(bottom);
119 + }
120 + };
121 +
122 + root.querySelectorAll('table tbody > tr').forEach(addEl);
123 +
124 + root.querySelectorAll(
125 + '.invoice-summary, .quote-summary, [class*="invoice-summary"], [class*="quote-summary"]'
126 + ).forEach(addEl);
127 +
128 + const sorted = [...new Set(bottoms)].sort((a, b) => a - b);
129 + return sorted;
130 + }
131 +
132 + /**
133 + * Build non-overlapping vertical slices [start,end) in px that stay under maxChunkPx
134 + * and end on the nearest row bottom when possible (avoids slicing mid-row in html2canvas).
135 + *
136 + * @param {number} totalHeightPx
137 + * @param {number[]} rowBottomsPx
138 + * @param {number} maxChunkPx
139 + * @returns {Array<{start:number,end:number}>}
140 + */
141 + buildRowAlignedSlices(totalHeightPx, rowBottomsPx, maxChunkPx) {
142 + const slices = [];
143 + let start = 0;
144 + while (start < totalHeightPx) {
145 + let end = Math.min(start + maxChunkPx, totalHeightPx);
146 + const backs = rowBottomsPx.filter((b) => b > start && b <= end);
147 + if (backs.length) {
148 + end = Math.max(...backs);
149 + }
150 + if (end <= start) {
151 + end = Math.min(start + maxChunkPx, totalHeightPx);
152 + }
153 + slices.push({ start, end });
154 + start = end;
155 + }
156 + return slices;
157 + }
158 +
159 + /**
160 + * Row / block bottoms within a vertical slice, expressed in mm from the top of that slice's image.
161 + *
162 + * @param {number[]} rowBottomsPx
163 + * @param {number} sliceStartPx
164 + * @param {number} sliceEndPx
165 + * @param {number} sliceHeightPx
166 + * @param {number} imgHeightMm
167 + * @returns {number[]}
168 + */
169 + breakPointsMmForSlice(rowBottomsPx, sliceStartPx, sliceEndPx, sliceHeightPx, imgHeightMm) {
170 + const pts = rowBottomsPx
171 + .filter((b) => b > sliceStartPx && b <= sliceEndPx)
172 + .map((b) => ((b - sliceStartPx) / sliceHeightPx) * imgHeightMm);
173 + if (imgHeightMm > 0) {
174 + pts.push(imgHeightMm);
175 + }
176 + return [...new Set(pts)].sort((a, b) => a - b);
177 + }
178 +
179 + /**
180 + * Compute html2canvas scale so width/height stay under browser canvas limits.
181 + *
182 + * @param {HTMLElement} el
183 + * @param {number} baseScale
184 + * @returns {number}
185 + */
186 + computeSafeScale(el, baseScale) {
187 + const w = Math.max(1, el.scrollWidth);
188 + const h = Math.max(1, el.scrollHeight);
189 + const maxEdge = this.constructor.MAX_CANVAS_EDGE;
190 + const maxScale = Math.min(maxEdge / w, maxEdge / h, baseScale);
191 + return Math.max(0.2, Math.min(baseScale, maxScale));
192 + }
193 +
194 + /**
195 + * Capture one vertical slice of an element (for very long content).
196 + *
197 + * @param {HTMLElement} element
198 + * @param {number} offsetY
199 + * @param {number} sliceHeight
200 + * @param {number} scale
201 + * @returns {Promise<HTMLCanvasElement>}
202 + */
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);
208 + return html2canvas(element, {
209 + scale,
210 + useCORS: true,
211 + allowTaint: true,
212 + backgroundColor: '#ffffff',
213 + logging: false,
214 + imageTimeout: 15000,
215 + windowWidth: lockedWidth,
216 + width: box.width,
217 + onclone: (_doc, clone) => {
218 + this.frameClone(clone, box);
219 + const node = clone;
220 + node.style.overflow = 'hidden';
221 + node.style.boxSizing = 'border-box';
222 + node.style.marginTop = -offsetY + 'px';
223 + node.style.height = sliceHeight + 'px';
224 + }
225 + });
226 + }
227 +
228 + /**
229 + * Append canvas to PDF with row-aware vertical crops.
230 + * Continuation pages (not at document top) get the same top inset as the HTML root padding — no extra mm margin.
231 + *
232 + * @param {jsPDF} pdf
233 + * @param {HTMLCanvasElement} canvas
234 + * @param {number} pageWidthMm
235 + * @param {number} pageHeightMm
236 + * @param {number[]} breakPointsMm
237 + * @param {boolean} prependPage Start this block on a new PDF page (multi-slice continuation).
238 + * @param {number} fragmentTopPx Y offset of this bitmap’s top within documentContent (0 = full capture).
239 + * @param {number} fragmentHeightPx Layout height (px) this bitmap represents (scrollHeight or slice height).
240 + * @param {number} padTopCssPx padding-top + border-top of documentContent (CSS px).
241 + * @param {number} scale html2canvas scale factor.
242 + */
243 + addImagePagesRowAware(
244 + pdf,
245 + canvas,
246 + pageWidthMm,
247 + pageHeightMm,
248 + breakPointsMm,
249 + prependPage,
250 + fragmentTopPx,
251 + fragmentHeightPx,
252 + padTopCssPx,
253 + scale
254 + ) {
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 + }
261 + const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])]
262 + .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001)
263 + .sort((a, b) => a - b);
264 +
265 + if (prependPage) {
266 + this.constructor.addA4Page(pdf);
267 + }
268 +
269 + let y0mm = 0;
270 + let firstCrop = true;
271 +
272 + while (y0mm < imgHeightMm - 0.0001) {
273 + const limit = y0mm + pageHeightMm;
274 + const inRange = fullBreaks.filter((b) => b > y0mm && b <= limit);
275 + let y1mm;
276 + if (inRange.length) {
277 + y1mm = Math.max(...inRange);
278 + } else {
279 + const nextB = fullBreaks.find((b) => b > y0mm);
280 + if (nextB !== undefined && nextB - y0mm <= pageHeightMm) {
281 + y1mm = nextB;
282 + } else {
283 + y1mm = Math.min(limit, imgHeightMm);
284 + }
285 + }
286 + if (y1mm <= y0mm) {
287 + y1mm = Math.min(y0mm + pageHeightMm, imgHeightMm);
288 + }
289 +
290 + const py0 = (y0mm / imgHeightMm) * canvas.height;
291 + const py1 = (y1mm / imgHeightMm) * canvas.height;
292 + const ch = Math.max(1, Math.round(py1 - py0));
293 +
294 + const crop = document.createElement('canvas');
295 + crop.width = canvas.width;
296 + crop.height = ch;
297 + const ctx = crop.getContext('2d');
298 + ctx.drawImage(canvas, 0, py0, canvas.width, ch, 0, 0, canvas.width, ch);
299 +
300 + /** Document Y (px) at top of this strip; > 0 means below HTML padding — match that inset. */
301 + const docYTop = fragmentTopPx + (py0 / canvas.height) * fragmentHeightPx;
302 + const padTopCanvasPx =
303 + docYTop > 0.5 && padTopCssPx > 0 ? Math.round(padTopCssPx * scale) : 0;
304 +
305 + let outCanvas = crop;
306 + if (padTopCanvasPx > 0) {
307 + const padded = document.createElement('canvas');
308 + padded.width = crop.width;
309 + padded.height = ch + padTopCanvasPx;
310 + const pctx = padded.getContext('2d');
311 + pctx.fillStyle = '#ffffff';
312 + pctx.fillRect(0, 0, padded.width, padded.height);
313 + pctx.drawImage(crop, 0, padTopCanvasPx);
314 + outCanvas = padded;
315 + }
316 +
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');
323 + const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width;
324 +
325 + if (!firstCrop) {
326 + this.constructor.addA4Page(pdf);
327 + }
328 + firstCrop = false;
329 + pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW');
330 +
331 + y0mm = y1mm;
332 + }
333 + }
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 +
51 420 async generatePDF() {
52 421 try {
53 422 // Show loading state
54 423 this.showLoading();
@@ -54,61 +423,165 @@
54 423 this.showLoading();
55 424
56 425 // Find the document content
57 426 const documentContent = document.querySelector('.invoice-content') ||
58 - document.querySelector('.quote-content');
427 + document.querySelector('.quote-content') ||
428 + document.querySelector('.receipt-container');
59 429
60 430 if (!documentContent) {
61 431 throw new Error('Document content not found');
62 432 }
433 + const box = this.captureBox(documentContent);
63 434
64 435 // Get document data for filename
65 436 const documentTitle = document.querySelector('.invoice-title')?.textContent ||
66 437 document.querySelector('.quote-title')?.textContent ||
438 + document.querySelector('.receipt-title')?.textContent ||
67 439 this.documentType;
68 440 const documentNumber = document.querySelector('.invoice-number')?.textContent ||
69 441 document.querySelector('.quote-number')?.textContent ||
442 + document.querySelector('.receipt-number')?.textContent ||
443 + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) ||
70 444 '';
71 445 const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`;
72 446
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 447 const jsPDF = window.jsPDF || window.jspdf?.jsPDF;
87 448 if (!jsPDF) {
88 449 throw new Error('jsPDF library not available');
89 450 }
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 451
101 - // Add first page
102 - pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
103 - heightLeft -= pageHeight;
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;
461 + const pdf = new jsPDF({
462 + orientation: 'p',
463 + unit: 'mm',
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
469 + });
470 + const pageWidthMm = a4.width;
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;
474 + const imgWidthMm = pageWidthMm;
104 475
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;
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;
482 + const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE;
483 + const needsSlices =
484 + totalHeight * baseScale > maxSingleCanvasPx ||
485 + totalWidth * baseScale > maxSingleCanvasPx;
486 +
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 + }
496 +
497 + if (!needsSlices) {
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);
505 + const canvas = await html2canvas(documentContent, {
506 + scale,
507 + useCORS: true,
508 + allowTaint: true,
509 + backgroundColor: '#ffffff',
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)
517 + });
518 + const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
519 + const breakMm = this.breakPointsMmForSlice(
520 + rowBottomsPx,
521 + 0,
522 + totalHeight,
523 + totalHeight,
524 + imgHeightMm
525 + );
526 + this.addImagePagesRowAware(
527 + pdf,
528 + canvas,
529 + imgWidthMm,
530 + pageHeightMm,
531 + breakMm,
532 + false,
533 + 0,
534 + totalHeight,
535 + padTopCssPx,
536 + scale
537 + );
538 + } else {
539 + const sliceCss = this.constructor.SLICE_HEIGHT_CSS;
540 + let scale = this.computeSafeScale(documentContent, baseScale);
541 + scale = Math.min(scale, maxSingleCanvasPx / sliceCss);
542 + scale = Math.max(0.2, scale);
543 +
544 + const slices = this.buildRowAlignedSlices(totalHeight, rowBottomsPx, sliceCss);
545 +
546 + for (let i = 0; i < slices.length; i++) {
547 + const { start, end } = slices[i];
548 + const sliceH = end - start;
549 + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box);
550 + const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width;
551 + const breakMm = this.breakPointsMmForSlice(
552 + rowBottomsPx,
553 + start,
554 + end,
555 + sliceH,
556 + imgHeightMm
557 + );
558 + this.addImagePagesRowAware(
559 + pdf,
560 + canvas,
561 + imgWidthMm,
562 + pageHeightMm,
563 + breakMm,
564 + i > 0,
565 + start,
566 + sliceH,
567 + padTopCssPx,
568 + scale
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' });
583 + }
111 584 }
112 585
113 586 // Download the PDF
114 587 pdf.save(filename);