Skip to content

fix(render): clip strokes through non-rectangular clip masks - #397

Open
wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:fix/stroke-clip-mask
Open

wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:fix/stroke-clip-mask

Conversation

@wittjeff

@wittjeff wittjeff commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #378.

Problem

render_shape sends the fill through the non-rectangular clip mask, but not the stroke. The stroke is drawn directly onto the page. All the clip tests also use the bbox of the path itself, not the area the stroke paints. Measured on the 7.22.2 wheel and on main (e0e678c):

  • A stroke under a curved clip runs past it (the reporter's case). The same happens under a single W over several rectangles, which also goes through the mask.
  • A horizontal or vertical rule under any masked clip disappears when its canvas coordinate is integral. Its bbox has zero height or width, so the mask area is empty and the function returns early. This is a regression from 7.15.0 (feat(render): clip masks, shading patterns and Coons meshes; CCITT, CMYK-JPEG, tiling-pattern and CJK text fixes #323); 7.14.0 drew these rules, without the clip.
  • A stroke whose path lies just outside a rectangular clip, but whose width reaches inside it, is dropped by the rectangle reject test.
  • Separate cause, also fixed here: build_clip_mask only checks area overlap for groups that contain a curved path. A shape outside a two-rectangle W gets no mask and is painted with no clip at all.

Changes

src/render/blend2d_renderer.h:

  • render_shape computes a paint box from the stroke's reach: half the width, times √2 for square caps, times the miter limit for miter joins. The rectangle reject test and the mask window use this box.
  • The stroke goes through the clip mask, like the fill.
  • Fill and stroke are rasterised into an A8 coverage window, multiplied by the clip mask, and composited with ctx.fill_mask in the paint colour. This replaces the PRGB32 layer, and the output is pixel-identical on the cases below. fill_mask uses the context's blend mode and global alpha. Fill and stroke still composite separately, in PDF order, each with its own alpha.
  • The clip mask is built once over the clip's bounding box (clip_state_canvas_bbox). It is reused while consecutive shapes have an equal clip state (same_clip_state), and each shape reads only its own window (multiply_a8_by_a8 with an offset). The cache is reset in set_size. If the clip box is larger than the 8192 px limit of build_clip_mask, the mask is built over the shape's own window and is not kept.
  • build_clip_mask treats a group with more than one subpath as needing the mask. A shape outside the union then counts as clipped away.

Bitmaps and text keep their own build_clip_mask calls. They gain only the grouped-rectangle fix.

Performance

The reporter's candidate (a separate PRGB32 stroke layer per shape) was 2.5x slower on a dense circular-clip page. This is relevant to the CAD slowdown in docling#4490. CPU time per page render at scale 4, median of 7, with base and fix built with the same compiler and run alternately:

page main this PR
1,000 random 1 pt lines through a circular clip 87–102 ms 74–78 ms
1,000 0.5 pt rules under a two-rectangle W 25–31 ms 38–43 ms
1,000 random lines, no clip 25–31 ms 25–27 ms

The second row costs more because main drops many of those rules and draws the rest without the clip. The circular-clip row is faster than main, because each coverage window covers only the part of the line inside the clip box.

Tests

New in tests/test_unit_clipping.py. All five fail on main and pass here:

  • a diagonal stroke through a circular clip stops at the circle
  • horizontal and vertical rules under a circular clip are drawn, and clipped
  • a fill and a stroke under a two-rectangle W: nothing outside the rectangles or in the gap between them
  • a stroke whose width reaches into a rectangular clip keeps the part inside

Unit lane (tests/test_unit_*.py): 237 passed, 6 skipped.

Regression suite

test_rendered_pages_match_groundtruth passes on this branch against the pinned dataset (6d3458e2). Groundtruth does not need to be regenerated. The other 21 tests in that file also pass. I ran it on main and on this branch and compared the per-page image metrics: 29 of 940 pages change, all within tolerance. To check the direction of each change, I rendered those pages with both builds and with PDFium at scale 2. Inside the pixels that differ between main and this branch:

  • 20 pages are closer to PDFium on this branch, and none are further away.
  • 9 pages have no difference above 16 levels.

Typical changes: a stroked rounded-pill border that spilled outside its clip (921558209587984065-1.pdf, error vs PDFium 146 → 1.4), table rules under clips (15424333971388669153-2.pdf p4, 14570626672493314948-53.pdf p34–38, 11926187848345237766-2.pdf), and a dashed border that PDFium clips away (7143857365833452481-1.pdf).

Note

#390 also edits render_shape (the tiling paint). Whichever PR merges second needs a small rebase.

AI-assisted (Claude Code). I checked the reproductions, measurements and regression comparison above locally.

🤖 Generated with Claude Code

The stroke branch of render_shape painted straight onto the page, so a
stroke under a curved clip (or under one W over several rectangles) ran
past the clip. The clip tests also used the path's own bbox: a horizontal
or vertical rule has a zero-area bbox, the mask area came out empty, and
the rule disappeared (a regression since 7.15.0). A stroke whose path
lies just outside a rectangular clip, but whose width reaches inside it,
was dropped for the same reason.

- size the clip tests and the mask window by the stroke's reach
- paint the stroke through the clip mask, like the fill
- rasterise fill and stroke into an A8 coverage window and composite with
  fill_mask, instead of a PRGB32 layer
- build the mask once over the clip box and reuse it while consecutive
  shapes share the clip; fall back to a per-shape mask when the clip box
  is too large to cache
- build_clip_mask: treat a group of several rectangle subpaths as needing
  the mask, so a shape outside the union is not painted unclipped

Fixes docling-project#378

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

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

@mergify

mergify Bot commented Oct 9, 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)(?:\(.+\))?(!)?:

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.

Non-rectangular clip masks are ignored for stroked shapes; zero-height paths can disappear

1 participant