Skip to content

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

Open

Description

@rasokiwayami

With docling-parse 7.22.1, a blue diagonal drawn through a circular clip extends outside the circle. The clip is respected for fills. This is a small synthetic correctness reproducer, not a reproduction of the original CAD or the reported 50x version regression in docling-project/docling#4490.

Expected: only the part of the stroke inside the circle is visible.
Actual: the complete diagonal is visible. Replacing the diagonal with a horizontal line also exposes a related case where the stroke disappears because its path bounding box has zero height.

Environment: macOS arm64, Python 3.14.6, docling-parse 7.22.1. Source inspected and built locally: 6b5b6fd0c70fbcde43bc35d0dad004047fdee7a2; the renderer matches the 7.22.1 source.

Minimal PDF, standard library only

Save as minimal_circle_stroke.py and run it. No fonts, images, or private documents are involved.

"""Standard-library-only PDF reproducer; no fonts or external assets."""
from pathlib import Path

content = b'''q
150 100 m
150 127.614237 127.614237 150 100 150 c
72.385763 150 50 127.614237 50 100 c
50 72.385763 72.385763 50 100 50 c
127.614237 50 150 72.385763 150 100 c h W n
0 0 1 RG 8 w
10 40 m 190 160 l S
Q
'''
objects = [
    b'<< /Type /Catalog /Pages 2 0 R >>',
    b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>',
    b'<< /Length '+str(len(content)).encode()+b' >>\nstream\n'+content+b'endstream',
]
pdf = b'%PDF-1.7\n'
offsets = []
for index, obj in enumerate(objects, 1):
    offsets.append(len(pdf))
    pdf += str(index).encode()+b' 0 obj\n'+obj+b'\nendobj\n'
xref = len(pdf)
pdf += b'xref\n0 5\n0000000000 65535 f \n'
for offset in offsets:
    pdf += f'{offset:010d} 00000 n \n'.encode()
pdf += f'trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode()
Path('circle-stroke.pdf').write_bytes(pdf)

Save the following as render_minimal.py in an environment with docling-parse installed:

"""Render the minimal PDF with an installed docling-parse wheel."""
from io import BytesIO
from pathlib import Path
import sys
from docling_parse.pdf_parser import DoclingThreadedPdfParser, RenderConfig, ThreadedPdfParserConfig

render = RenderConfig()
render.scale = 2.0
parser = DoclingThreadedPdfParser(parser_config=ThreadedPdfParserConfig(
    loglevel="fatal", threads=1, max_concurrent_results=1, render_config=render))
try:
    parser.load(BytesIO(Path(sys.argv[1]).read_bytes()))
    result = next(parser.iterate_results())
    assert result.success, result.error_message
    result.get_image().save(sys.argv[2])
finally:
    parser.unload_all()
python minimal_circle_stroke.py
python render_minimal.py circle-stroke.pdf circle-stroke.png

To reproduce the disappearing horizontal stroke, replace 10 40 m 190 160 l S with 10 100 m 190 100 l S in the PDF content.

Investigation and bounded candidate

In render_shape (src/render/blend2d_renderer.h), build_clip_mask creates a non-rectangular coverage mask, but only the fill branch uses it. The stroke branch calls ctx.stroke_path(path) directly. The mask/rejection bounding box also excludes stroke width, caps, and joins.

A local candidate applies the mask to a separate stroke layer after the fill, and expands the painting bounds to include stroke extent. It preserves the existing fill/stroke alpha and blend order. On a synthetic circle-clipped 1,000-line page, outside-circle blue pixels (2px margin) changed from 183,800 to 0. Existing fill-only and rectangular-clip control images were pixel-identical. An 18-case native fixture suite passed 92 checks, and the upstream clipping/transparency assertions plus seven additional cases passed 21 tests through a native CLI adapter. This is not a rebuilt Python wheel or a full regression-corpus run.

Performance limitation: the correctness candidate is slower for the dense circular-clip fixture: 1.533s to 3.842s (2.51x, median of three serial CLI runs including parsing and PNG output). Peak process RSS was 112.55 MiB versus 111.17 MiB. A single-stroke fixture stayed about 48ms. This candidate does not solve the performance report; mask reuse or a cheaper stroke coverage route would need separate work. No backend switch or mask disabling is proposed.

The pre-existing difference from PDFium for translucent B fill/stroke knockout behavior remains unchanged. Oversized masks, allocation failures, nonzero crop origins, and the full corpus have not been verified.

AI-assisted investigation. The reproducer above was executed against the installed wheel and both native builds; the correctness and timing limits are stated explicitly.

Activity

  1. wittjeff commented on Oct 9, 2026

    @wittjeff
    Contributor

    Thanks for the clear reproducer. I confirmed both cases on the 7.22.2 wheel and on main (e0e678c), and found two more cases with the same causes. A fix is in #397.

    What goes wrong in render_shape

    1. The stroke branch never uses the clip mask, as you describe. This affects every clip that goes through the mask: curved clips, and also a single W over several rectangles (20 20 70 160 re 110 20 70 160 re W n). Those are drawn as a union, which clip_to_rect cannot express.
    2. The zero-height case 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). The clip tests use the bbox of the path itself. For a horizontal or vertical rule that box has zero height or width, so the mask area is empty and the shape returns early. 7.14.0 still drew those rules (without the clip). Under any masked clip, rules at integral canvas coordinates disappear. Rules at fractional coordinates get a 1 px mask area and are drawn without the clip.
    3. The rectangular-clip reject test has the same cause. A stroke whose path lies just outside a re W n clip, but whose width reaches inside, is dropped.
    4. A fill (not only a stroke) can leak too. build_clip_mask only checks the area overlap for groups that contain a non-rectangular path. With a two-rectangle W and a shape outside both rectangles, no group path reaches the mask, no mask is built, and the caller paints the shape with no clip. 0 0 200 15 re f under the clip above paints all 12,000 px at scale 2.

    Performance

    I agree with your point that masking each stroke on its own is too slow for dense drawings (docling#4490). Most of the cost is per-pixel work over the mask window: rasterize the clip, clear a PRGB32 layer, multiply, blit. The PR changes three things:

    • The clip mask is built once over the clip's bounding box. It is reused while consecutive shapes have the same clip state, and each shape reads only its own window.
    • Fill and stroke are rasterized into an A8 coverage window and composited with ctx.fill_mask in the paint colour. This replaces the PRGB32 layer and gives pixel-identical output. fill_mask keeps the context's blend mode, and fill and stroke still composite separately, each with its own alpha.
    • The mask window is the shape's paint box intersected with the clip box. A long line through a small clip no longer pays for its whole bounding box.

    CPU time per page render at scale 4 (process user+sys time, median of 7, base and fix built with the same compiler and run alternately):

    page main fix
    1,000 random 1 pt lines through a circular clip 87–102 ms 74–78 ms
    1,000 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 on the fix because main drops many of those rules and draws the rest without the clip.

    The render groundtruth suite passes unchanged on #397. 29 of 940 pages change within tolerance, and every change that is visible moves closer to PDFium. The PR has the details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions