| @@ -19,25 +19,30 @@ | ||
| 19 | 19 | this.init(); |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | init() { |
| 23 | - // Add html2canvas script if not already loaded | |
| 24 | - if (typeof html2canvas === 'undefined') { | |
| 25 | - this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', () => { | |
| 26 | - this.loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', () => { | |
| 27 | - this.setupPDFButtons(); | |
| 28 | - }); | |
| 29 | - }); | |
| 30 | - } else { | |
| 31 | - this.setupPDFButtons(); | |
| 23 | + // html2canvas and jsPDF are bundled with the plugin and loaded ahead of | |
| 24 | + // this file by whoever renders the page (PdfHelper::enqueuePdfScripts(), | |
| 25 | + // AdminAssets, or an explicit <script> in the single/preview templates). | |
| 26 | + // | |
| 27 | + // This used to inject both libraries from cdnjs when they were missing. | |
| 28 | + // That is no longer permitted — WordPress.org requires every asset to | |
| 29 | + // ship inside the plugin — so a missing library is now a setup error we | |
| 30 | + // report rather than something we silently fetch over the network. | |
| 31 | + // Same resolution the render path uses below — the UMD build exposes | |
| 32 | + // window.jspdf.jsPDF, older/standalone builds expose window.jsPDF. | |
| 33 | + const hasJsPdf = !!(window.jsPDF || (window.jspdf && window.jspdf.jsPDF)); | |
| 34 | + | |
| 35 | + if (typeof html2canvas === 'undefined' || !hasJsPdf) { | |
| 36 | + console.error( | |
| 37 | + 'Easy Invoice: PDF generation unavailable — html2canvas and/or jsPDF ' + | |
| 38 | + 'were not loaded before document-pdf.js. Enqueue the "html2canvas" and ' + | |
| 39 | + '"jspdf" handles (or include assets/js/vendors/) on this page.' | |
| 40 | + ); | |
| 41 | + return; | |
| 32 | 42 | } |
| 33 | - } | |
| 34 | 43 | |
| 35 | - loadScript(src, callback) { | |
| 36 | - const script = document.createElement('script'); | |
| 37 | - script.src = src; | |
| 38 | - script.onload = callback; | |
| 39 | - document.head.appendChild(script); | |
| 44 | + this.setupPDFButtons(); | |
| 40 | 45 | } |
| 41 | 46 | |
| 42 | 47 | setupPDFButtons() { |
| 43 | 48 | // Find all PDF download buttons |
| @@ -194,12 +199,13 @@ | ||
| 194 | 199 | * @param {number} sliceHeight |
| 195 | 200 | * @param {number} scale |
| 196 | 201 | * @returns {Promise<HTMLCanvasElement>} |
| 197 | 202 | */ |
| 198 | - captureSlice(element, offsetY, sliceHeight, scale) { | |
| 203 | + captureSlice(element, offsetY, sliceHeight, scale, box) { | |
| 199 | 204 | // Lock the capture viewport to the element's own layout width so |
| 200 | 205 | // responsive CSS doesn't kick in and shrink columns mid-capture. |
| 201 | - const lockedWidth = Math.max(1, element.scrollWidth); | |
| 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); | |
| 202 | 208 | return html2canvas(element, { |
| 203 | 209 | scale, |
| 204 | 210 | useCORS: true, |
| 205 | 211 | allowTaint: true, |
| @@ -206,10 +212,11 @@ | ||
| 206 | 212 | backgroundColor: '#ffffff', |
| 207 | 213 | logging: false, |
| 208 | 214 | imageTimeout: 15000, |
| 209 | 215 | windowWidth: lockedWidth, |
| 210 | - width: lockedWidth, | |
| 216 | + width: box.width, | |
| 211 | 217 | onclone: (_doc, clone) => { |
| 218 | + this.frameClone(clone, box); | |
| 212 | 219 | const node = clone; |
| 213 | 220 | node.style.overflow = 'hidden'; |
| 214 | 221 | node.style.boxSizing = 'border-box'; |
| 215 | 222 | node.style.marginTop = -offsetY + 'px'; |
| @@ -244,9 +251,14 @@ | ||
| 244 | 251 | fragmentHeightPx, |
| 245 | 252 | padTopCssPx, |
| 246 | 253 | scale |
| 247 | 254 | ) { |
| 248 | - const imgHeightMm = (canvas.height * pageWidthMm) / canvas.width; | |
| 255 | + let imgHeightMm = (canvas.height * pageWidthMm) / canvas.width; | |
| 256 | + // A sheet that is one page tall bar a rounding hair must not spill a | |
| 257 | + // blank sliver onto a second page. | |
| 258 | + if (imgHeightMm > pageHeightMm && imgHeightMm - pageHeightMm < 3) { | |
| 259 | + imgHeightMm = pageHeightMm; | |
| 260 | + } | |
| 249 | 261 | const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])] |
| 250 | 262 | .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001) |
| 251 | 263 | .sort((a, b) => a - b); |
| 252 | 264 | |
| @@ -319,8 +331,93 @@ | ||
| 319 | 331 | y0mm = y1mm; |
| 320 | 332 | } |
| 321 | 333 | } |
| 322 | 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 | + | |
| 323 | 420 | async generatePDF() { |
| 324 | 421 | try { |
| 325 | 422 | // Show loading state |
| 326 | 423 | this.showLoading(); |
| @@ -332,8 +429,9 @@ | ||
| 332 | 429 | |
| 333 | 430 | if (!documentContent) { |
| 334 | 431 | throw new Error('Document content not found'); |
| 335 | 432 | } |
| 433 | + const box = this.captureBox(documentContent); | |
| 336 | 434 | |
| 337 | 435 | // Get document data for filename |
| 338 | 436 | const documentTitle = document.querySelector('.invoice-title')?.textContent || |
| 339 | 437 | document.querySelector('.quote-title')?.textContent || |
| @@ -341,8 +439,9 @@ | ||
| 341 | 439 | this.documentType; |
| 342 | 440 | const documentNumber = document.querySelector('.invoice-number')?.textContent || |
| 343 | 441 | document.querySelector('.quote-number')?.textContent || |
| 344 | 442 | document.querySelector('.receipt-number')?.textContent || |
| 443 | + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) || | |
| 345 | 444 | ''; |
| 346 | 445 | const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`; |
| 347 | 446 | |
| 348 | 447 | const jsPDF = window.jsPDF || window.jspdf?.jsPDF; |
| @@ -350,12 +449,20 @@ | ||
| 350 | 449 | throw new Error('jsPDF library not available'); |
| 351 | 450 | } |
| 352 | 451 | |
| 353 | 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; | |
| 354 | 461 | const pdf = new jsPDF({ |
| 355 | 462 | orientation: 'p', |
| 356 | 463 | unit: 'mm', |
| 357 | - format: 'a4', | |
| 464 | + format: singlePage ? [a4.width, Math.max(a4.height, contentHeightMm)] : 'a4', | |
| 358 | 465 | // FlateDecode-compress the embedded bitmaps so a high-resolution |
| 359 | 466 | // capture doesn't bloat the file to 20MB. |
| 360 | 467 | compress: true, |
| 361 | 468 | precision: 4 |
| @@ -360,9 +467,11 @@ | ||
| 360 | 467 | compress: true, |
| 361 | 468 | precision: 4 |
| 362 | 469 | }); |
| 363 | 470 | const pageWidthMm = a4.width; |
| 364 | - const pageHeightMm = a4.height; | |
| 471 | + // A4 pages keep a strip clear at the foot for the page label. | |
| 472 | + const FOOTER_MM = 9; | |
| 473 | + const pageHeightMm = singlePage ? Math.max(a4.height, contentHeightMm) : a4.height - FOOTER_MM; | |
| 365 | 474 | const imgWidthMm = pageWidthMm; |
| 366 | 475 | |
| 367 | 476 | // 2× the CSS pixel grid — matches retina display density, so text |
| 368 | 477 | // edges stay crisp instead of soft. (Previous 1.5× produced the |
| @@ -367,23 +476,33 @@ | ||
| 367 | 476 | // 2× the CSS pixel grid — matches retina display density, so text |
| 368 | 477 | // edges stay crisp instead of soft. (Previous 1.5× produced the |
| 369 | 478 | // "PDF quality is bad" customer report.) |
| 370 | 479 | const baseScale = 2; |
| 371 | - const totalHeight = documentContent.scrollHeight; | |
| 372 | - const totalWidth = documentContent.scrollWidth; | |
| 480 | + const totalHeight = box.height; | |
| 481 | + const totalWidth = box.width; | |
| 373 | 482 | const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE; |
| 374 | 483 | const needsSlices = |
| 375 | 484 | totalHeight * baseScale > maxSingleCanvasPx || |
| 376 | 485 | totalWidth * baseScale > maxSingleCanvasPx; |
| 377 | 486 | |
| 378 | - const rowBottomsPx = this.collectRowBottomsPx(documentContent); | |
| 379 | - const padTopCssPx = this.getContentPaddingTopPx(documentContent); | |
| 487 | + let rowBottomsPx = this.collectRowBottomsPx(documentContent); | |
| 488 | + let padTopCssPx = this.getContentPaddingTopPx(documentContent); | |
| 489 | + if (box.card) { | |
| 490 | + // Rows were measured on the live page; in the framed clone the | |
| 491 | + // card starts FRAME_PX below the top of the capture. | |
| 492 | + const shift = box.pad - (box.card.getBoundingClientRect().top - documentContent.getBoundingClientRect().top); | |
| 493 | + rowBottomsPx = rowBottomsPx.map((b) => b + shift); | |
| 494 | + padTopCssPx = box.pad; | |
| 495 | + } | |
| 380 | 496 | |
| 381 | 497 | if (!needsSlices) { |
| 382 | 498 | const scale = this.computeSafeScale(documentContent, baseScale); |
| 383 | 499 | // Lock the capture viewport to the element's own layout width |
| 384 | 500 | // so responsive media queries don't shrink the design mid-capture. |
| 385 | - const lockedWidth = Math.max(1, documentContent.scrollWidth); | |
| 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); | |
| 386 | 505 | const canvas = await html2canvas(documentContent, { |
| 387 | 506 | scale, |
| 388 | 507 | useCORS: true, |
| 389 | 508 | allowTaint: true, |
| @@ -390,9 +509,12 @@ | ||
| 390 | 509 | backgroundColor: '#ffffff', |
| 391 | 510 | logging: false, |
| 392 | 511 | imageTimeout: 15000, |
| 393 | 512 | windowWidth: lockedWidth, |
| 394 | - width: lockedWidth | |
| 513 | + windowHeight: window.innerHeight, | |
| 514 | + width: box.width, | |
| 515 | + height: box.height, | |
| 516 | + onclone: (_doc, clone) => this.frameClone(clone, box) | |
| 395 | 517 | }); |
| 396 | 518 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 397 | 519 | const breakMm = this.breakPointsMmForSlice( |
| 398 | 520 | rowBottomsPx, |
| @@ -423,9 +545,9 @@ | ||
| 423 | 545 | |
| 424 | 546 | for (let i = 0; i < slices.length; i++) { |
| 425 | 547 | const { start, end } = slices[i]; |
| 426 | 548 | const sliceH = end - start; |
| 427 | - const canvas = await this.captureSlice(documentContent, start, sliceH, scale); | |
| 549 | + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box); | |
| 428 | 550 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 429 | 551 | const breakMm = this.breakPointsMmForSlice( |
| 430 | 552 | rowBottomsPx, |
| 431 | 553 | start, |
| @@ -444,8 +566,21 @@ | ||
| 444 | 566 | sliceH, |
| 445 | 567 | padTopCssPx, |
| 446 | 568 | scale |
| 447 | 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' }); | |
| 448 | 583 | } |
| 449 | 584 | } |
| 450 | 585 | |
| 451 | 586 | // Download the PDF |