Files
hermes-config/skills/software-development/jspdf-pdf-export/SKILL.md
T
2026-07-12 10:17:17 -04:00

14 KiB
Raw Blame History

name, description
name description
jspdf-pdf-export Generate, download, print, and export multi-page PDFs as images in Vite/React/TypeScript apps using jsPDF + pdfjs-dist — PDF construction, page-break logic, and PNG conversion.

jsPDF PDF Export

Generate professional multi-page PDFs (quotes, invoices, repair orders) and optionally export every page as a separate PNG image.

When to Load

  • Building a PDF generator with jsPDF in a Vite/React/TypeScript app
  • Adding "Download PDF", "Print", or "Export as Images" buttons
  • Converting PDF pages to PNG images client-side
  • Setting up pdfjs-dist in a Vite environment

Key Techniques

1. Multi-Page PDF Construction (jsPDF)

Page units: Use 'pt' (points) for pixel-precise layout. Letter = 612×792 pt.

const doc = new jsPDF('p', 'pt', 'letter');
const PAGE_W = 612, PAGE_H = 792, MARGIN = 40;
const CW = PAGE_W - MARGIN * 2;  // content width
const CONTENT_BOTTOM = PAGE_H - MARGIN;

Page-break logic — measure content height before drawing, add a new page if it won't fit:

function newPage(doc: jsPDF, currentY: number): number {
  doc.addPage();
  return MARGIN;  // reset Y to top of new page
}

// Before drawing a block, check:
if (y + estimatedBlockHeight > CONTENT_BOTTOM - FOOTER_HEIGHT) {
  y = newPage(doc, y);
  // Redraw section header if needed
}

Footers — draw on every page via drawFooter(), using doc.setPage(pageNum) to target each one. The doc.getNumberOfPages() call at the end tells you the total.

Footer layout — structure the footer area with clear vertical zones. Define a FOOTER_HEIGHT constant that accommodates all elements plus inter-line gaps; values between 4555pt work for most layouts. Content page-break guards check CONTENT_BOTTOM - FOOTER_HEIGHT, so the footer zone starts at that boundary:

y = PAGE_H - MARGIN - FOOTER_HEIGHT    (footer zone begins, e.g. 702pt on letter)
  ┌─ Payment terms      (y ≈ +4)    — last page only, right-aligned
  ├─ Separator line      (y ≈ +12)
  ├─ Footer message      (y ≈ +27)   — centered
  ├─ Quote note / expiry  (y ≈ +39)  — centered
  └─ Page number         (y = PAGE_H - 10)

Example setup:

const CONTENT_BOTTOM = PAGE_H - MARGIN; // 752
const FOOTER_HEIGHT = 50;               // reserved for all footer elements
// Content page-break threshold: CONTENT_BOTTOM - FOOTER_HEIGHT (702)

Payment terms in footer — render on the last page only, right-aligned, above the separator. This keeps them visible regardless of content page count:

if (pageNum === totalPages && settings.paymentTerms) {
  const label = 'Payment Terms: ';
  const full = label + settings.paymentTerms;
  const py = PAGE_H - MARGIN - FOOTER_HEIGHT + 4;
  doc.setTextColor(...MG); doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
  doc.text(full, PAGE_W - MARGIN, py, { align: 'right' });
}

2. PDF → PNG Conversion (pdfjs-dist)

Install:

npm install pdfjs-dist

Worker configuration in your app entry point (main.tsx):

import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();

Dual-input pattern — accept either a jsPDF instance or a Blob in the image-export function, so callers can pass the result of generateQuotePDF() (which returns a Blob) without rebuilding the doc:

export async function downloadPdfAsImages(
  pdfInput: jsPDF | Blob,
  customerName: string,
): Promise<void> {
  let arrayBuffer: ArrayBuffer;
  if (pdfInput instanceof Blob) {
    arrayBuffer = await pdfInput.arrayBuffer();
  } else {
    arrayBuffer = pdfInput.output('arraybuffer');
  }

  const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;

  for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
    const page = await pdf.getPage(pageNum);
    const viewport = page.getViewport({ scale: 2 });  // 2× for Retina

    const canvas = document.createElement('canvas');
    canvas.width = viewport.width;
    canvas.height = viewport.height;
    const ctx = canvas.getContext('2d')!;

    await page.render({ canvasContext: ctx, viewport }).promise;

    const blob = await new Promise<Blob>(resolve =>
      canvas.toBlob(b => resolve(b!), 'image/png'),
    );

    // Trigger download
    const fileName = `${customerName.replace(/\s+/g, '_')}_Page${pageNum}.png`;
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = fileName;
    document.body.appendChild(a); a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    page.cleanup();
  }
}

3. UI Integration

Import the functions and add a button that generates the PDF in-memory then calls the image export:

import { downloadQuotePDF, printQuotePDF, generateQuotePDF } from '../lib/pdf';
import { downloadPdfAsImages } from '../lib/pdf-to-images';
import { Image } from 'lucide-react';

const handlePdf = async (mode: 'download' | 'print' | 'images') => {
  setGenerating(true);
  try {
    if (mode === 'download') await downloadQuotePDF(quote, settings);
    else if (mode === 'print') await printQuotePDF(quote, settings);
    else {
      const blob = await generateQuotePDF(quote, settings);
      await downloadPdfAsImages(blob, customerInfo.name);
    }
  } finally {
    setGenerating(false);
  }
};

<button onClick={() => handlePdf('images')} disabled={generating}>
  <Image className="h-4 w-4" /> Export as Images
</button>

4. Settings-Aware PDF Customization

When your PDF quote generator reads from a ShopSettings object, use these patterns:

Hex color for accents — parse user-chosen hex colors safely:

type ColorTuple = [number, number, number];

function hexToRgb(hex: string): ColorTuple {
  if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return [22, 163, 74]; // fallback green
  const c = hex.replace('#', '');
  const r = parseInt(c.substring(0, 2), 16);
  const g = parseInt(c.substring(2, 4), 16);
  const b = parseInt(c.substring(4, 6), 16);
  if (isNaN(r) || isNaN(g) || isNaN(b)) return [22, 163, 74];
  return [r, g, b];
}

// Usage:
const ACCENT = settings.pdfAccentColor ? hexToRgb(settings.pdfAccentColor) : GR;
// Then use ACCENT wherever you'd use the hardcoded accent color

Dynamic expiry in footer — use a {days} placeholder so settings override works naturally:

const qfn = (settings.quoteFooterNote || 'This quote is valid for {days} days from the date above.')
  .replace('{days}', String(settings.quoteExpiryDays || 30));

Custom tax label — pass through from settings:

doc.text(settings.taxLabel || 'Tax', sumX, y);

Payment terms — render in the footer on the last page only (see "Footer layout" above for the recommended pattern). Avoid rendering payment terms inline after the pricing summary — that position is vulnerable to page-break boundary overlap and won't survive multi-page documents.

Logo on page 1 — render from a base64 data-URL when configured:

if (settings.logoUrl && settings.showLogoOnPdf) {
  doc.addImage(settings.logoUrl, 'PNG', MARGIN, y, 50, 20);
  y += 24; // image height + gap
}

Corollary: DEFAULT_SETTINGS must include {days} in the default footer note. If the default quoteFooterNote doesn't contain {days}, the expiry feature silently does nothing for users who never customize the footer. Always set:

quoteFooterNote: 'This quote is valid for {days} days from the date above.',

5. PDF Construction Helpers

For professional-looking PDFs:

  • Color constants as tuples: type ColorTuple = [number, number, number];
  • Footer: separator line + message + page number (Page X of Y)
  • Rounded rectangles: doc.roundedRect(x, y, w, h, r, r, 'FD') for fill + stroke
  • Text wrapping: doc.splitTextToSize(text, maxWidth) returns an array of lines
  • Dynamic import for code-splitting: Use static imports when the module is already pulled in by other exports (prevents Vite's INEFFECTIVE_DYNAMIC_IMPORT warning)

Multi-Layer Data Flow (UI vs PDF Discrepancy)

When the same computed values (totals, subtotals, taxes) appear in both the UI component and the PDF generator, they often have independent computation paths through different helper functions. This creates a class of bug: the UI shows correct numbers but the PDF shows different ones, or vice versa.

Root cause pattern: UI components call helper A (e.g. computeQuoteTotalsbillableServices), while the PDF generator has its own inline computation or calls the same helper through a different chain. If the helper has context-sensitive filtering (e.g., filters out pending services when mixed), the PDF and UI can diverge without any explicit bug in either.

Fix pattern — extract shared computation:

  1. Identify the exact computation both layers need (e.g., "total of all non-declined services with discount")
  2. Create a dedicated helper function in a shared lib module that both the UI and the PDF import
  3. Give the helper a name that describes the specific view it computes, not a generic one — e.g. computeCombinedTotals or computeSplitQuoteTotals, not a modified computeQuoteTotals
  4. Bypass intermediate filtering functions in the shared helper when they'd suppress data the other layer needs
// BAD: UI calls computeQuoteTotals → billableServices (filters out pending when mixed)
// PDF calls computeQuoteTotals → billableServices (same filter, same wrong result for "if all approved")

// GOOD: dedicated helper that computes on all non-declined services
export function computeCombinedTotals(services, discount, settings) {
  const allNonDeclined = services.filter(s => s.customerDecision !== 'declined');
  // ... compute totals directly, no billableServices filter ...
}

Verification: Generate a PDF with mixed-status services and compare the numbers against the UI display. If they differ, trace the computation chain in each layer — they're almost certainly diverging at a filter function.

Filter-Context Pitfall (billableServices Pattern)

A filtering function that's correct for one presentation context can silently break a different one:

  • Customer-facing quote: Only show totals for what the customer has already approved → filter out pending services
  • Internal "what if" estimate: Show what everything would cost if approved → include ALL non-declined services

Fix: Don't reuse the filtered result variable (servicesTotal computed from billableIds) for the "what if" view. Compute the combined total separately with a purpose-built function that doesn't share the filter context. Use computeSplitQuoteTotals() to get approved, pending, and combined breakdowns from a single call.

Dynamic Pricing Summary Layout

When the PDF pricing summary needs to show different layouts based on data (split totals for mixed-status services vs simple totals for uniform services):

  1. Compute all values first using a shared helper, before any drawing
  2. Test for the layout condition (e.g., hasMixed = approved && pending services exist)
  3. Render conditionally — one branch for the full split layout, another for the simple layout
  4. Account for row count in page-break height calculations — the split layout is significantly taller
// Compute upfront
let sumRows = 0;
if (hasMixed) {
  sumRows += 4 + 4 + 1; // approved + pending + combined sections
  if (discount > 0) sumRows++;
  sumRows += 2; // separator + grand total
} else {
  sumRows = 1; // subtotal
  if (discount > 0) sumRows++;
  if (shopCharge > 0) sumRows++;
  if (tax > 0) sumRows++;
  sumRows += 2; // separator + total
}
const sumTotalH = bp + (sumRows * lineHeight + padding) + bp;
// Use sumTotalH for page-break check and rect sizing

Content Overflow Past the Page-Break Threshold

Every block drawn after the pricing summary — shop charge explanation, travel fee note, warranty disclaimer — must have its own page-break guard. Unguarded text can extend y past CONTENT_BOTTOM - FOOTER_HEIGHT and overlap the footer zone. Even though drawFooter() redraws on top, the overlap causes visual artifacts. The pattern: measure the block before drawing, add a page if it won't fit before the footer zone.

If FOOTER_HEIGHT < 35pt on a letter page, payment terms position at PAGE_H - MARGIN - FOOTER_HEIGHT - 2 falls inside the content zone rather than the footer zone, making it vulnerable to content overlap. Always set FOOTER_HEIGHT to the tallest possible footer stack plus a few points of breathing room (4555pt).

Pitfalls (legacy — consolidated above)

  • pdfjs-dist worker: If GlobalWorkerOptions.workerSrc isn't set, pdf.js blocks the main thread on each getDocument(). The new URL(..., import.meta.url) Vite pattern copies the worker as a static asset.
  • canvas.toBlob null: Usually means the canvas was tainted by cross-origin data. In pdfjs this shouldn't happen since we're rendering from a same-origin ArrayBuffer.
  • Font selection: jsPDF's built-in fonts are limited. For extended character sets (VINs with special chars, non-Latin text), use doc.addFileToVFS() and doc.addFont() to embed custom fonts.
  • Page number discrepancy: doc.getNumberOfPages() must be called AFTER all content is written. Footer loops should happen last.
  • Content overflow past the page-break threshold: Every block drawn after the pricing summary — shop charge explanation, travel fee note, warranty disclaimer — must have its own page-break guard. Unguarded text can extend y past CONTENT_BOTTOM - FOOTER_HEIGHT and overlap the footer zone. Even though drawFooter() redraws on top, the overlap causes visual artifacts. The pattern: measure the block before drawing, add a page if it won't fit before the footer zone.
  • FOOTER_HEIGHT too small: If FOOTER_HEIGHT < 35pt on a letter page, payment terms position at PAGE_H - MARGIN - FOOTER_HEIGHT - 2 falls inside the content zone rather than the footer zone, making it vulnerable to content overlap. Always set FOOTER_HEIGHT to the tallest possible footer stack plus a few points of breathing room (4555pt).

Verification

  1. Build: npx tsc --noEmit && npx vite build
  2. Check console for INEFFECTIVE_DYNAMIC_IMPORT warnings — prefer static imports
  3. In the browser, verify download triggers N files for N pages

Reference Files

  • references/pdf-to-images-full-code.md — The complete downloadPdfAsImages implementation with full JSDoc, error handling, and memory cleanup.