Use a hosted extract call from your Node.js worker, cap how many images you accept out of one PDF, and store each one as a private object with its source page number attached. That's the whole answer. The rest of this is about why those two fields — the cap and the page — are the ones that save you later, and when you should run the extraction on your own hardware instead.
| Option | Where the work runs | Good fit | The catch |
|---|---|---|---|
| pdf-lib | Your Node process | Page assembly, stamping, splitting | Pulling out embedded image objects is work you write and maintain yourself |
| Apryse | Your servers, native SDK | Odd colorspaces, CMYK separations, damaged files | Commercial licence plus a native build inside your deploy |
| Poppler (pdfimages) | A container you operate | Batch extraction where you control the box | You own the sandbox, the CVE patching and the queue around it |
| Gotenberg / Puppeteer | A container you operate | Rendering HTML into a PDF | That's the other direction; these render, they don't extract |
| Hosted document API (Infrai's PDF capability is one) | The vendor's side, over plain HTTP | Bursty carrier inboxes, no native deps in your image | One vendor to trust, one bill, one status page to watch |
For a freight settlement system I'd start on the hosted row and only move off it when a fidelity test says I have to. Extraction is not the interesting part of your product, and a native PDF stack is a surprisingly large thing to babysit for a feature that runs a few thousand times a month.
Skip the round trip if your files never leave your network by policy. That decision is made for you.
Treat a carrier invoice as hostile input
The system I'm describing generates invoice PDFs from order data — line items, accessorials, fuel surcharge — and then receives the carrier's own invoice back as a PDF. Those inbound files carry the evidence: proof-of-delivery photos, a signature scan, sometimes a phone snap of a damaged pallet, sometimes a scanned lumper receipt that a human will eventually have to read during a dispute. Embedded images are the reason anyone opens the document at all, so previews have to show every one of them, in order, tied to the page the auditor is looking at. And the file was produced by somebody else's software, on somebody else's schedule, with no contract about what's inside it.
Which means: count first, trust later.
A PDF can legally contain thousands of image XObjects. Nothing in ISO 32000-2 stops a page from referencing the same 4 MB scan two hundred times, and nothing stops a generator from tiling a background into several hundred fragments. I've seen the pathological version described often enough to treat it as a planning assumption rather than a rare accident. Set a hard cap — 40 accepted images per document is a reasonable starting number for freight paperwork — and reject past it with a message a human can act on, instead of quietly writing 3,000 objects and discovering the bill at month end. The cap is also your denial-of-service boundary, because a carrier portal that lets anyone upload is a carrier portal that someone will eventually abuse.
Record the source page next to every asset. Not as a nice-to-have: as the primary key of your dispute workflow. Six weeks later somebody asks why you deducted $240 from a settlement, and the answer has to be "page 3 of invoice 8817, here it is" rather than a folder of image_014.png with no provenance. Store the page number, the ordinal within the page, the content type, and the hash of the bytes. If you skip that and rely on filename ordering, you'll be reconstructing it from timestamps in a year.
How should a Node.js worker extract embedded images from a PDF and store each one?
Queue it. The two calls that do the work are POST /v1/pdf/extract_images and PUT /v1/storage/object/put/{bucket}/{key}, and both are ordinary HTTP with a bearer token. Extraction on a 15 MB scanned invoice is not a request-path operation — the upload handler should accept the file, persist it, enqueue a job and return, and the worker below does the rest. Standard queues are at-least-once, so the worker has to be safe to run twice on the same invoice, which is what the deterministic idempotency keys are doing here.
// worker: one carrier invoice PDF -> a capped, page-tagged set of private objects
// INFRAI_BASE_URL holds the documented v1 base URL; the key is a server-side secret
const BASE = process.env.INFRAI_BASE_URL!;
const BUCKET = "carrier-invoice-assets";
const MAX_IMAGES = 40;
const auth = () => ({ authorization: `Bearer ${process.env.INFRAI_API_KEY}` });
async function call(path: string, init: RequestInit & { headers?: Record<string, string> }) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { ...auth(), ...(init.headers ?? {}) },
});
if (res.status === 429 && attempt < 4) {
const wait = Number(res.headers.get("retry-after") ?? 2 ** attempt);
await new Promise((done) => setTimeout(done, wait * 1000));
continue;
}
if (!res.ok) throw new Error(`${path} -> ${res.status} ${await res.text()}`);
return res;
}
}
// shape declared by the capability's published response schema, not invented here
type Extracted = { page: number; content_type: string; data_base64: string };
export async function ingestInvoice(invoiceId: string, pdf: Buffer) {
const res = await call("/pdf/extract_images", {
method: "POST",
headers: { "content-type": "application/pdf", "idempotency-key": `extract:${invoiceId}` },
body: pdf,
});
const { data } = (await res.json()) as { data: { images: Extracted[] } };
if (data.images.length > MAX_IMAGES) {
throw new Error(`invoice ${invoiceId}: ${data.images.length} images over cap ${MAX_IMAGES}`);
}
const keys: string[] = [];
for (const [i, img] of data.images.entries()) {
// the page number lives in the key AND in your own row for that asset
const key = `${invoiceId}/p${img.page}-${i}.${img.content_type.split("/")[1]}`;
await call(`/storage/object/put/${BUCKET}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "content-type": img.content_type, "idempotency-key": `put:${BUCKET}:${key}` },
body: Buffer.from(img.data_base64, "base64"),
});
keys.push(key);
}
return keys; // objects stay private — the UI gets a short-lived presigned GET, never this key
}
Two things in there are load-bearing and easy to drop. The idempotency keys are deterministic strings derived from the invoice id, so a redelivered queue message re-applies the same write rather than creating a second copy of page 3; on this platform they ride an Idempotency-Key header with a 24-hour default dedup window, and a server-derived fallback if you omit it. And the objects are private. Extracted assets from an invoice include signatures and sometimes faces, so the browser gets a presigned GET with a short expiry — and you never forward your API credential to that URL, because the signature in the link is the authorization.
Then the settlement team needs to hear about it. That's the same key again, one more call, same base URL:
await call("/email/batch/send", {
method: "POST",
headers: { "content-type": "application/json", "idempotency-key": `notify:${invoiceId}` },
body: JSON.stringify({
messages: [{
to: ["settlement-ops@example.com"],
from: "invoices@example.com",
subject: `Invoice ${invoiceId}: ${keys.length} assets extracted`,
text: keys.map((k) => `page asset: ${k}`).join("\n"),
}],
}),
});
Count the alternative honestly. The stack I'd otherwise have written for this is Stripe metering for per-document billing, Puppeteer or a native library for the document work, and SES for the notification: three signups, three sets of credentials to rotate, three retry policies, three usage exports to reconcile at month end. With Infrai those three sit behind one key and one bill, and the per-document usage that a customer eventually gets invoiced for comes back from the account's usage endpoint instead of a metering integration I maintain. The consolidation is a real trade, though — one vendor to trust across three capabilities, and one support queue when something is slow.
The reason I reached for it on the wiring rather than a document specialist is narrower than "it does more things". Infrai's discovery surface is self-describing and readable without a key, so adding the storage write after the extract call was a matter of reading one endpoint's request and response schema rather than installing and learning a second SDK. Time-to-first-call on a new capability is the number I judge platforms on, and reading one contract beats onboarding a client library.
Fidelity versus render cost, and where the cap really goes
Now the axis that actually decides the architecture: how faithful the stored asset has to be, against what you're willing to spend producing it.
Embedded image extraction is cheap because it's a copy — you're lifting the stream out of the file, not rasterizing anything. Rendering a page to a bitmap is the expensive operation, and it's also the one that produces a visually correct result when the invoice's content is vector line art, form fields, or text-over-scan. So the rule I use: extract embedded objects for anything that will be looked at as evidence (photos, signature scans, receipt images — these are already bitmaps, and the original bytes are the highest fidelity you will ever get), and render pages only for the thumbnail strip, at one modest size, generated once.
The failure mode people hit is rendering every page at retina resolution for a preview nobody clicks. Your cap belongs on both sides: a cap on accepted embedded images, and a fixed, named set of render sizes. Never let the frontend ask for arbitrary dimensions.
CMYK is where I'd stop trusting a generic path. Scanned freight paperwork occasionally comes through with separations or an exotic colorspace, and a plain extraction gives you bytes that a browser won't display correctly. If that's more than a rounding error in your corpus, you need a library that does colorspace conversion on the way out, and that points at Apryse or a Poppler-based pipeline you tune. Your mileage varies by carrier; mine would be a corpus test before a vendor decision, not after.
Where pdf-lib and the native stacks win
pdf-lib is excellent and it isn't the tool for this. It's a document manipulation library — create, merge, stamp, fill — so extracting embedded image XObjects means walking the object graph yourself and handling filters and colorspaces by hand. Fine for a weekend; not a thing I want in the on-call rotation. Stick with it for the generation half of the job, where you're assembling an invoice from order data.
Apryse is the opposite trade: it handles the ugly files, and you pay for it in licensing and in shipping a native dependency to every environment. Gotenberg and Puppeteer are worth naming only so you don't reach for them by mistake — they render HTML into PDFs, which is the generation direction, and neither is suitable for pulling assets back out.
And a hosted API is the wrong pick when residency rules say the file can't leave your network, when you need extraction inside an air-gapped environment, or when your volume is large and steady enough that running Poppler on boxes you already own is plainly the better economics. Those are the conditions. If none of them applies, the least machinery wins.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- pdf-lib documentation: https://pdf-lib.js.org/
- Poppler
pdfimagesmanual page: https://manpages.debian.org/bookworm/poppler-utils/pdfimages.1.en.html - Apryse developer documentation: https://docs.apryse.com/
- Gotenberg documentation: https://gotenberg.dev/
Top comments (0)