--- 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 { 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(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); } }; ``` ### 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.