On a hand-built PDF, pdfimages, PyMuPDF and a pdf.js bitmap all returned the whole stored picture, even where the page showed half of it or showed it turned or mirrored. What the page shows has to come from somewhere else. pdf.js has no extract call, but getOperatorList() returns a page's drawing instructions, so I read each image's matrix and clip out of it.
The setup was pdfjs-dist 4.10.38 (legacy build) under Node 24.14.1 on an Apple M4. The files were a hand-built page with four lossless images (one clipped, one rotated, one mirrored, and a palette image I leave out because it has nothing to do with placement), the 2-page Creative Commons Indonesia brochure (CC BY 4.0) and a 10-page Creative Commons newsletter (CC BY 3.0), both from Wikimedia Commons. Everything below holds for these three files only. Lengths are in pt (1/72 inch), y up.
An image paint carries no position
Page 1 of the hand-built file comes back as 52 operations. For the clipped image they read save, constructPath, clip, endPath, transform, paintImageXObject, restore. The path args are [[19], [60,540,90,120], [60,540,150,660]]: 19 is OPS.rectangle, then x, y, width, height and a bounding box. The paint op is only ["img_p0_1", 240, 160], an id and a pixel size. The position comes entirely from the transform before it.
That transform is [a b c d e f], applied to a unit square. A is [180,0,0,120,60,540]: 180×120 pt with its corner at (60, 540). B is [0,180,-120,0,450,540]: a is 0 and b is positive, a quarter turn counter-clockwise, ending up 120 wide and 180 tall. C is [-180,0,0,120,240,300], with a negative a and a determinant a·d − b·c of −21,600, so it is mirrored. A negative determinant is my mirror test; for C, b and c are 0, so it is left-right.
The last cm is not the matrix
A transform multiplies into whatever matrix is current, and the paint op carries none. The real matrix is every cm since the page started, minus the ones a restore popped. This is the core of my walk, trimmed. The full script also handles form XObjects and groups, and the newsletter has three of each.
let ctm = [1, 0, 0, 1, 0, 0], clips = [], stack = [], path;
for (let i = 0; i < ol.fnArray.length; i++) {
const f = ol.fnArray[i], a = ol.argsArray[i];
if (f === OPS.save) stack.push([ctm, clips.length]);
else if (f === OPS.restore) [ctm, clips.length] = stack.pop();
else if (f === OPS.transform) ctm = mul(a, ctm);
else if (f === OPS.constructPath) path = { xy: a[1], ctm };
else if (f === OPS.clip) clips.push(path);
else if (f === OPS.paintImageXObject) console.log(a[0], ctm, clips.length);
}
On the brochure (8 image paints) and the newsletter (36), the last cm before each paint was already the full matrix, 44 times out of 44, with save depth up to 4. I don't know why. Then I edited a copy of the hand-built file, wrapping A's drawing in q 0.5 0 0 0.5 100 100 cm … Q. Its last cm stayed [180,0,0,120,60,540] while the accumulated matrix was [90,0,0,60,130,370]. The other three images still matched. I accumulate.
Clips share those coordinates
A clip arrives as a constructPath followed by clip. The rectangle is in the coordinates in force when the path was built, so I keep that matrix, push the corners through it and take the bounding box. A restore drops the clips added since its save. The visible share is the image box intersected with every clip box, over the image box.
| File | Visible share | Checked against |
|---|---|---|
| Hand-built, A | 0.500 | a 96 dpi render, cropped to the left 120 columns, differed by 0.00 on a 0–255 scale |
| Second hand-built file, rotated 90° and clipped | 0.500 | the render matched the stored image's right half, turned 90°, with error 0.00 |
| Third hand-built file, two nested clips | 0.333 | a render showed 33.8% visible |
| Brochure, 8 images | 1.000 for all 5 under a clip | arithmetic only |
| Newsletter, 36 images | 14 under a clip, 7 lose 0.4% to 2.3% | arithmetic only, pages not rendered |
What I couldn't read
Page 9 of the newsletter has three images tilted by about 7°, 5° and 14°, each under a clip that is not a rectangle. My rectangle math doesn't apply, so I have no visible share for them. W* and text clips are untested.
For the stored side I used ImgIng (https://imging.ai/), a browser tool, on the hand-built file in a desktop browser. It takes the images actually painted on a page, not a screenshot, and A, B and C came back whole, unrotated and unmirrored, the same pixels as pdfimages. It takes one PDF per run, returns 0 results for a page with no images, and names ZIP entries by serial number, with page numbers only on its cards. On the newsletter it listed 32 results where the operator list has 36 paints, since a banner reused on several pages is exported once. The op list keeps every placement with its own matrix, and a deduplicating extractor keeps one file.
To get the part the page shows, crop the stored pixels by the visible share when the matrix is axis-aligned and the clip a rectangle; otherwise render the page and crop, which I haven't compared across tools.
Top comments (0)