Repository navigation
Conversation
Contributor
|
✅ DCO Check Passed Thanks @wittjeff, all your commits are properly signed off. 🎉 |
Contributor
Merge Protections🟢 Merge protection satisfied — ready to merge. Show 1 satisfied protection🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
|
PeterStaar-IBM
self-requested a review
October 22, 2026 15:12
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>
wittjeff
force-pushed
the
fix/extgstate-soft-mask
branch
from
October 27, 2026 13:47
c1b3d70 to
bfb443c
Compare
7 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #384.
Summary
An object painted under an ExtGState whose
/SMasknames 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 /Luminosityand/S /Alpha,/BBox,/BCand/TRare honoured; the mask's coordinate system is the CTM at thegs, 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.
page_grph.h). The/SMaskdictionary is kept on the ExtGState entry as asoft_mask_spec: the/Gstream, the mask type,/BCas device RGB and/TRsampled to a 256-entry table throughpdf_function. A mask that cannot be read is reported and clears the current one, which is the pre-existing behaviour.grph.h).gssnapshots the CTM together with the entry;/SMask /Noneclears both. The decoded mask is part of the graphics state, so it survivesq/Qand reaches forms.stream.h). Right aftergs, the stream decoder sub-decodes the/Ggroup the wayDodecodes a form, into containers of its own (its cells, shapes and images are coverage, not page content), seeded with/Matrixcomposed 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, likeclip_state.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 withpage_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 onRenderConfig) switches the compositing off. The time spent is reported underrender_page.soft_mask.Scope limits
Each case is reported with a warning, as #306 does:
Doare 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.build_clip_maskuses.gsfor 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-gsrule, 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):8 pages move closer to pdfium, none move away, 15 are unchanged within 0.05. The largest change is
complex_invisible_fonts_01.pdfpage 1 (17.19 to 3.55), a magazine cover whose photo is a transparency group painted under a mask with a second mask inside. On14327672304519301096-2.pdfpage 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.pdfpage 1 differs from its stored groundtruth bymean_abs_error10.54 (limit 10) andchanged_pixels_ratio0.31 (limit 0.15), because the stored render predates soft masks. Itsfull_page.pngis regenerated in dataset PR #9 (one file, on top of the current pin6d3458e2), andHF_DATASET_REVISIONpoints 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.pypasses 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_*.pylane passes with the fix (268 passed), as do the full mypy run and the pre-commit hooks.🤖 Generated with Claude Code