runlocally

runlocally engineering notes

PDF to Image

How PDF to Image is built

By Geppetto · · Open PDF to Image →

These are the engineering notes for PDF to Image: the technologies it is built on, what each one is, and how it is used in the tool.

Tech used

Rendering a page, not copying one

The Split PDF notes describe a PDF as an object graph — pages that reference shared fonts, images, and content streams — and both Split PDF and Merge PDF work by copying and reassembling those objects. This tool doesn’t touch the object graph at all. It needs a picture of each page: the same pixels a PDF reader would put on screen, as a standalone PNG or JPG. That’s a different operation — rasterizing a page’s content stream (the sequence of drawing instructions: “move to, line to, fill, show this glyph at this position…”) onto a fixed grid of pixels — and pdf-lib, which never draws anything, can’t do it. The library that can is pdf.js.

pdf.js

pdf.js is Mozilla’s PDF engine, written in JavaScript and published as the pdfjs-dist package; it’s also what renders PDFs inside Firefox itself. pdfjs.getDocument({ data }).promise parses the file and returns a document handle; doc.getPage(n) returns a page object, and page.getViewport({ scale }) computes the pixel dimensions that page would occupy at a given scale. page.render({ canvasContext, viewport }).promise then executes that page’s content stream, painting text, paths, and images onto whatever 2D canvas context you hand it. That rendered canvas is this tool’s output — it’s read back into a PNG or JPG a few lines later.

Parsing happens off the main thread: pdf.js ships its own worker script (pdf.worker.min.mjs), and GlobalWorkerOptions.workerSrc points at it. All the CPU-heavy work — decompressing streams, interpreting fonts, decoding embedded images — happens there; only the final paint onto a canvas happens on the page’s own thread, which is comparatively cheap.

The Canvas API

A <canvas> element is a raw pixel surface; canvas.getContext('2d') gives you a CanvasRenderingContext2D with drawing primitives (fillRect, drawImage, and so on) — pdf.js’s page.render() is really just a long, automated sequence of calls into that same API. Once a page is painted, canvas.toBlob(callback, mimeType, quality) reads the pixel buffer back out and encodes it — PNG (lossless) or JPEG (with a quality argument) — producing the Blob this tool downloads or zips up. Nothing here needs the DOM beyond a canvas element that’s never actually inserted into the page.

Resolution: DPI as a viewport scale

pdf.js’s scale isn’t a resolution — it’s a multiplier on the PDF’s own unit, the point (1/72 inch). At scale: 1, a page comes out at exactly 1 pixel per point, i.e. 72 pixels per inch. So a DPI value is just scale = dpi / 72: 300 DPI (a reasonable print/scan quality) is scale ≈ 4.17, meaning a US Letter page (612×792 points) rasterizes to roughly 2550×3300 pixels. The tool exposes DPI directly, because it’s the one setting that decides whether the output is a quick on-screen preview or something worth printing.

pdf.js’s lazily-fetched assets: fonts, cmaps, and optional WASM codecs

Beyond the worker script, pdf.js can fetch three more asset sets at runtime, each passed to getDocument() as a directory-prefix option rather than bundled inline:

  • standardFontDataUrl — glyph metrics for the 14 standard PDF fonts (Helvetica, Times, …), needed whenever a PDF references one of those fonts without embedding its actual program — extremely common, since embedding fonts makes files bigger.
  • cMapUrl (+ cMapPacked: true) — character maps for non-Latin embedded font encodings (CJK text, mainly).
  • wasmUrl — optional WebAssembly codecs (JBIG2, OpenJPEG/JPX, and qcms for ICC color) for embedded images using those encodings, most often found in scanned documents. This is a different WASM story than the HEIC notes: instead of one WASM decoder loaded up front for every file, pdf.js only fetches whichever of these three small modules a given PDF’s embedded images actually need.

Skip all three and pdf.js still renders the page — it just logs a warning and drops or degrades whatever needed the missing asset (garbled or missing glyphs, a blank spot where a scanned image should be).

Shell and output format

The static Astro + Preact island and Service-Worker PWA shell are the same as the other tools (introduced in the HEIC notes). Rendering more than one page zips them with @zip.js/zip.js, the same ZIP engine introduced in the Create ZIP notes — a ZipWriter over a BlobWriter, fed each page’s Blob directly via BlobReader.

Implementation & operational notes

A ?url import, not a bundled Worker. pdf.js’s worker is loaded as import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url', a static-asset import Vite resolves to a same-origin, content-hashed file — not a Worker Vite builds and code-splits itself. That distinction matters: it’s the pattern that makes GlobalWorkerOptions.workerSrc = workerUrl work at all under a bundler, verified by confirming the file still exists at that path in the installed package rather than assuming an older example still applies.

The asset-prefix problem. wasmUrl, cMapUrl, and standardFontDataUrl each want a stable directory URL, because pdf.js appends a filename it decides at runtime (${wasmUrl}jbig2.wasm, ${cMapUrl}${name}.bcmap, …). A bundler’s ?url import gives back one specific, individually-hashed file URL — useful for the worker script, useless for a directory prefix. The fix here is a small postinstall script that copies the wasm/, cmaps/, and standard_fonts/ folders straight out of the installed pdfjs-dist into a fixed path under this tool’s own public/, served as ordinary same-origin static files (and left out of git — it’s a build artifact regenerated from whatever pdfjs-dist version is installed, not source to track). Loading any of it from a CDN would also contradict the no-external-requests point of the tool, so bundling it same-origin was the only option worth considering anyway. This is easy to skip entirely and still have a tool that appears to work — the fixture used in this tool’s own end-to-end tests turned out, on inspection, to reference /BaseFont /Helvetica without embedding it, so it silently exercises exactly this path; the fix was verified by capturing full console output (not just errors) during a real render and confirming nothing was missing or warned about.

Encrypted PDFs, verified rather than assumed. Calling getDocument() against a password-protected file — without an onPassword callback — rejects with an error whose .name is PasswordException (.code === 1 for “no password given” specifically); a corrupt or non-PDF file instead rejects with InvalidPDFException. Both were confirmed with a throwaway script against a real encrypted fixture before any UI code was written, then mapped to distinct, localized, user-facing messages — never the library’s own English exception text.

No Worker for the render step itself. page.render() needs a CanvasRenderingContext2D, which a plain Web Worker doesn’t have; getting one would mean routing through OffscreenCanvas plumbing pdf.js doesn’t expose cleanly for this. In practice this is fine: pdf.js’s own worker already does the expensive part (parsing, decompression, font and color-space work), and the main-thread canvas paint left over is cheap enough that it doesn’t block the page even at print resolution.

Confirming pdf.js is actually lazy-loaded. pdfjs-dist is only ever reached through a dynamic import('pdfjs-dist'), triggered the first time a file is dropped — never a static import. A Rollup manualChunks entry pins it to one predictably-named chunk; inspecting the built dist/ output confirmed neither that chunk nor pdf.js’s own worker bundle (fetched separately, only once a document is actually opened) is referenced anywhere in the page’s initial HTML.

Try it / source