14 KiB
name, description
| name | description |
|---|---|
| jspdf-pdf-export | 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.
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:
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:
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:
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:
npm install pdfjs-dist
Worker configuration in your app entry point (main.tsx):
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:
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:
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:
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:
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:
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:
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:
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_IMPORTwarning)
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:
- Identify the exact computation both layers need (e.g., "total of all non-declined services with discount")
- Create a dedicated helper function in a shared lib module that both the UI and the PDF import
- Give the helper a name that describes the specific view it computes, not a generic one — e.g.
computeCombinedTotalsorcomputeSplitQuoteTotals, not a modifiedcomputeQuoteTotals - Bypass intermediate filtering functions in the shared helper when they'd suppress data the other layer needs
// 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):
- Compute all values first using a shared helper, before any drawing
- Test for the layout condition (e.g.,
hasMixed = approved && pending services exist) - Render conditionally — one branch for the full split layout, another for the simple layout
- Account for row count in page-break height calculations — the split layout is significantly taller
// 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.workerSrcisn't set, pdf.js blocks the main thread on eachgetDocument(). Thenew 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()anddoc.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
ypastCONTENT_BOTTOM - FOOTER_HEIGHTand overlap the footer zone. Even thoughdrawFooter()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 < 35pton a letter page, payment terms position atPAGE_H - MARGIN - FOOTER_HEIGHT - 2falls inside the content zone rather than the footer zone, making it vulnerable to content overlap. Always setFOOTER_HEIGHTto the tallest possible footer stack plus a few points of breathing room (45–55pt).
Verification
- Build:
npx tsc --noEmit && npx vite build - Check console for
INEFFECTIVE_DYNAMIC_IMPORTwarnings — prefer static imports - In the browser, verify download triggers N files for N pages
Reference Files
references/pdf-to-images-full-code.md— The completedownloadPdfAsImagesimplementation with full JSDoc, error handling, and memory cleanup.