I make pet sympathy cards as a small side thing — usually I start from
AI-generated artwork that I refine, then print and fold the card at home.
The repetitive part was always the same: lay out 4 finished images
(front, back, inside-left, inside-right) onto a landscape page in the right
fold order, then babysit my printer over duplex alignment.
I'd been doing this in Affinity Designer for every card. Eventually got
tired enough to build a small browser tool that does it for me.
The tool is live at foldcardpdf.com. It's
free, runs entirely in the browser (no backend, no account, no uploads —
images never leave your machine), and includes a duplex-drift compensation
feature I'll explain at the end.
This post is about the technical decisions behind building it.
Why pdf-lib
Three real options in JS-land for generating PDFs in the browser:
| Library | Notes |
|---|---|
| pdf-lib | Pure JS, no native deps, ~330KB minified+gzipped. Mature API. Good docs. |
| jsPDF | Older. Less consistent API. Heavier bundle for what we needed. |
| PDFKit | Designed for Node first; browser port works but feels awkward. |
I went with pdf-lib because the API for embedding images and drawing them at
specific (x, y, w, h) positions on a page is exactly the primitive a
fold-card-layout tool needs:
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([paperWidth, paperHeight]);
const img = await pdfDoc.embedJpg(jpegBytes);
page.drawImage(img, {
x: panelX, y: panelY,
width: panelWidth, height: panelHeight,
});
Nothing more clever than that. The whole PDF generator is maybe 200 lines.
The image resize trick
Source images are often huge — a phone photo is easily 4000×3000px. If you
just embed that into the PDF at 5×7 inches × 300 DPI (1500×2100px), pdf-lib
will happily put the full original image in the PDF, ballooning the file size
to 30+ MB.
I downsample to the target resolution before embedding, using a hidden
<canvas>:
async function downsample(file: File, targetW: number, targetH: number) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(targetW, targetH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, targetW, targetH);
const blob = await canvas.convertToBlob({
type: 'image/jpeg',
quality: 0.92,
});
return new Uint8Array(await blob.arrayBuffer());
}
OffscreenCanvas is the win here — it doesn't block the main thread.
Output PDFs are typically 1–3 MB instead of 30+ MB.
The duplex-drift problem (the part I'm proud of)
Most home duplex printers have a small but consistent offset between the
front and back of the page when printing both sides — typically 1–3mm in
either X or Y. For most documents nobody notices. For a fold card where
the inside has to line up exactly with the outside fold, that offset is
the difference between "looks store-bought" and "obviously homemade."
The fix is a one-time calibration step:
- Print an alignment test PDF (just crosshairs on the front, same crosshairs on the back).
- Hold it up to a light. Measure how far the back crosshair has drifted from the front, in mm.
- Type
dxanddyinto the tool. - Every PDF afterwards translates the inside page by
(-dx, -dy)to compensate.
The implementation is one line in the layout code:
const insidePanelX = basePanelX - (drift.dx * pxPerMm);
const insidePanelY = basePanelY - (drift.dy * pxPerMm);
But the idea of doing it at all is the part that took me longest. I'd
been blaming the printer for years.
What it cost to ship
- pdf-lib: 330KB
- React + ReactDOM: ~140KB (gzipped)
- App code: ~25KB
- Total bundle: under 600KB gzipped
Hosted as a static site on Cloudflare Pages — free tier, $0 to run
regardless of traffic.
Try it
foldcardpdf.com — free, no signup, no upload.
Drop in images, get a PDF.
Open to feedback, especially if you've shipped browser-only tools and have
hard-won opinions on the bundle size / streaming PDF gen tradeoffs I
glossed over here.
Top comments (0)