DEV Community

Lank_M
Lank_M

Posted on

Why PDF image extractors return the stored picture, not the one on the page

Every extractor I pointed at my test PDF gave me back a rotated picture unrotated and a half-hidden picture whole. After three years of codec work I took that as the correct answer, but I wanted to be able to say why.

The file is one hand-written page with four lossless images. Each has four coloured corners and a big letter, so I can see which way it faces. A is hidden by a clip path, so the page shows only its left half. B is turned 90° counter-clockwise by the placement matrix. C is mirrored. D is a 16-colour palette image with /Decode [15 0], which swaps palette entries 0 and 15. I ran pdfimages 26.08.0, PyMuPDF 1.26.5, the decoded bitmap from pdf.js 4.10.38 in Chromium 149 (pdf.js has no extract call), and ImgIng (https://imging.ai/) in a desktop browser. One file, default settings. Sizes are pixels; pt is 1/72 inch.

Three tools stopped at the same place

On A, B and C, pdfimages, PyMuPDF and the pdf.js bitmap returned the stored pixels, the same SHA-256 on every tool: the whole 240×160 image for A, the unrotated one for B, the unmirrored one for C. D went differently. Reading its palette indexes as stored gives 566fb92e549e, yet all three tools returned the page's colours, 4b16acc5dcfa. They applied /Decode and ignored the clip and the matrix.

My reading is that it depends on where the instruction lives. /Decode sits in the image's own dictionary, beside its colour space. The clip (re W n) and the matrix (cm) sit in the page's content stream, around the operator that paints the image. The tools seem to decode the object and stop where the page starts placing it. That is a reading of four images, nothing I found documented.

One stored image can sit on a page four times

The strongest argument came from a second file: one image object painted four times, each under a different clip (a rectangle, a triangle, a circle, and a box that never touches it).

import fitz
pdf = fitz.open("clip-shapes.pdf")
pg = pdf[0]
stored = pg.get_images()
drawn = pg.get_image_info(xrefs=True)
cuts = [d for d in pg.get_drawings(extended=True) if d["type"] == "clip"]
print(len(stored), "stored,", len(drawn), "drawn,", len(cuts), "clips")
Enter fullscreen mode Exit fullscreen mode
1 stored, 4 drawn, 4 clips
Enter fullscreen mode Exit fullscreen mode

pdfimages -list prints four rows for that page, all object 5. The page shows 50%, 50%, 37% and 0% of the picture. An extractor that returned the visible part would have to return four images from one object, or quietly pick one. Returning the object is the only answer that doesn't depend on which placement you mean.

A 10-page Creative Commons newsletter (CC BY 3.0, via Wikimedia Commons) shows it from the other side: 36 rows in pdfimages -list, 32 distinct images, since one banner is painted on pages 2, 4 and 6. ImgIng returned 32 results.

The visible part would cost a dpi and an alpha channel

On A, the clip is 90 pt wide and the picture is drawn 180 pt wide for 240 pixels, so it is stored at 96 pixels per inch and the visible part is the left 120 columns. Cropped to 120×160, it matches a 96 dpi render of the page with an average error of 0.00 on a 0 to 255 scale. At 72, 150, 300 and 600 dpi the error is 0.98 to 2.51, from resampling. "What the page shows" depends on a dpi somebody has to choose, and extraction has none to choose.

The shape is the second cost: the circle exposes 37% of the picture, so a visible-part result would need an alpha channel. PyMuPDF reports each clip as a bounding box (scissor), and estimating from that gave 100% for the triangle (truth 50%) and 46% for the circle (truth 37%). Only rectangles came out right.

I don't know how these tools were designed, so this is an inference from the behaviour.

One image where ImgIng and the page disagree

On A, B and C, ImgIng returned the same pixels as the other three. On D it returned the stored palette, 566fb92e549e, so its colours are reversed against the page. A grey image and an RGB image carrying /Decode in a second file did match the page, which narrows the gap to a palette plus /Decode, in the 4-bit [15 0] form only; I haven't run the 8-bit [255 0]. My guess is that its palette path never reads /Decode.

One PDF per run, 0 results for a page with no images, ZIP entries named by number.

What I do with it

An extractor answers "which image objects does this file store". For the picture a reader sees, I read the placement (transform and clip in PyMuPDF, the matrix and clip operators in pdf.js) or render the page at a dpi I choose and crop.

What I can't answer is how often real files hide half a picture. A two-page public brochure has 9 placements and 20 clip entries, but I never matched clips to images.

Top comments (0)