Skip to content

fix(render): composite ExtGState soft masks (/SMask) - #413

Open
wittjeff wants to merge 4 commits into
docling-project:mainfrom
wittjeff:fix/extgstate-soft-mask
Open

wittjeff wants to merge 4 commits into
docling-project:mainfrom
wittjeff:fix/extgstate-soft-mask

Conversation

@wittjeff

@wittjeff wittjeff commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #384.

Summary

An object painted under an ExtGState whose /SMask names a transparency group was painted fully opaque: #306 parsed the key to a three-value enum and the renderer had nothing to composite. InDesign builds drop shadows and feathered photo edges from exactly this mask, so on such pages docling's layout and OCR saw solid boxes over the photos.

The mask is now composited. /S /Luminosity and /S /Alpha, /BBox, /BC and /TR are honoured; the mask's coordinate system is the CTM at the gs, as 11.6.5.1 requires. pdfium and MuPDF agree with the result on the issue's page.

How it works

The renderer already paints a non-rectangular clip by filling a layer, multiplying it by an A8 coverage plane and blitting it. A soft mask is the same operation with a different plane, so the change stays inside that structure.

  1. Parse (page_grph.h). The /SMask dictionary is kept on the ExtGState entry as a soft_mask_spec: the /G stream, the mask type, /BC as device RGB and /TR sampled to a 256-entry table through pdf_function. A mask that cannot be read is reported and clears the current one, which is the pre-existing behaviour.
  2. State (grph.h). gs snapshots the CTM together with the entry; /SMask /None clears both. The decoded mask is part of the graphics state, so it survives q/Q and reaches forms.
  3. Decode (stream.h). Right after gs, the stream decoder sub-decodes the /G group the way Do decodes a form, into containers of its own (its cells, shapes and images are coverage, not page content), seeded with /Matrix composed with the snapshotted CTM. One decode per entry and CTM, shared by every decoder of the page. Every text, shape, bitmap and shading instruction painted under the mask carries a shared pointer to the result, like clip_state.
  4. Render (blend2d_renderer.h). The group is painted once per page onto a page-sized canvas over its backdrop, read back as an A8 plane through /TR (backdrop outside /BBox), and cached per mask. A masked instruction re-enters its own paint routine with page_context() redirected to a layer over its bounding box; the layer is multiplied by the plane and blitted with the instruction's blend mode. Constant alpha, non-rectangular clip masks and the text routes all work unchanged inside the layer.

render_config.render_soft_masks (default on, exposed on RenderConfig) switches the compositing off. The time spent is reported under render_page.soft_mask.

Scope limits

Each case is reported with a warning, as #306 does:

  • Transparency groups stay inlined: alpha, blend mode and soft mask in force at the Do are pushed down onto each paint inside the group, and a mask the group's content installs is intersected with the outer one. This is exact when the group's contents do not overlap each other. A mask set inside a mask group is not applied.
  • Knockout and isolated group attributes stay ignored.
  • A canvas side above 8192 px paints unmasked, the bound build_clip_mask uses.
  • Both caches are bounded per page, since a page can install a new mask with every gs for next to nothing: at most 512 distinct masks are decoded (the rest paint unmasked, reported once) and at most 256 MB of mask planes are held (past that the cache is dropped and rebuilt as needed).

Tests

tests/test_unit_soft_masks.py, 22 cases on hand-built pages: the issue's page, mask placement, a gradient mask, strokes, images, a mask with a curved clip, /BBox, /BC, /TR, /S /Alpha, /SMask /None, q/Q, the CTM-at-gs rule, a form under a page mask, a transparency group under a mask (with no mask of its own, with /SMask /None, and with a second mask inside), a mask inside a mask, the switch, unreadable masks, and the per-page decode cap.

Effect on the corpus

Measured against pypdfium2 on the 11 regression-corpus files that carry a graphics-state soft mask (23 pages; errors/default.pdf, with 138 masks, timed separately below):

mean absolute difference to pypdfium2 (0-255), averaged over the 23 pages
before (7.22.1) 5.19
after 4.25

8 pages move closer to pdfium, none move away, 15 are unchanged within 0.05. The largest change is complex_invisible_fonts_01.pdf page 1 (17.19 to 3.55), a magazine cover whose photo is a transparency group painted under a mask with a second mask inside. On 14327672304519301096-2.pdf page 2 (6.56 to 5.00) the signal-strength bars rendered as solid black blocks and are now the blue and orange gradient fills pdfium draws. The corpus masks are drop shadows and small gradients, so the deltas are small; the InDesign pages of the report are where the difference is large. One page leaves the render regression tolerance: complex_invisible_fonts_01.pdf page 1 differs from its stored groundtruth by mean_abs_error 10.54 (limit 10) and changed_pixels_ratio 0.31 (limit 0.15), because the stored render predates soft masks. Its full_page.png is regenerated in dataset PR #9 (one file, on top of the current pin 6d3458e2), and HF_DATASET_REVISION points at it; it should move to the merge commit once that PR lands. Its instructions and bitmaps groundtruth are unchanged. tests/test_regression_threaded_render.py passes against the new revision (22 passed).

Timing with the same binary, masks on versus off, parse plus render at scale 1 on one thread, two runs each: default.pdf (426 pages, 138 masks) 33.2 s and 32.8 s on, 36.1 s and 29.9 s off, inside run-to-run noise; 14327672304519301096-2.pdf (6 pages, 11 masks) 0.23 s and 0.26 s on, 0.20 s off.

The whole tests/test_unit_*.py lane passes with the fix (268 passed), as do the full mypy run and the pre-commit hooks.

🤖 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)(?:\(.+\))?(!)?:

@PeterStaar-IBM
PeterStaar-IBM self-requested a review October 22, 2026 15:12
wittjeff and others added 4 commits October 27, 2026 09:10
An object painted under an ExtGState whose /SMask names a transparency
group was painted fully opaque: the key was parsed to a three-value enum
and the renderer had nothing to composite. InDesign builds drop shadows
and feathered photo edges from this mask, so those effects came out as
opaque boxes over the photos.

The mask is now composited (ISO 32000-1, 11.6.5):

- page_grph.h keeps the /SMask dictionary as a soft_mask_spec: the /G
  group, /S, /BC as device RGB and /TR sampled through pdf_function.
- grph.h snapshots the CTM at the gs, which is the mask's coordinate
  system; /SMask /None clears it; the mask survives q/Q and reaches forms.
- stream.h sub-decodes the group right after the gs, the way Do decodes
  a form, into containers of its own and once per entry and CTM. Every
  text, shape, bitmap and shading instruction painted under the mask
  shares the decoded soft_mask_instruction, like clip_state.
- blend2d_renderer.h paints the group once per page over its backdrop,
  reads it back as an A8 plane through /TR (backdrop outside /BBox) and
  caches it per mask. A masked instruction re-enters its paint routine
  with page_context() redirected to a layer; the layer is multiplied by
  the plane and blitted with the instruction's blend mode.

render_config.render_soft_masks switches the compositing off; the time
spent is reported under render_page.soft_mask. A mask set inside a mask
group, knockout and isolated attributes, and canvases above 8192 px per
side stay unhandled and are reported.

Fixes docling-project#384

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
A page can install a new soft mask with every `gs` for next to nothing,
and each decoded mask cost an instruction list at parse time and a
canvas-sized A8 plane at render time, so a hostile page could hold
gigabytes.

- The renderer keeps at most 256 MB of mask planes per page; past that
  the cache is dropped and planes are rebuilt as needed.
- The decoder decodes at most 512 distinct masks per page; the rest of
  the page paints unmasked, reported once.
- The CTM in the decode cache key is quantised to 1e-3, so float noise
  between two `gs` of the same mask does not make two decodes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
A transparency group painted under a soft mask is masked as a whole, and a
mask that the group's content installs acts inside the group (ISO 32000-1,
11.6.6). The group's alpha and blend mode were already pushed down onto its
content; the soft mask was not. A `gs` inside the group replaced the outer
mask, so only the inner mask took effect, and `/SMask /None` inside the
group removed the outer mask.

The graphics state now keeps the mask in force at the `Do` as the group
mask. Paints inside the group carry the intersection of the group mask and
their own mask, and the renderer builds its plane as the product of the two
planes. Intersections are cached per pair, like decoded masks.

Reported by dlaszlo on docling-project#384: a 50% mask inside a group painted under a 50%
mask now leaves a quarter of the paint, (191, 0, 64), as pdfium does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
complex_invisible_fonts_01.pdf page 1 paints a transparency group under a
soft mask with a second mask inside; its stored full_page.png predates soft
masks and is outside the render tolerance now. Only that image changes.

Dataset PR: https://huggingface.co/datasets/docling-project/regression-dataset-for-docling-parse/discussions/9
Re-point HF_DATASET_REVISION at the merge commit once that PR lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@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.

This branch has not been deployed

No deployments
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.

Page renderer ignores a soft mask set in the graphics state (/SMask in ExtGState)

2 participants