| @@ -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 |
| @@ -78,11 +83,28 @@ | ||
| 78 | 83 | * @param {Object} pdf jsPDF instance |
| 79 | 84 | */ |
| 80 | 85 | static addA4Page(pdf) { |
| 81 | 86 | pdf.addPage('a4', 'p'); |
| 87 | + this.paintPageWhite(pdf); | |
| 82 | 88 | } |
| 83 | 89 | |
| 84 | 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 | + /** | |
| 85 | 107 | * Padding-top + border-top of the invoice/quote root (CSS px), matching visible HTML inset. |
| 86 | 108 | * |
| 87 | 109 | * @param {HTMLElement} el |
| 88 | 110 | * @returns {number} |
| @@ -194,12 +216,13 @@ | ||
| 194 | 216 | * @param {number} sliceHeight |
| 195 | 217 | * @param {number} scale |
| 196 | 218 | * @returns {Promise<HTMLCanvasElement>} |
| 197 | 219 | */ |
| 198 | - captureSlice(element, offsetY, sliceHeight, scale) { | |
| 220 | + captureSlice(element, offsetY, sliceHeight, scale, box) { | |
| 199 | 221 | // Lock the capture viewport to the element's own layout width so |
| 200 | 222 | // responsive CSS doesn't kick in and shrink columns mid-capture. |
| 201 | - const lockedWidth = Math.max(1, element.scrollWidth); | |
| 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); | |
| 202 | 225 | return html2canvas(element, { |
| 203 | 226 | scale, |
| 204 | 227 | useCORS: true, |
| 205 | 228 | allowTaint: true, |
| @@ -206,10 +229,11 @@ | ||
| 206 | 229 | backgroundColor: '#ffffff', |
| 207 | 230 | logging: false, |
| 208 | 231 | imageTimeout: 15000, |
| 209 | 232 | windowWidth: lockedWidth, |
| 210 | - width: lockedWidth, | |
| 233 | + width: box.width, | |
| 211 | 234 | onclone: (_doc, clone) => { |
| 235 | + this.frameClone(clone, box); | |
| 212 | 236 | const node = clone; |
| 213 | 237 | node.style.overflow = 'hidden'; |
| 214 | 238 | node.style.boxSizing = 'border-box'; |
| 215 | 239 | node.style.marginTop = -offsetY + 'px'; |
| @@ -244,9 +268,14 @@ | ||
| 244 | 268 | fragmentHeightPx, |
| 245 | 269 | padTopCssPx, |
| 246 | 270 | scale |
| 247 | 271 | ) { |
| 248 | - const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width; | |
| 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 | + } | |
| 249 | 278 | const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])] |
| 250 | 279 | .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001) |
| 251 | 280 | .sort((a, b) => a - b); |
| 252 | 281 | |
| @@ -319,8 +348,93 @@ | ||
| 319 | 348 | y0mm = y1mm; |
| 320 | 349 | } |
| 321 | 350 | } |
| 322 | 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 | + | |
| 323 | 437 | async generatePDF() { |
| 324 | 438 | try { |
| 325 | 439 | // Show loading state |
| 326 | 440 | this.showLoading(); |
| @@ -332,8 +446,9 @@ | ||
| 332 | 446 | |
| 333 | 447 | if (!documentContent) { |
| 334 | 448 | throw new Error('Document content not found'); |
| 335 | 449 | } |
| 450 | + const box = this.captureBox(documentContent); | |
| 336 | 451 | |
| 337 | 452 | // Get document data for filename |
| 338 | 453 | const documentTitle = document.querySelector('.invoice-title')?.textContent || |
| 339 | 454 | document.querySelector('.quote-title')?.textContent || |
| @@ -341,8 +456,9 @@ | ||
| 341 | 456 | this.documentType; |
| 342 | 457 | const documentNumber = document.querySelector('.invoice-number')?.textContent || |
| 343 | 458 | document.querySelector('.quote-number')?.textContent || |
| 344 | 459 | document.querySelector('.receipt-number')?.textContent || |
| 460 | + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) || | |
| 345 | 461 | ''; |
| 346 | 462 | const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`; |
| 347 | 463 | |
| 348 | 464 | const jsPDF = window.jsPDF || window.jspdf?.jsPDF; |
| @@ -350,19 +466,37 @@ | ||
| 350 | 466 | throw new Error('jsPDF library not available'); |
| 351 | 467 | } |
| 352 | 468 | |
| 353 | 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; | |
| 354 | 478 | const pdf = new jsPDF({ |
| 355 | 479 | orientation: 'p', |
| 356 | 480 | unit: 'mm', |
| 357 | - format: 'a4', | |
| 481 | + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4', | |
| 358 | 482 | // FlateDecode-compress the embedded bitmaps so a high-resolution |
| 359 | 483 | // capture doesn't bloat the file to 20MB. |
| 360 | 484 | compress: true, |
| 361 | 485 | precision: 4 |
| 362 | 486 | }); |
| 487 | + this.constructor.paintPageWhite(pdf); | |
| 363 | 488 | const pageWidthMm = a4.width; |
| 364 | - const pageHeightMm = a4.height; | |
| 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); | |
| 365 | 499 | const imgWidthMm = pageWidthMm; |
| 366 | 500 | |
| 367 | 501 | // 2× the CSS pixel grid — matches retina display density, so text |
| 368 | 502 | // edges stay crisp instead of soft. (Previous 1.5× produced the |
| @@ -367,23 +501,33 @@ | ||
| 367 | 501 | // 2× the CSS pixel grid — matches retina display density, so text |
| 368 | 502 | // edges stay crisp instead of soft. (Previous 1.5× produced the |
| 369 | 503 | // "PDF quality is bad" customer report.) |
| 370 | 504 | const baseScale = 2; |
| 371 | - const totalHeight = documentContent.scrollHeight; | |
| 372 | - const totalWidth = documentContent.scrollWidth; | |
| 505 | + const totalHeight = box.height; | |
| 506 | + const totalWidth = box.width; | |
| 373 | 507 | const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE; |
| 374 | 508 | const needsSlices = |
| 375 | 509 | totalHeight * baseScale > maxSingleCanvasPx || |
| 376 | 510 | totalWidth * baseScale > maxSingleCanvasPx; |
| 377 | 511 | |
| 378 | - const rowBottomsPx = this.collectRowBottomsPx(documentContent); | |
| 379 | - const padTopCssPx = this.getContentPaddingTopPx(documentContent); | |
| 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 | + } | |
| 380 | 521 | |
| 381 | 522 | if (!needsSlices) { |
| 382 | 523 | const scale = this.computeSafeScale(documentContent, baseScale); |
| 383 | 524 | // Lock the capture viewport to the element's own layout width |
| 384 | 525 | // so responsive media queries don't shrink the design mid-capture. |
| 385 | - const lockedWidth = Math.max(1, documentContent.scrollWidth); | |
| 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); | |
| 386 | 530 | const canvas = await html2canvas(documentContent, { |
| 387 | 531 | scale, |
| 388 | 532 | useCORS: true, |
| 389 | 533 | allowTaint: true, |
| @@ -390,9 +534,12 @@ | ||
| 390 | 534 | backgroundColor: '#ffffff', |
| 391 | 535 | logging: false, |
| 392 | 536 | imageTimeout: 15000, |
| 393 | 537 | windowWidth: lockedWidth, |
| 394 | - width: lockedWidth | |
| 538 | + windowHeight: window.innerHeight, | |
| 539 | + width: box.width, | |
| 540 | + height: box.height, | |
| 541 | + onclone: (_doc, clone) => this.frameClone(clone, box) | |
| 395 | 542 | }); |
| 396 | 543 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 397 | 544 | const breakMm = this.breakPointsMmForSlice( |
| 398 | 545 | rowBottomsPx, |
| @@ -423,9 +570,9 @@ | ||
| 423 | 570 | |
| 424 | 571 | for (let i = 0; i < slices.length; i++) { |
| 425 | 572 | const { start, end } = slices[i]; |
| 426 | 573 | const sliceH = end - start; |
| 427 | - const canvas = await this.captureSlice(documentContent, start, sliceH, scale); | |
| 574 | + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box); | |
| 428 | 575 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 429 | 576 | const breakMm = this.breakPointsMmForSlice( |
| 430 | 577 | rowBottomsPx, |
| 431 | 578 | start, |
| @@ -444,8 +591,21 @@ | ||
| 444 | 591 | sliceH, |
| 445 | 592 | padTopCssPx, |
| 446 | 593 | scale |
| 447 | 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' }); | |
| 448 | 608 | } |
| 449 | 609 | } |
| 450 | 610 | |
| 451 | 611 | // Download the PDF |