Skip to content

fix(render): patch Blend2D so a sub-pixel stroked curve cannot hang rendering - #396

Closed
wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/blend2d-stroke-offset-quad-stall
Closed

wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/blend2d-stroke-offset-quad-stall

Conversation

@wittjeff

@wittjeff wittjeff commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #393. Stacked on #387: it reuses that PR's cmake/apply_patch.cmake and adds a second PATCH_COMMAND step. The first commit (d91aea1) is #387; only the second commit (c4a0d00) is new here. Merge #387 first; I can rebase this onto main if you prefer to take them separately.

Problem. With rendering enabled, page 29 of the reporter's document never finished: one core at 100 %, memory flat, inside the native call, so neither Python nor Docling's document_timeout could stop it. The trigger is one hairline (0 w) with round caps and joins, a line and a cubic, scaled to about 0.6 x 0.4 px on the canvas.

Cause. Blend2D's stroker (PathStroker::offset_quad()) splits each quad of the cubic's approximation where its tangent has turned by a fixed angle. One quad was nearly collinear and turned back on itself. After a split landed on the turning point, the derivative of the rest was only rounding noise, the next split parameter came from that noise, and the split returned the same curve forever. Details and measurements are in blend2d/blend2d#269 and blend2d/blend2d#270.

Change.

  • cmake/blend2d-stroke-offset-quad-stall.patch: the Blend2D fix. If a split returns the same curve, offset the rest of the part as its chord and leave the loop. It only triggers where the old loop could not terminate, so it changes no output that was produced before (bit-identical stroked outlines in 256,446 swept cases).
  • cmake/extlib_blend2d.cmake: apply it after the fix(render): patch Blend2D so a sheared image at the clip edge does not segfault #387 patch. Drop both when the pin moves past the upstream fixes.
  • tests/test_unit_subpixel_stroke_render.py: the reporter's reproducer, stroked in black, rendered at scale 2 in a child process with a 60 s timeout. It also checks that the stroke's ink lands inside the stroke's box.

Validation (local, Linux x86-64, GCC 13).

  • The new test fails on the 7.22.2 wheel (timeout) and passes on this branch. The reporter's script renders the page in 0.04 s.
  • tests/test_unit_*.py: 239 passed, 1 skipped.
  • A/B render of all 447 PDFs in tests/data/regression at scale 2 against a build of fix(render): patch Blend2D so a sheared image at the clip edge does not segfault #387: all 3,364 page images byte-identical. Two documents hit the 180 s per-document limit in both runs; their pages that rendered in either run are identical too.

🤖 Generated with Claude Code

wittjeff and others added 2 commits October 6, 2026 12:13
…ot segfault

Blend2D's JIT analytic fill reused a stale span length when the last
coverage word of a scanline was all ones, which happens when an affine
image blit's edge crosses the right clip edge and the clip width is
252..255 mod 256. The zero-length span then ran the per-pixel loop 2^32
times and walked the destination pointer off the heap. Page 7 of the
PDF in docling-project#365 hits exactly that at scale 3.0 (1279 px canvas).

Carry the upstream fix (blend2d/blend2d#268, for blend2d/blend2d#267) as
a FetchContent PATCH_COMMAND on the pinned commit, applied once by
cmake/apply_patch.cmake, and add a regression test that renders a
synthetic page on the crashing geometry in a child process.

Fixes docling-project#365

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

Blend2D's stroker offsets each quad of a cubic's approximation by splitting
it where its tangent has turned by a fixed angle, until the split parameter
falls outside (1e-5, 1 - 1e-5). On a nearly collinear quad that turns back
on itself, a split can land on the turning point, where the derivative of
the rest is only rounding noise. The next parameter then comes from that
noise, passes the check, and the split returns the same curve, so the loop
never ends. A hairline cubic of about 0.6 x 0.4 px hung get_image() inside
the native call, where neither Python nor Docling's document timeout can
stop it.

Carry blend2d/blend2d#270 as a second FetchContent patch step: when a split
returns the same curve, offset the rest of the part as its chord and leave
the loop. It only triggers where the old loop could not terminate, so no
output that rendered before changes. Add a regression test that renders the
reporter's page in a child process with a timeout.

Fixes docling-project#393

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

@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.

@PeterStaar-IBM

Copy link
Copy Markdown
Member

superseded by #433

@wittjeff
wittjeff deleted the fix/blend2d-stroke-offset-quad-stall branch October 31, 2026 16:05
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 rendering never returns on a sub-pixel stroked curve (Blend2D stroke_path loop)

3 participants