---
name: jspdf-pdf-export
description: 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.

```typescript
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:

```typescript
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 45–55pt 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:
```typescript
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:

```typescript
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**:
```bash
npm install pdfjs-dist
```

**Worker configuration** in your app entry point (`main.tsx`):
```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:
```typescript
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. `computeQuoteTotals` → `billableServices`), 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

```typescript
// 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

```typescript
// 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.

### 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 (45–55pt).

## 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 (45–55pt).

## 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.
