Skip to content

fix(render): draw images in /CalGray and /CalRGB colour spaces - #411

Merged
cau-git merged 4 commits into
docling-project:mainfrom
wittjeff:fix/cal-colorspace-images
Oct 29, 2026
Merged

cau-git merged 4 commits into
docling-project:mainfrom
wittjeff:fix/cal-colorspace-images

Conversation

@wittjeff

@wittjeff wittjeff commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Image XObjects in the CIE-based /CalGray and /CalRGB colour spaces were never drawn. The renderer painted its yellow "missing image" placeholder over them instead, which is what docling-project/docling#4861 reports as an "empty yellow page". PDF/A image-to-PDF converters write /CalGray for scanned bilevel pages, and with the default docling pipeline the OCR stage runs on that rendered page, so such a document converted to nothing at all.

Standalone PR under #428 (no other open PR touches page_xobject_image.h). Rebased on main (7.24.1), no merge commits.

Evidence from the regression dataset

dln_23cb379bc6159a37e5488b65bb08ad295ce3ff21d9ddf682064330594ad59db3.pdf, page 1, carries a /CalRGB JPEG (/Im1, 2080x200). Left to right: pypdfium2, main, this PR.

dln_23cb379b p1: pypdfium2 | main | this PR

Against pypdfium2, over the whole regression corpus (940 sampled pages, scale 2.0, the page set and metric of test_regression_pypdfium_render.py; before = the 7.24.1 release, which is main):

page before mean_abs_error before changed_pixels_ratio after mean_abs_error after changed_pixels_ratio
dln_23cb379b… p1 5.10 0.1306 0.26 0.0045
  • Pages whose metrics moved: 1 (the cited page), closer to pypdfium2.
  • Pages that got worse: 0. No page increased by more than PYPDFIUM_IMAGE_TOLERANCE, and no page moved from within it to above it.
  • Render status changed: 0 of 940.

The sample from docling#4861 (APT002.pdf, not in the dataset) renders as the scanned invoice, and with this extension the default docling pipeline converts it to 22 text items and 2 tables, the content pdfium yields, where it produced an empty document before.

Root cause

/CalGray and /CalRGB are always written as an array, [/CalGray << /WhitePoint ... /Gamma ... >>]. The image resource (page_xobject_image.h) only recognised the /ICCBased, /DeviceN, /Separation and /Indexed arrays; anything else kept the array's JSON dump as its colour-space name, the bitmap state reported it as unsupported and no pixels reached the renderer:

bitmap.h:1013  WARN| bitmap: unsupported color space '["/CalGray",{"/Gamma":2.2,"/WhitePoint":[0.95,1,1.089]}]' for xobject_key=/X0
blend2d_renderer.h:3444  WARN| render_bitmap: no pixel data for xobject_key=/X0 shape=0x0x0 has_data=false — drawing semi-transparent yellow placeholder

The vector-colour path already handled both spaces (page_colorspace.h maps /CalGray to gray and /CalRGB to RGB), so only the image path was missing them.

Fix

  • The image resource hands any array space the branches above did not claim to pdf_resource<PAGE_COLORSPACE> and reads the samples as the device space of the family it reports (detail::device_space_for_family). The calibration dictionary only refines how the samples look, which the renderer does not model, so this matches how fill and stroke colours in these spaces are already treated.
  • An /Indexed image whose base is [/CalRGB ...] or [/CalGray ...] gets the same mapping for indexed_base_cs. A /Separation base is skipped there because the existing code parses it as a tint space right below.
  • /Lab is left as it was: it needs a real L*a*b* to RGB conversion and stays reported as unsupported.

Tests

  • tests/test_regression_pypdfium_cited_pages.py (new, second commit): an enforced comparison against pypdfium2 on the cited page, with its own tolerance (mean_abs_error 1.0, changed_pixels_ratio 0.02). It fails on 7.24.1 (5.10 / 0.131) and passes with the fix (0.26 / 0.0045). The file holds a list of cited pages, so later rendering fixes can add theirs.
  • tests/test_unit_cal_colorspace_images.py renders four synthetic pages and checks the painted block and the white margin: a /CalGray Group 4 fax image with the default /Decode and with /BlackIs1 true plus /Decode [1 0], a /CalRGB 8-bit image, and an /Indexed image over a /CalRGB base. All four fail with the placeholder colour (255, 255, 153) before the fix. The Group 4 encoder that test_unit_ccitt_images.py used moves to tests/image_fixtures.py.
  • Unit lane, test_regression_parse.py and the new test: pass. test_regression_threaded_render.py: fails only on the ground-truth change below.

Ground truth changes

One page: dln_23cb379bc6159a37e5488b65bb08ad295ce3ff21d9ddf682064330594ad59db3.pdf page 1. Its image is now decoded, so its render ground truth changes:

  • groundtruth/render/bitmaps/…page_no_1.bitmaps.json.gz: bitmaps[0] gets pixel_format rgb (was unknown), its decoded shape (was empty), extension .jpg (was .bin), and the hashes and retained_filename of the decoded image.
  • groundtruth/render/instructions/…page_no_1.instructions.json.gz: the image instruction gets pixel_format rgb and its shape.
  • groundtruth/render/bitmap_data/…page_no_1.bitmap_1.jpg: new retained image.
  • The stored page image …page_no_1.full_page.png does not change: it is pixel-identical to the new render.

How I checked that nothing else moves: I regenerated the render ground truth with this branch and compared the decoded contents with the pinned revision. Six other pages also differ on my machine (image hashes on 11315887125936234472_013 p3/p13/p14, 17068186561688296387-1 p1 and 5ad06a71-…-2 p1; font ascent and descent on 07f5395c8b3e7d1c_0001 p1 and 11564548392525182445_007). They differ the same way with the 7.24.1 release wheel and with a local build of main plus unrelated changes, so they come from the local environment, not from this PR.

Refs docling-project/docling#4861

Checklist (#428):

  • One topic per pull request, rebased on main, no merge commits
  • Superseded pull requests closed and linked (none)
  • Regression dataset document and page named
  • Before/after evidence for the cited page
  • Rendering: pypdfium2 comparison image, corpus-wide metrics table, enforced image regression test
  • Changes to existing ground truth listed with the reason

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @wittjeff, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@cau-git

cau-git commented Oct 28, 2026

Copy link
Copy Markdown
Member

Thanks for this contribution. This PR is on hold until it has been consolidated as described in #428. Please resubmit it as part of the topic PR listed there, with the evidence the issue asks for.

wittjeff and others added 2 commits October 28, 2026 12:57
An image XObject whose /ColorSpace is [/CalGray dict] or [/CalRGB dict]
was left as the array's JSON dump by the image resource, which only
unwrapped the ICCBased, DeviceN, Separation and Indexed arrays. The
bitmap state then reported the space as unsupported and the renderer
drew its yellow placeholder over the whole image. PDF/A image-to-PDF
converters write scanned bilevel pages as /CalGray, so such a scan
rendered as an empty yellow page, and OCR run on that page found
nothing.

The image resource now hands any other array space to
pdf_resource<PAGE_COLORSPACE>, which already classifies CalGray and
CalRGB for fill and stroke colours, and reads the samples as the device
space of that family. An /Indexed base written the same way is mapped
alike. /Lab still needs a real conversion and is left as is.

The Group 4 encoder of the CCITT unit test moves to
tests/image_fixtures.py so the new tests build their fixtures with it.

Refs docling-project/docling#4861

Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
…on regression

tests/test_regression_pypdfium_render.py only reports how the corpus
compares with pypdfium2. Add an enforced comparison for the pages that a
rendering fix cites, each with its own tolerance, starting with the
/CalRGB JPEG on dln_23cb379b... page 1 (fails on 7.24.1 with
mean_abs_error 5.10 and changed_pixels_ratio 0.131; passes with the fix
at 0.26 and 0.0045).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@wittjeff
wittjeff force-pushed the fix/cal-colorspace-images branch from bb2baa5 to e282c5f Compare October 28, 2026 18:40
The only array spaces that reached the generic PAGE_COLORSPACE branch and
came back with a device family were /CalGray and /CalRGB; Lab, Pattern and
the rest stayed unsupported, and the CMYK case could not be reached. Map
the two names directly at both sites (top-level /ColorSpace and /Indexed
base) with one small helper, which keeps the behaviour and drops the
per-image colour-space resource and the /Separation exclusion.

Add the docling#4861 sample (APT002, a /CalGray CCITT G4 scan) to the
cited pages: mean_abs_error 38.1 / changed_pixels_ratio 0.111 before the
fix, 3.30 / 0.051 after; the rest is anti-aliasing of the downsampled
1-bit scan.

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
cau-git
cau-git previously approved these changes Oct 29, 2026
… truth

Dataset PR docling-project#11 adds APT002 (docling#4861) with its ground truth and updates the render ground truth of dln_23cb379b... page 1, whose /CalRGB image is now decoded.

Signed-off-by: Christoph Auer <cau@zurich.ibm.com>
@cau-git
cau-git merged commit 4df810b into docling-project:main Oct 29, 2026
43 checks passed
@wittjeff
wittjeff deleted the fix/cal-colorspace-images branch October 29, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants