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

316 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 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:
```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 (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.