
Most of what my team maintains sits somewhere on the product-image path of an e-commerce platform, and one input format keeps coming back that nobody likes: a catalogue PDF as the only copy of a set of product photos. Getting the pictures out is the easy half. The half I care about is being able to say, before those files enter our asset store, whether each one is what the PDF actually contains or something regenerated along the way, a re-encoded JPEG, a page render at display size, a PNG that lost its alpha. A folder of images that merely look fine doesn't answer that.
The trick that makes this checkable is that you don't need the supplier's originals. The PDF is its own reference. A JPEG inside a PDF is stored as a /DCTDecode stream, and that stream is a complete JPEG file, so a faithful extraction produces a file whose SHA-256 equals the SHA-256 of the raw stream. Flate-compressed images (what PNGs become) have no file to compare against, so for those I decode the stored object, fold in its soft mask if it has one, and compare pixels. Pixel size is enough to pair each recovered file with its object, and colour space comes straight from the PDF, which I want recorded because a CMYK product shot needs different handling downstream.
The check
I ran it on test PDFs I generated myself with reportlab: an 8-page brochure with JPEG and PNG product shots, a PDF holding one CMYK and one grayscale JPEG, and a single RGB JPEG at 1600×1067. The recovered files came from ImgIng's Extract PDF images. It runs in the browser, and the workspace states that the file is read only in the browser and never uploaded. For synthetic files I took that at face value; before feeding it anything under an NDA I would still watch the Network tab myself, because that's what the compliance people would ask me.
The check walks every page with PyMuPDF's get_images(full=True), which lists each image object the page paints along with its size, colour space, filter and soft-mask reference, and files them by pixel size. Each recovered file is then opened, paired with the objects of the same size, and tested. For a DCTDecode object the test is a straight hash comparison against the undecoded stream (excerpt; f is the recovered file, xref the candidate object):
if filt == "DCTDecode":
same = hashlib.sha256(f.read_bytes()).digest() == \
hashlib.sha256(pdf.xref_stream_raw(xref)).digest()
For everything else the stored object is decoded, and if it has a soft mask, the mask is folded into its alpha before comparing (excerpt):
pix = fitz.Pixmap(pdf, xref)
if smask:
pix = fitz.Pixmap(pix, fitz.Pixmap(pdf, smask)) # fold the mask into alpha
Both sides are converted to RGBA and diffed with Pillow's ImageChops.difference. If you write this yourself, call getbbox(alpha_only=False) on the diff; on RGBA images Pillow's default looks only at the alpha channel, and two opaque images with completely different colours come out "identical". A JPEG whose object also carries a mask gets a warning appended to its verdict. Across the four test PDFs, every recovered file found its object:
| Recovered file | Format / mode | Matched object | Verdict |
|---|---|---|---|
| Brochure, 5 files | 3 PNG RGBA, 2 JPEG RGB | DeviceRGB | PNGs pixels identical, JPEGs sha256 == PDF stream |
| CMYK JPEG | JPEG CMYK | DeviceCMYK | sha256 == PDF stream |
| Grayscale JPEG | JPEG L | DeviceGray | sha256 == PDF stream |
| RGB JPEG 1600×1067 | JPEG RGB | DeviceRGB | sha256 == PDF stream |
| Page render crop (control) | PNG RGBA | none | no image object is 1361x908 |
| Masked JPEG | JPEG RGB | DeviceRGB | sha256 == PDF stream, but the PDF also has a mask for it |
Because I made these PDFs, I also had the source files and hashed those too: all three recovered JPEGs, RGB, CMYK and grayscale, match them exactly. Nothing got converted either; the CMYK file is still a CMYK JPEG, which I prefer to a silent RGB conversion. The colour decision stays with whoever owns the print or web pipeline. The PNG shots matched pixel for pixel, the one with transparency included. One thing I'd put in a pipeline note: every PNG came back as RGBA, even the ones that were opaque RGB going in (alpha is 255 everywhere). If a downstream service rejects images with an alpha channel, that's where it'll trip.
A row that should fail, and one that passes when it shouldn't
The page render crop is the control. I rendered the same page with ImgIng's PDF to images at 216 DPI (1786×2526 for the whole A4 page) and cropped the photo out of it: 1361×908, against 1600×1067 stored. Its PSNR against the original scaled to that size was 47.19 dB, so by eye it passes, but no object in the PDF has that size, and the check rejects it before any hashing happens. That's exactly the file I don't want in an asset store with a "supplier original" tag on it.
The masked JPEG is the case a hash alone gets wrong. In that sample the JPEG has a soft mask that fades it to an oval on the page. The extracted file is byte-identical to the stored stream, so it is faithful in the narrow sense, but the transparency lived in a separate object and did not come along. I only have that one hand-built sample, which is why the script prints a warning next to the verdict for any masked JPEG.
Two UI details matter for automation. With one result, the button reads "Save this image" and writes a single .jpg; with several, you only get "Save extracted images (ZIP)" and there is no per-card save, so I unpack the ZIP into a folder and point the script at it. The ZIP is stored uncompressed and the file names are just -image-001, -002, so the page numbers shown on the cards don't travel with the files.
Everything above ran against files generated on my own machine, with the extractor at https://imging.ai/ reading them inside the browser.
Top comments (0)