Repository navigation
Non-rectangular clip masks are ignored for stroked shapes; zero-height paths can disappear #378
Description
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.pngTo 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
wittjeff commentedon Oct 9, 2026
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_shapeWover several rectangles (20 20 70 160 re 110 20 70 160 re W n). Those are drawn as a union, whichclip_to_rectcannot express.re W nclip, but whose width reaches inside, is dropped.build_clip_maskonly checks the area overlap for groups that contain a non-rectangular path. With a two-rectangleWand 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 funder 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:
ctx.fill_maskin the paint colour. This replaces the PRGB32 layer and gives pixel-identical output.fill_maskkeeps the context's blend mode, and fill and stroke still composite separately, each with its own alpha.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):
WThe second row costs more on the fix because
maindrops 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.