| @@ -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,9 +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) { | |
| 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); | |
| 199 | 208 | return html2canvas(element, { |
| 200 | 209 | scale, |
| 201 | 210 | useCORS: true, |
| 202 | 211 | allowTaint: true, |
| @@ -201,9 +210,13 @@ | ||
| 201 | 210 | useCORS: true, |
| 202 | 211 | allowTaint: true, |
| 203 | 212 | backgroundColor: '#ffffff', |
| 204 | 213 | logging: false, |
| 214 | + imageTimeout: 15000, | |
| 215 | + windowWidth: lockedWidth, | |
| 216 | + width: box.width, | |
| 205 | 217 | onclone: (_doc, clone) => { |
| 218 | + this.frameClone(clone, box); | |
| 206 | 219 | const node = clone; |
| 207 | 220 | node.style.overflow = 'hidden'; |
| 208 | 221 | node.style.boxSizing = 'border-box'; |
| 209 | 222 | node.style.marginTop = -offsetY + 'px'; |
| @@ -238,9 +251,14 @@ | ||
| 238 | 251 | fragmentHeightPx, |
| 239 | 252 | padTopCssPx, |
| 240 | 253 | scale |
| 241 | 254 | ) { |
| 242 | - 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 | + } | |
| 243 | 261 | const fullBreaks = [...new Set([0, ...breakPointsMm.filter((b) => b > 0 && b <= imgHeightMm), imgHeightMm])] |
| 244 | 262 | .filter((v, i, a) => i === 0 || v > a[i - 1] + 0.0001) |
| 245 | 263 | .sort((a, b) => a - b); |
| 246 | 264 | |
| @@ -295,9 +313,14 @@ | ||
| 295 | 313 | pctx.drawImage(crop, 0, padTopCanvasPx); |
| 296 | 314 | outCanvas = padded; |
| 297 | 315 | } |
| 298 | 316 | |
| 299 | - const cropData = outCanvas.toDataURL('image/jpeg', 0.92); | |
| 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'); | |
| 300 | 323 | const segMm = (outCanvas.height * pageWidthMm) / outCanvas.width; |
| 301 | 324 | |
| 302 | 325 | if (!firstCrop) { |
| 303 | 326 | this.constructor.addA4Page(pdf); |
| @@ -302,14 +325,99 @@ | ||
| 302 | 325 | if (!firstCrop) { |
| 303 | 326 | this.constructor.addA4Page(pdf); |
| 304 | 327 | } |
| 305 | 328 | firstCrop = false; |
| 306 | - pdf.addImage(cropData, 'JPEG', 0, 0, pageWidthMm, segMm); | |
| 329 | + pdf.addImage(cropData, 'PNG', 0, 0, pageWidthMm, segMm, undefined, 'SLOW'); | |
| 307 | 330 | |
| 308 | 331 | y0mm = y1mm; |
| 309 | 332 | } |
| 310 | 333 | } |
| 311 | 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 | + | |
| 312 | 420 | async generatePDF() { |
| 313 | 421 | try { |
| 314 | 422 | // Show loading state |
| 315 | 423 | this.showLoading(); |
| @@ -321,8 +429,9 @@ | ||
| 321 | 429 | |
| 322 | 430 | if (!documentContent) { |
| 323 | 431 | throw new Error('Document content not found'); |
| 324 | 432 | } |
| 433 | + const box = this.captureBox(documentContent); | |
| 325 | 434 | |
| 326 | 435 | // Get document data for filename |
| 327 | 436 | const documentTitle = document.querySelector('.invoice-title')?.textContent || |
| 328 | 437 | document.querySelector('.quote-title')?.textContent || |
| @@ -330,8 +439,9 @@ | ||
| 330 | 439 | this.documentType; |
| 331 | 440 | const documentNumber = document.querySelector('.invoice-number')?.textContent || |
| 332 | 441 | document.querySelector('.quote-number')?.textContent || |
| 333 | 442 | document.querySelector('.receipt-number')?.textContent || |
| 443 | + (window.easyInvoiceDocument && window.easyInvoiceDocument.number) || | |
| 334 | 444 | ''; |
| 335 | 445 | const filename = `${documentTitle}-${documentNumber}-${new Date().toISOString().split('T')[0]}.pdf`; |
| 336 | 446 | |
| 337 | 447 | const jsPDF = window.jsPDF || window.jspdf?.jsPDF; |
| @@ -339,36 +449,72 @@ | ||
| 339 | 449 | throw new Error('jsPDF library not available'); |
| 340 | 450 | } |
| 341 | 451 | |
| 342 | 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; | |
| 343 | 461 | const pdf = new jsPDF({ |
| 344 | 462 | orientation: 'p', |
| 345 | 463 | unit: 'mm', |
| 346 | - format: 'a4' | |
| 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 | |
| 347 | 469 | }); |
| 348 | 470 | const pageWidthMm = a4.width; |
| 349 | - 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; | |
| 350 | 474 | const imgWidthMm = pageWidthMm; |
| 351 | 475 | |
| 352 | - const baseScale = 1.5; | |
| 353 | - const totalHeight = documentContent.scrollHeight; | |
| 354 | - const totalWidth = documentContent.scrollWidth; | |
| 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; | |
| 355 | 482 | const maxSingleCanvasPx = this.constructor.MAX_CANVAS_EDGE; |
| 356 | 483 | const needsSlices = |
| 357 | 484 | totalHeight * baseScale > maxSingleCanvasPx || |
| 358 | 485 | totalWidth * baseScale > maxSingleCanvasPx; |
| 359 | 486 | |
| 360 | - const rowBottomsPx = this.collectRowBottomsPx(documentContent); | |
| 361 | - 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 | + } | |
| 362 | 496 | |
| 363 | 497 | if (!needsSlices) { |
| 364 | 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); | |
| 365 | 505 | const canvas = await html2canvas(documentContent, { |
| 366 | 506 | scale, |
| 367 | 507 | useCORS: true, |
| 368 | 508 | allowTaint: true, |
| 369 | 509 | backgroundColor: '#ffffff', |
| 370 | - logging: false | |
| 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) | |
| 371 | 517 | }); |
| 372 | 518 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 373 | 519 | const breakMm = this.breakPointsMmForSlice( |
| 374 | 520 | rowBottomsPx, |
| @@ -399,9 +545,9 @@ | ||
| 399 | 545 | |
| 400 | 546 | for (let i = 0; i < slices.length; i++) { |
| 401 | 547 | const { start, end } = slices[i]; |
| 402 | 548 | const sliceH = end - start; |
| 403 | - const canvas = await this.captureSlice(documentContent, start, sliceH, scale); | |
| 549 | + const canvas = await this.captureSlice(documentContent, start, sliceH, scale, box); | |
| 404 | 550 | const imgHeightMm = (canvas.height * imgWidthMm) / canvas.width; |
| 405 | 551 | const breakMm = this.breakPointsMmForSlice( |
| 406 | 552 | rowBottomsPx, |
| 407 | 553 | start, |
| @@ -420,8 +566,21 @@ | ||
| 420 | 566 | sliceH, |
| 421 | 567 | padTopCssPx, |
| 422 | 568 | scale |
| 423 | 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' }); | |
| 424 | 583 | } |
| 425 | 584 | } |
| 426 | 585 | |
| 427 | 586 | // Download the PDF |