| @@ -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,9 +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) { | |
| 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); | |
| 199 | 225 | return html2canvas(element, { |
| 200 | 226 | scale, |
| 201 | 227 | useCORS: true, |
| 202 | 228 | allowTaint: true, |
| @@ -201,9 +227,13 @@ | ||
| 201 | 227 | useCORS: true, |
| 202 | 228 | allowTaint: true, |
| 203 | 229 | backgroundColor: '#ffffff', |
| 204 | 230 | logging: false, |
| 231 | + imageTimeout: 15000, | |
| 232 | + windowWidth: lockedWidth, | |
| 233 | + width: box.width, | |
| 205 | 234 | onclone: (_doc, clone) => { |
| 235 | + this.frameClone(clone, box); | |
| 206 | 236 | const node = clone; |
| 207 | 237 | node.style.overflow = 'hidden'; |
| 208 | 238 | node.style.boxSizing = 'border-box'; |
| 209 | 239 | node.style.marginTop = -offsetY + 'px'; |
| @@ -238,9 +268,14 @@ | ||
| 238 | 268 | fragmentHeightPx, |
| 239 | 269 | padTopCssPx, |
| 240 | 270 | scale |
| 241 | 271 | ) { |
| 242 | - 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 | + } | |
| 243 | 278 | const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])] |
| 244 | 279 | .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001) |
| 245 | 280 | .sort((a, b) => a - b); |
| 246 | 281 | |
| @@ -295,9 +330,14 @@ | ||
| 295 | 330 | pctx.drawImage(crop, 0, padTopCanvasPx); |
| 296 | 331 | outCanvas = padded; |
| 297 | 332 | } |
| 298 | 333 | |
| 299 | - const cropData = outCanvas.toDataURL('image/jpeg', 0.92); | |
| 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'); | |
| 300 | 340 | const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width; |
| 301 | 341 | |
| 302 | 342 | if (!firstCrop) { |
| 303 | 343 | this.constructor.addA4Page(pdf); |
| @@ -302,14 +342,99 @@ | ||
| 302 | 342 | if (!firstCrop) { |
| 303 | 343 | this.constructor.addA4Page(pdf); |
| 304 | 344 | } |
| 305 | 345 | firstCrop = false; |
| 306 | - pdf.addImage(cropData, 'JPEG', 0, 0, pageWidthMm, segMm); | |
| 346 | + pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW'); | |
| 307 | 347 | |
| 308 | 348 | y0mm = y1mm; |
| 309 | 349 | } |
| 310 | 350 | } |
| 311 | 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 | + | |
| 312 | 437 | async generatePDF() { |
| 313 | 438 | try { |
| 314 | 439 | // Show loading state |
| 315 | 440 | this.showLoading(); |
| @@ -321,8 +446,9 @@ | ||
| 321 | 446 | |
| 322 | 447 | if (!documentContent) { |
| 323 | 448 | throw new Error('Document content not found'); |
| 324 | 449 | } |
| 450 | + const box = this.captureBox(documentContent); | |
| 325 | 451 | |
| 326 | 452 | // Get document data for filename |
| 327 | 453 | const documentTitle = document.querySelector('.invoice-title')?.textContent || |
| 328 | 454 | document.querySelector('.quote-title')?.textContent || |
| @@ -330,8 +456,9 @@ | ||
| 330 | 456 | this.documentType; |
| 331 | 457 | const documentNumber = document.querySelector('.invoice-number')?.textContent || |
| 332 | 458 | document.querySelector('.quote-number')?.textContent || |
| 333 | 459 | document.querySelector('.receipt-number')?.textContent || |
| 460 | + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) || | |
| 334 | 461 | ''; |
| 335 | 462 | const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`; |
| 336 | 463 | |
| 337 | 464 | const jsPDF = window.jsPDF || window.jspdf?.jsPDF; |
| @@ -339,36 +466,80 @@ | ||
| 339 | 466 | throw new Error('jsPDF library not available'); |
| 340 | 467 | } |
| 341 | 468 | |
| 342 | 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; | |
| 343 | 478 | const pdf = new jsPDF({ |
| 344 | 479 | orientation: 'p', |
| 345 | 480 | unit: 'mm', |
| 346 | - format: 'a4' | |
| 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 | |
| 347 | 486 | }); |
| 487 | + this.constructor.paintPageWhite(pdf); | |
| 348 | 488 | const pageWidthMm = a4.width; |
| 349 | - 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); | |
| 350 | 499 | const imgWidthMm = pageWidthMm; |
| 351 | 500 | |
| 352 | - const baseScale = 1.5; | |
| 353 | - const totalHeight = documentContent.scrollHeight; | |
| 354 | - const totalWidth = documentContent.scrollWidth; | |
| 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; | |
| 355 | 507 | const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE; |
| 356 | 508 | const needsSlices = |
| 357 | 509 | totalHeight * baseScale > maxSingleCanvasPx || |
| 358 | 510 | totalWidth * baseScale > maxSingleCanvasPx; |
| 359 | 511 | |
| 360 | - const rowBottomsPx = this.collectRowBottomsPx(documentContent); | |
| 361 | - 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 | + } | |
| 362 | 521 | |
| 363 | 522 | if (!needsSlices) { |
| 364 | 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); | |
| 365 | 530 | const canvas = await html2canvas(documentContent, { |
| 366 | 531 | scale, |
| 367 | 532 | useCORS: true, |
| 368 | 533 | allowTaint: true, |
| 369 | 534 | backgroundColor: '#ffffff', |
| 370 | - logging: false | |
| 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) | |
| 371 | 542 | }); |
| 372 | 543 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 373 | 544 | const breakMm = this.breakPointsMmForSlice( |
| 374 | 545 | rowBottomsPx, |
| @@ -399,9 +570,9 @@ | ||
| 399 | 570 | |
| 400 | 571 | for (let i = 0; i < slices.length; i++) { |
| 401 | 572 | const { start, end } = slices[i]; |
| 402 | 573 | const sliceH = end - start; |
| 403 | - const canvas = await this.captureSlice(documentContent, start, sliceH, scale); | |
| 574 | + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box); | |
| 404 | 575 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 405 | 576 | const breakMm = this.breakPointsMmForSlice( |
| 406 | 577 | rowBottomsPx, |
| 407 | 578 | start, |
| @@ -420,8 +591,21 @@ | ||
| 420 | 591 | sliceH, |
| 421 | 592 | padTopCssPx, |
| 422 | 593 | scale |
| 423 | 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' }); | |
| 424 | 608 | } |
| 425 | 609 | } |
| 426 | 610 | |
| 427 | 611 | // Download the PDF |