Most "extract images from PDF" code I found online does the same thing: render or decode each image, draw it onto a canvas, and call canvas.toBlob(). It works, and every photo you get back has been through a second round of lossy compression, even when the PDF was holding a perfectly good JPEG file all along.
I build Filewhisk, a set of file tools that run entirely in the browser, and I wanted the PDF image extractor to hand back the images exactly as they are stored. This post covers the three paths I ended up with, the canvas behaviour that made me write my own PNG encoder, and how I matched my own PDF parsing with pdf.js for the formats I didn't want to decode myself.
A JPEG inside a PDF is already a JPEG file
PDF stores images as stream objects. When a stream's filter is DCTDecode, its data is a JPEG bitstream: you can write the bytes to disk with a .jpg extension and open them. No decoding, no quality setting, no loss.
With pdf-lib, finding them looks like this:
import { PDFDocument, PDFName, PDFRawStream } from 'pdf-lib';
const doc = await PDFDocument.load(bytes);
const N = (name) => PDFName.of(name);
for (const [ref, obj] of doc.context.enumerateIndirectObjects()) {
if (!(obj instanceof PDFRawStream)) continue;
if (obj.dict.get(N('Subtype')) !== N('Image')) continue;
if (obj.dict.get(N('Filter')) === N('DCTDecode')) {
const jpeg = new Blob([obj.contents], { type: 'image/jpeg' });
}
}
(PDFName.of returns the same object for the same name, so === works.)
In my tests the extracted bytes were identical to the JPEG that went into the PDF, byte for byte. But "has a DCTDecode filter" is not the same as "these bytes are the picture you see". I only take the raw bytes when all of these are true:
-
No
/SMask. Transparency is stored as a separate grayscale image. The JPEG alone is the photo with its transparent areas filled in, often black. -
No
/Decodearray. It remaps sample values. Some CMYK JPEGs from Adobe apps are stored inverted and fixed up by/Decode [1 0 1 0 1 0 1 0]. The raw file looks like a photo negative. - Gray or RGB colour space. A CMYK JPEG is a valid file, but many viewers show it with odd colours.
-
No
/Mask(colour-key transparency). -
The filter is only
DCTDecode.Filtercan also be an array, such as[/FlateDecode /DCTDecode].
Everything that fails these checks goes down one of the other two paths.
Don't use enumerateIndirectObjects to decide what to extract
The loop above is fine for a demo, but it returns every image object in the file. That includes images no page ever draws: leftovers from earlier edits, alternates, and the soft masks themselves. Users expect "the pictures in my document".
So I read each page's content stream and follow the drawing operators instead. The parts that matter are small:
-
q/Qpush and pop the graphics state -
a b c d e f cmmultiplies the current transformation matrix (CTM) -
/Name Dodraws an XObject from the page's resources. If it's a Form XObject, recurse into its content with its/Matrix; if it's an image, record it. -
BI … ID … EIis an inline image, whose binary data you have to skip without tokenising it
The same pass gives you something useful for free. An image is drawn into the unit square, so the CTM tells you how large it is printed:
const widthInches = Math.hypot(ctm[0], ctm[1]) / 72; // PDF units are 1/72 inch
const heightInches = Math.hypot(ctm[2], ctm[3]) / 72;
const dpi = Math.min(pixelWidth / widthInches, pixelHeight / heightInches);
An 800×600 photo placed four inches wide is 200 DPI. Showing that number next to each image answers the most common complaint about extractors ("why is my image blurry?") before anyone asks it: the PDF only contained a small image.
The pass also gives each page its images in drawing order, which becomes important in a moment.
Lossless images: why I didn't use canvas for PNG
Flate-compressed images are raw pixel rows, which is easy to decode:
- Inflate the stream (I use fflate).
- Undo the PNG predictor if
/DecodeParmshasPredictor >= 10. - For
/Indexedcolour spaces, expand each index through the lookup table. - If there is an
/SMaskwith the same dimensions, decode it the same way and use it as the alpha channel.
The obvious way to produce a PNG from that is putImageData and then toBlob('image/png'). PNG is lossless, so that should be safe. It isn't, for images with partial transparency.
A 2D canvas stores pixels with premultiplied alpha, so the colour of a semi-transparent pixel loses precision on the way in. I measured a putImageData → getImageData round trip in Chrome 153:
| Written (R, G, B, alpha) | Read back |
|---|---|
| 200, 100, 50, 255 | 200, 100, 50, 255 |
| 200, 100, 50, 128 | 199, 100, 50, 128 |
| 200, 100, 50, 10 | 204, 102, 51, 10 |
| 200, 100, 50, 3 | 170, 85, 85, 3 |
| 200, 100, 50, 1 | 255, 0, 0, 1 |
Across 40 colours and every alpha from 1 to 254, 7,630 of 10,160 semi-transparent pixels came back different. On screen it's barely visible. But the anti-aliased edges of a logo are exactly those pixels, and the whole point of the tool was "exactly as stored".
Writing a PNG yourself turns out to be about 40 lines, because fflate does the compression:
const CRC = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (const b of bytes) c = CRC[(c ^ b) & 255] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function chunk(type, payload) {
const out = new Uint8Array(12 + payload.length);
const view = new DataView(out.buffer);
view.setUint32(0, payload.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(payload, 8);
view.setUint32(8 + payload.length, crc32(out.subarray(4, 8 + payload.length)));
return out;
}
// channels: 1 = gray, 2 = gray + alpha, 3 = RGB, 4 = RGBA (8 bits each)
function encodePng(pixels, width, height, channels) {
const colorType = { 1: 0, 2: 4, 3: 2, 4: 6 }[channels];
const rowLen = width * channels;
const raw = new Uint8Array((rowLen + 1) * height); // first byte of each row: filter 0
for (let y = 0; y < height; y++) {
raw.set(pixels.subarray(y * rowLen, (y + 1) * rowLen), y * (rowLen + 1) + 1);
}
const header = new Uint8Array(13);
const hv = new DataView(header.buffer);
hv.setUint32(0, width);
hv.setUint32(4, height);
header[8] = 8; // bit depth
header[9] = colorType;
return new Blob([
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', header),
chunk('IDAT', fflate.zlibSync(raw, { level: 6 })),
chunk('IEND', new Uint8Array(0)),
], { type: 'image/png' });
}
Two side benefits: a grayscale image stays a grayscale PNG instead of tripling in size as RGBA, and no canvas is involved, so iOS Safari's canvas size limit doesn't stop you on a large scan.
Everything else: let pdf.js decode it
That leaves CMYK images, JPEG 2000, the black-and-white fax formats scanners love (CCITT and JBIG2), 1-bit images and anything with a /Decode array. Writing decoders for all of those was not a good use of my time. pdf.js already decodes every one of them, and it converts CMYK the way a PDF viewer shows it.
pdf.js doesn't work in terms of PDF object references, though. You ask for a page's operator list and get opaque image ids:
const page = await pdf.getPage(pageNumber);
const list = await page.getOperatorList();
const paints = [];
list.fnArray.forEach((fn, i) => {
if (fn === pdfjsLib.OPS.paintImageXObject) {
const [id, width, height] = list.argsArray[i];
paints.push({ id, width, height });
} else if (fn === pdfjsLib.OPS.paintImageXObjectRepeat) {
paints.push({ id: list.argsArray[i][0] }); // same image drawn several times in a row
}
});
To get an image's pixels, ask the page's object store and wait for the callback, since the object may not have arrived from the worker yet. Ids that start with g_ live in page.commonObjs instead of page.objs:
function getImage(page, id) {
const store = id.startsWith('g_') ? page.commonObjs : page.objs;
return new Promise((resolve) => store.get(id, resolve));
}
What comes back is either { bitmap, width, height } (an ImageBitmap decoded in the worker) or { data, width, height, kind } with a raw buffer, where kind is 1 for 1-bit gray (bit set = white), 2 for RGB and 3 for RGBA. I handle both and hand the result to the same encodePng. A bitmap can only be read back through a canvas, so the premultiplied-alpha rounding from the previous section does apply on this path when the image has partial transparency. I accept that for these formats; in my test files the images that ended up here were opaque, and for those the round trip is exact.
The remaining problem is mapping a pdf.js id back to the PDF object the rest of my code knows about. This is where the drawing order from the content-stream pass pays off:
- On a page, take my list of drawn images (without image masks, which pdf.js paints with a different operator) and pdf.js's
paintImageXObjectcalls. - If both lists are the same length, pair them by position.
- Accept a pair only if pdf.js's width and height match the image object's
/Widthand/Height. - If anything doesn't line up, try another page that draws the same image. If none works, count the image as "could not decode" instead of guessing.
For JPEG 2000, JBIG2 and CCITT, pdf.js 6 loads its decoders as separate files (openjpeg.wasm, jbig2.wasm, plus plain-JavaScript fallbacks) from the folder you pass as wasmUrl to getDocument. If you self-host pdf.js, copy that wasm/ folder too and set the option.
Testing notes
- Compare bytes, not file names. The JPEG test embeds a known JPEG with pdf-lib, extracts it and compares every byte with the original.
-
Build the awkward PDFs yourself. pdf-lib can register raw image streams, so a test can create a grayscale Flate image, an
/Indexedimage with a four-colour palette and a 1-bit checkerboard, then check exact pixel values in the output. -
Include an image that is never drawn. Register an image in a page's resources without a
Dofor it, and assert that it doesn't show up. - Deduplicate by content, not by reference. Merged PDFs often embed the same logo once per page. I hash the extracted bytes or pixels and show one card listing every page the image appears on.
Takeaways
- A
DCTDecodestream is a JPEG file. Save it as-is unless it has a soft mask, a/Decodearray, a colour-key mask or a CMYK colour space. - Walk the content streams to find the images a page actually draws, and use the CTM to report the DPI each one is printed at.
-
putImageData/toBlobis not a lossless path for semi-transparent pixels. Writing a PNG with fflate is short. - Let pdf.js decode the formats you don't want to own, and match its images to yours by drawing order, checked by dimensions.
The extractor linked at the top runs this setup, and the PDF never leaves the browser.
Top comments (0)