initial commit
This commit is contained in:
@@ -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 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.
|
||||
Reference in New Issue
Block a user