Short answer: for B2B SaaS invoice previews, use a bounded background job that extracts each embedded image with a PDF parser, normalizes its pixels, and stores a content-addressed object plus a page manifest; keep the original PDF as the source of truth.
That choice keeps invoice upload latency separate from decode work. It also handles a detail that surprises many implementations: a PDF is an object graph, not a folder of image files. An image can be indirect, masked, or reused on several pages.
How can a Node.js example extract embedded images from a PDF and store each one?
The data flow is deliberately plain. The API accepts an order's PDF, records its digest, and places a job on a queue. A worker loads the bytes, walks each page's operator list, resolves image objects, converts supported layouts to RGBA, and writes one object per digest. Only after those writes succeed does it publish a manifest for the preview service.
Keep it boring.
Here is the smallest useful implementation. It writes raw RGBA files to a local directory so the extraction behavior can be tested without committing to an image encoder or a storage vendor. The same keys can be passed to an object-store client later.
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { getDocument, OPS } from "pdfjs-dist/legacy/build/pdf.mjs";
type PdfImage = {
width: number;
height: number;
data: Uint8ClampedArray | Uint8Array;
kind?: "rgb" | "rgba" | "gray";
};
type ImageRecord = {
page: number;
resource: string;
key: string;
width: number;
height: number;
sha256: string;
};
function resolveObject(page: any, name: string): Promise<PdfImage> {
return new Promise((resolve, reject) => {
page.objs.get(name, (value: PdfImage) => {
if (!value?.data || !value.width || !value.height) {
reject(new Error(`image resource ${name} has no decoded pixels`));
return;
}
resolve(value);
});
});
}
function rgbaBytes(image: PdfImage): Uint8Array {
const input = Uint8Array.from(image.data);
if (image.kind === "rgba" || input.length === image.width * image.height * 4) {
return input;
}
if (input.length !== image.width * image.height * 3) {
throw new Error("unsupported decoded image layout");
}
const output = new Uint8Array(image.width * image.height * 4);
for (let src = 0, dst = 0; src < input.length; src += 3, dst += 4) {
output[dst] = input[src];
output[dst + 1] = input[src + 1];
output[dst + 2] = input[src + 2];
output[dst + 3] = 255;
}
return output;
}
export async function extractImages(
pdf: Uint8Array,
outputDir: string,
): Promise<ImageRecord[]> {
await mkdir(outputDir, { recursive: true });
const document = await getDocument({ data: pdf }).promise;
const records: ImageRecord[] = [];
const seen = new Set<string>();
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const operators = await page.getOperatorList();
for (let i = 0; i < operators.fnArray.length; i += 1) {
const fn = operators.fnArray[i];
if (fn !== OPS.paintImageXObject && fn !== OPS.paintImageMaskXObject) continue;
const resource = operators.argsArray[i]?.[0];
if (typeof resource !== "string" || seen.has(resource)) continue;
const image = await resolveObject(page, resource);
const bytes = rgbaBytes(image);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const key = `${sha256}.rgba`;
await writeFile(join(outputDir, key), bytes);
seen.add(resource);
records.push({ page: pageNumber, resource, key, width: image.width, height: image.height, sha256 });
}
}
await writeFile(join(outputDir, "manifest.json"), JSON.stringify(records, null, 2));
return records;
}
The seen set prevents a logo painted on five pages from being decoded five times. The SHA-256 key handles a second case: different resource names can still contain identical pixels. Keep both values. The resource and page fields describe placement; the digest describes storage identity.
The output is raw RGBA on purpose. A browser preview can encode it as PNG, while a separate image service can produce WebP when that is useful. Copying the parser's buffer into a typed array also makes the hash and the manifest independent of library internals.
What breaks when PDF image extraction meets real invoices?
Color assumptions are the first quiet failure. Grayscale samples, indexed color, alpha masks, and image masks are all valid PDF content. Treating a one-channel buffer as RGB shifts every later byte and can create a preview that looks damaged even though the invoice is intact. The function above rejects layouts it cannot normalize; the worker records the page and reason instead of guessing.
Memory is the loud failure. A 6,000 by 6,000 RGBA image is about 144 MB before JavaScript object overhead. Set a document byte ceiling, a per-image pixel ceiling, and a wall-clock timeout before parsing. If an invoice crosses a limit, mark its preview unavailable and retain the original billing record. A thumbnail must never decide whether an order can be collected.
There is a less obvious accounting problem. PDF operators describe paint operations, not unique files. A repeated logo is one asset with several placements, while two separate resources may be byte-for-byte equal. If the manifest stores only a count, support cannot explain why a five-page document produced one stored object. Recording page references alongside content hashes fixes that ambiguity.
I keep one deliberately awkward fixture in my test corpus: a scanned receipt with a mask and a reused company mark. It catches the exact regression where a decoder passes ordinary RGB fixtures but produces a transparent or color-shifted result in production. The fixture is small; the lesson is not. The test opens the document, waits for the operator list, and checks that the manifest has one content hash for the reused mark while retaining two page references. It then verifies the mask's dimensions and the RGBA byte length rather than comparing a screenshot, because a screenshot can hide a one-byte channel shift. Finally, it runs the same input through the queue wrapper twice and expects the second pass to address the existing key instead of creating a suffix. That one fixture covers object identity, page placement, channel normalization, and idempotency in a way a directory full of ordinary color images never will. When a parser upgrade changes any of those assertions, I stop the rollout and inspect the release notes before accepting a new fixture result.
Your mileage may vary across parser releases. Pin the version, rerun the same fixtures after upgrades, and compare dimensions, hashes, and rejection reasons before changing the worker.
Which storage and rendering trade-offs fit invoice previews?
Fidelity is the useful decision axis, not a vendor scorecard. Extracted assets preserve the boundaries a support agent may need when checking a scanned receipt. Page thumbnails are cheaper to serve and better for a dense list, but they hide which embedded object supplied a mark or stamp. Keeping the source PDF gives exact archival fidelity while making every preview request pay the decode cost.
| Storage choice | Inspection fidelity | Render cost | Main risk |
|---|---|---|---|
| Decoded embedded images | High at image level | Medium during ingestion | More objects and metadata |
| Page thumbnails only | Medium | Low at read time | Harder source-asset audits |
| Original PDF only | Exact source | Repeated decode work | Slow preview response |
For this invoice flow, I extract during ingestion, cap pixels, and defer expensive thumbnail encoding until a user opens the preview. The common upload path stays predictable, and the support view can still show the original embedded image when fidelity matters.
Fidelity wins.
The catch is scope. This worker is not suitable for legally verified rendering, font substitution, or full PDF redaction. Those jobs need a dedicated rendering or compliance pipeline; switching to that service is the right move when visual equivalence is a legal requirement.
How should retries, identity, and observability be wired?
Use an idempotency key such as invoiceId + pdfSha256. A retry then targets the same object keys, and a compare-and-swap manifest update prevents a late worker from publishing stale page references. Network writes deserve exponential backoff with a hard attempt limit. A parser exception for the same bytes is usually permanent, while an object-store timeout is usually transient; sharing one retry policy turns a bad document into an endless queue item.
The operational checklist belongs in the worker's contract. Record queue age, decode duration, peak resident memory, extracted-image count, rejected-byte totals, parser version, and the PDF digest. Redact invoice contents from logs. Alert on queue age and memory before customers report missing previews, and retain the original PDF so a failed derivative never destroys the billing artifact.
Three words matter: measure before tuning.
References
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- PDF.js API documentation: https://mozilla.github.io/pdf.js/api/draft/
- Node.js Crypto API: https://nodejs.org/api/crypto.html
- Node.js File System Promises API: https://nodejs.org/api/fs.html
Further reading
The PDF object model is specified by ISO 32000-2. The PDF.js operator-list and object APIs explain the parser calls used above, while Node.js documents the hashing and file APIs:
Top comments (0)