| @@ -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); |