initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,315 @@
---
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.