You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Page decode of dense CAD drawings 50x+ slower since 7.13.0 (minutes instead of seconds), independent of shapes_content_level #375
Since docling-parse 7.13.0, decoding a single dense vector page (an A1 architectural façade drawing, one page, 14–15 MB, Creator Archicad, Producer PDFTron PDFNet, V10.8084, PDF 1.7) takes minutes instead of seconds. shapes_content_level=SKIP does not help: the time goes into the page decode itself.
It hits docling through DoclingParsePageBackend._ensure_parsed() (docling 2.121, docling-serve 1.31.0, which locks docling-parse 7.15.0): the conversion holds the worker well past docling-serve's sync wait, and the request is cut by the HTTP timeout.
Measurements
Same page, same machine (macOS arm64, Python 3.14), DoclingPdfParser().load(...).get_page(1, content_config=...) with the content config docling 2.121 uses (chars COMPUTE, words/lines MATERIALIZE, bitmaps MATERIALIZE without bytes):
docling-parse
shapes SKIP
shapes COMPUTE
7.10.0
3.3 s
–
7.11.0
2.9 s
–
7.12.0
2.4 s
–
7.12.1
2.4 s (drawing 2: 2.3 s)
4.1 s (drawing 2: 3.8 s)
7.13.0
> 60 s (stopped)
–
7.14.0
> 60 s (stopped)
–
7.15.0
> 200 s (stopped)
> 200 s (stopped)
7.22.1
> 240 s (stopped)
> 240 s (stopped)
The output for 7.12.x is complete (270 text lines). End to end in docling-serve on an NVIDIA L4: 46 s and 86 s for the two drawings on docling-serve 1.30.0 (docling-parse 7.10.0); more than 900 s with no response on 1.31.0 (docling-parse 7.15.0).
The 7.13.0 changelog has a single functional change: render: Add tiling patterns, clipping, Type3 glyphs and CID text (#320). Clipping paths are everywhere in CAD exports, so that is my first suspect, but I have not bisected inside the release.
Follow-up: there is no workaround for docling ≥ 2.132, so docling-serve 1.36.0 is blocked for these pages.
docling-slim 2.132.0 requires docling-parse>=7.22.1. Its ThreadedDoclingParseDocumentBackend calls DoclingThreadedPdfParser.load(..., page_range=...). 7.12.1 through 7.15.0 do not accept page_range, so pinning a version from before the regression fails at load: TypeError: DoclingThreadedPdfParser.load() got an unexpected keyword argument 'page_range'.
With the versions docling 2.132 accepts, the exact docling path (docling-slim 2.132.0 + docling-parse 7.22.1, ThreadedDoclingParseDocumentBackend.iter_pages(), text cells plus page image at scale 2) takes more than 240 s on the single façade page (stopped there). docling-serve 1.30.0 converts the whole document in 86 s on an L4.
So the only working combination we have is docling 2.121 (docling-serve 1.31.0) + docling-parse 7.12.1. A fix of the 7.13.0 regression in a release that keeps the page_range API would unblock newer docling-serve.
I bisected this inside 7.13.0 with synthetic A1 pages. The regression is the tiling-pattern replay that #320 added to pdf_decoder<STREAM>::do_pattern_fill, not the clipping work. CAD exporters draw hatching as /Pattern cs /Px scn ... re f, and that path is the only one that got slower.
Scaling on 7.22.2, shapes SKIP: 2000 fills 3.6 s; 30 strokes per cell 7.6 s; the fills inside 20 nested q 9.1 s. With shapes COMPUTE (the threaded backend in docling >= 2.123): 500 fills 2.6 s, 30 strokes per cell 34 s. A facade with a few thousand hatched regions lands in the minutes the issue reports.
Why
For every fill operator whose colour is a tiling pattern, do_pattern_fill replays the pattern cell on a lattice. Each cell constructs a new pdf_decoder<STREAM>, copies the whole graphics-state stack (update_stack), copies the cell instructions, and interprets them. The lattice is clamped to 17x17, so the cap is 289 nested interpretations per fill.
With shapes_content_level=SKIP this work is pure waste twice over:
pdf_state<SHAPE>::re/m/l return early when keep_shapes is false, so the path being filled is never recorded. get_current_path_bbox then fails, the lattice falls back to the page box, and every fill (even a 12 pt square) replays the full 289 cells. The log confirms it: painting tiling pattern /P1 cells x[0, 16] y[0, 16] for every size.
Every stroke and fill the cells produce is dropped by the same keep_shapes guards, so nothing is extracted or rendered from the replay.
The shading-pattern branch of the same function already returns early when keep_shapes is false; the tiling branch does not.
return early from the tiling branch when config.keep_shapes is false, mirroring the shading branch;
parse the pattern cell once per resource (pdf_resource<PAGE_PATTERN>::parse_stream cached) instead of once per fill.
With the patch, shapes SKIP is back to 7.12.1 speed on every variant (500 fills 0.04 s, 2000 fills 0.05 s, 20-deep q 0.02 s). tests -k "pattern or tiling or shading" passes (45 tests). The only behaviour change is that text or bitmaps inside a tiling cell are no longer extracted in SKIP mode, where the cell's geometry was already discarded.
This restores the pre-7.13 timing for docling <= 2.122 (DoclingParsePageBackend, shapes SKIP). It does not help the threaded backend in docling >= 2.123, which computes shapes for the page render. For that path the per-cell replay itself is the cost. Options I see, in increasing size: cache the per-cell pdf_decoder and stack copy across the lattice instead of rebuilding them 289 times; a per-page budget of pattern cells after which a fill degrades to a flat fill in the pattern's mean colour; or emit one pattern instruction (cell instructions + matrix + steps + clip) and let the renderer tile it, which is what blend2d's pattern fill is for. #386 covers the text-only decode; the renderer-side tiling is worth a separate discussion.
How to confirm on the real drawing
Run with loglevel="info" and count the lattice lines; each one is one hatched region, with its cell range:
Follow-up: tiling patterns on the shapes-COMPUTE path (threaded backend, docling >= 2.123)
#386 restores the text-only decode. The render path still replays the pattern cell per lattice position, and the threaded backend always computes shapes because it renders the page from the same decode. Measured with the 7.22.2 wheel, DoclingThreadedPdfParser, shapes COMPUTE, one thread, 1684x2384 pt page, 500 hatched 60x60 pt regions, 4 pt cell:
cell content
decode only
decode + render at scale 2
1 stroke per cell
12.7 s
16.4 s
30 strokes per cell
171 s
185 s
control: 500 clipped strokes, no pattern
0.17 s
0.88 s
So the decoder, not blend2d, is the cost, and it scales with fills x cells x strokes-per-cell: 500 x 289 x 30 = 4.3 million register_paths calls, each pushing a page_item<PAGE_SHAPE> (with its graphics state copy) and a shape_instruction (with a fresh get_clip_state() copy of the fill's clip). The same page holds 4.3 million shape items in memory afterwards. (Side note: a local GCC 13 -O3 build runs this path about 5x faster than the manylinux GCC 8.5 wheel, so the wheel toolchain magnifies it.)
Options
Constant-factor cleanup of the replay (reuse one cell decoder across the lattice, stop copying the state stack per cell, cache the clip state per paint). Worth doing, but it leaves the O(fills x cells x strokes) item count and memory. Expect 2-5x, not 50x.
A per-page cell budget with a flat-fill fallback (fill the path with the stroke colour at alpha = the cell's ink coverage). About 30 lines in do_pattern_fill; bounds the worst case but changes the picture for anything over budget, and pages under budget stay slow.
Renderer-side tiling. This is what pdfium and mupdf do for small cells: rasterise the cell once into a tile and fill the path with it. Blend2D supports it directly: BLPattern(image, BL_EXTEND_MODE_REPEAT) with set_transform, used as the fill style of fill_path. The pieces already exist in the codebase:
the decoder already sub-decodes into a child pdf_render_instructions (widget appearance streams in page.h) and re-emits from it;
render_shading already maps a PDF matrix to canvas space (transform from scale_x_, origin_x_, the y flip) and sets it on a BLGradient; a BLPattern takes the same matrix;
render_shape already owns the clip, clip-mask and blend-mode handling for a filled path.
Shape of the change:
pdf_resource<PAGE_PATTERN>: decode the cell once per page into its own pdf_render_instructions (own cells/shapes/images sinks, identity base CTM, materialize_pattern_fill_color for PaintType 2 so the tile is keyed by colour as well).
shape_instruction: add an optional std::shared_ptr<tiling_paint> (cell instructions, pattern matrix, XStep/YStep, BBox, paint type, fill colour). do_pattern_fill then emits ONE fill instruction for the path, carrying the paint, instead of replaying. page_shapes gets one item per fill (the hatched outline), so docling's intersects_with(shapes=True) still sees vector content there, and get_shape_lines no longer sees hatch strokes as candidate rules, which is an improvement.
renderer: render_shape with a tiling paint rasterises the tile (XStep x YStep in pattern space at the canvas scale, drawn with the cell offset by +-step so cells that overlap their step still wrap), caches it per (pattern, colour, scale) for the page, and fills with BLPattern. Everything else (clip, mask, alpha, blend) is the existing path.
large cells: when the tile in device space exceeds the path's bbox (or a hard cap, say 2048 px a side), keep the direct replay but bounded by the path bbox and option 2's budget. pdfium uses the same split.
Cost after the change: decoder O(fills + cell strokes per pattern), renderer O(fills) fills plus one tile raster per pattern. On the table above that is the control row, not the pattern rows.
Recommendation
Option 3 with option 2 as the large-cell fallback, in two PRs: first the renderer-side tiling for cells whose tile fits in the fill bbox (covers every CAD hatch), then the budgeted direct replay for the rest. Option 1 is not worth a PR on its own; the pieces that survive (one decoded cell per pattern) come for free with option 3.
Open questions for the maintainers: whether page_shapes should carry the replayed cell strokes at all (today's behaviour) or only the hatched outline (proposed), and whether a tile cache keyed per page is enough or should live with the embedded font cache across pages.
The renderer-side tiling from the comment above is up as #390 (stacked on #386). On the 30-stroke-cell page the threaded decode-plus-render drops from 185 s to 1.4 s; the pages in the regression corpus that use tiling patterns render the same or better (the hatched rules the 17-cell replay cap had truncated are now complete), and need their groundtruth regenerated since a fill is now one shape item and one page image rather than its replayed cells.
Activity
racso-dev commentedon Oct 2, 2026
Follow-up: there is no workaround for docling ≥ 2.132, so docling-serve 1.36.0 is blocked for these pages.
docling-parse>=7.22.1. ItsThreadedDoclingParseDocumentBackendcallsDoclingThreadedPdfParser.load(..., page_range=...). 7.12.1 through 7.15.0 do not acceptpage_range, so pinning a version from before the regression fails at load:TypeError: DoclingThreadedPdfParser.load() got an unexpected keyword argument 'page_range'.ThreadedDoclingParseDocumentBackend.iter_pages(), text cells plus page image at scale 2) takes more than 240 s on the single façade page (stopped there). docling-serve 1.30.0 converts the whole document in 86 s on an L4.So the only working combination we have is docling 2.121 (docling-serve 1.31.0) + docling-parse 7.12.1. A fix of the 7.13.0 regression in a release that keeps the
page_rangeAPI would unblock newer docling-serve.wittjeff commentedon Oct 6, 2026
I bisected this inside 7.13.0 with synthetic A1 pages. The regression is the tiling-pattern replay that #320 added to
pdf_decoder<STREAM>::do_pattern_fill, not the clipping work. CAD exporters draw hatching as/Pattern cs /Px scn ... re f, and that path is the only one that got slower.Measurements
Synthetic 1684x2384 pt page, 500 hatched 60x60 pt rectangles, 4 pt tiling cell, reporter's content config (chars COMPUTE, words/lines MATERIALIZE, bitmaps MATERIALIZE, shapes SKIP), Linux x86_64, PyPI wheels:
Scaling on 7.22.2, shapes SKIP: 2000 fills 3.6 s; 30 strokes per cell 7.6 s; the fills inside 20 nested
q9.1 s. With shapes COMPUTE (the threaded backend in docling >= 2.123): 500 fills 2.6 s, 30 strokes per cell 34 s. A facade with a few thousand hatched regions lands in the minutes the issue reports.Why
For every fill operator whose colour is a tiling pattern,
do_pattern_fillreplays the pattern cell on a lattice. Each cell constructs a newpdf_decoder<STREAM>, copies the whole graphics-state stack (update_stack), copies the cell instructions, and interprets them. The lattice is clamped to 17x17, so the cap is 289 nested interpretations per fill.With
shapes_content_level=SKIPthis work is pure waste twice over:pdf_state<SHAPE>::re/m/lreturn early whenkeep_shapesis false, so the path being filled is never recorded.get_current_path_bboxthen fails, the lattice falls back to the page box, and every fill (even a 12 pt square) replays the full 289 cells. The log confirms it:painting tiling pattern /P1 cells x[0, 16] y[0, 16]for every size.keep_shapesguards, so nothing is extracted or rendered from the replay.The shading-pattern branch of the same function already returns early when
keep_shapesis false; the tiling branch does not.Prototype fix
Two small changes, in #386:
config.keep_shapesis false, mirroring the shading branch;pdf_resource<PAGE_PATTERN>::parse_streamcached) instead of once per fill.With the patch, shapes SKIP is back to 7.12.1 speed on every variant (500 fills 0.04 s, 2000 fills 0.05 s, 20-deep
q0.02 s).tests -k "pattern or tiling or shading"passes (45 tests). The only behaviour change is that text or bitmaps inside a tiling cell are no longer extracted in SKIP mode, where the cell's geometry was already discarded.This restores the pre-7.13 timing for docling <= 2.122 (
DoclingParsePageBackend, shapes SKIP). It does not help the threaded backend in docling >= 2.123, which computes shapes for the page render. For that path the per-cell replay itself is the cost. Options I see, in increasing size: cache the per-cellpdf_decoderand stack copy across the lattice instead of rebuilding them 289 times; a per-page budget of pattern cells after which a fill degrades to a flat fill in the pattern's mean colour; or emit one pattern instruction (cell instructions + matrix + steps + clip) and let the renderer tile it, which is what blend2d's pattern fill is for. #386 covers the text-only decode; the renderer-side tiling is worth a separate discussion.How to confirm on the real drawing
Run with
loglevel="info"and count the lattice lines; each one is one hatched region, with its cell range:wittjeff commentedon Oct 6, 2026
Follow-up: tiling patterns on the shapes-COMPUTE path (threaded backend, docling >= 2.123)
#386 restores the text-only decode. The render path still replays the pattern cell per lattice position, and the threaded backend always computes shapes because it renders the page from the same decode. Measured with the 7.22.2 wheel,
DoclingThreadedPdfParser, shapes COMPUTE, one thread, 1684x2384 pt page, 500 hatched 60x60 pt regions, 4 pt cell:So the decoder, not blend2d, is the cost, and it scales with fills x cells x strokes-per-cell: 500 x 289 x 30 = 4.3 million
register_pathscalls, each pushing apage_item<PAGE_SHAPE>(with its graphics state copy) and ashape_instruction(with a freshget_clip_state()copy of the fill's clip). The same page holds 4.3 million shape items in memory afterwards. (Side note: a local GCC 13-O3build runs this path about 5x faster than the manylinux GCC 8.5 wheel, so the wheel toolchain magnifies it.)Options
Constant-factor cleanup of the replay (reuse one cell decoder across the lattice, stop copying the state stack per cell, cache the clip state per paint). Worth doing, but it leaves the O(fills x cells x strokes) item count and memory. Expect 2-5x, not 50x.
A per-page cell budget with a flat-fill fallback (fill the path with the stroke colour at alpha = the cell's ink coverage). About 30 lines in
do_pattern_fill; bounds the worst case but changes the picture for anything over budget, and pages under budget stay slow.Renderer-side tiling. This is what pdfium and mupdf do for small cells: rasterise the cell once into a tile and fill the path with it. Blend2D supports it directly:
BLPattern(image, BL_EXTEND_MODE_REPEAT)withset_transform, used as the fill style offill_path. The pieces already exist in the codebase:pdf_render_instructions(widget appearance streams inpage.h) and re-emits from it;render_shadingalready maps a PDF matrix to canvas space (transformfromscale_x_,origin_x_, the y flip) and sets it on aBLGradient; aBLPatterntakes the same matrix;render_shapealready owns the clip, clip-mask and blend-mode handling for a filled path.Shape of the change:
pdf_resource<PAGE_PATTERN>: decode the cell once per page into its ownpdf_render_instructions(own cells/shapes/images sinks, identity base CTM,materialize_pattern_fill_colorfor PaintType 2 so the tile is keyed by colour as well).shape_instruction: add an optionalstd::shared_ptr<tiling_paint>(cell instructions, pattern matrix, XStep/YStep, BBox, paint type, fill colour).do_pattern_fillthen emits ONE fill instruction for the path, carrying the paint, instead of replaying.page_shapesgets one item per fill (the hatched outline), so docling'sintersects_with(shapes=True)still sees vector content there, andget_shape_linesno longer sees hatch strokes as candidate rules, which is an improvement.render_shapewith a tiling paint rasterises the tile (XStep x YStep in pattern space at the canvas scale, drawn with the cell offset by +-step so cells that overlap their step still wrap), caches it per (pattern, colour, scale) for the page, and fills withBLPattern. Everything else (clip, mask, alpha, blend) is the existing path.Cost after the change: decoder O(fills + cell strokes per pattern), renderer O(fills) fills plus one tile raster per pattern. On the table above that is the control row, not the pattern rows.
Recommendation
Option 3 with option 2 as the large-cell fallback, in two PRs: first the renderer-side tiling for cells whose tile fits in the fill bbox (covers every CAD hatch), then the budgeted direct replay for the rest. Option 1 is not worth a PR on its own; the pieces that survive (one decoded cell per pattern) come for free with option 3.
Open questions for the maintainers: whether
page_shapesshould carry the replayed cell strokes at all (today's behaviour) or only the hatched outline (proposed), and whether a tile cache keyed per page is enough or should live with the embedded font cache across pages.wittjeff commentedon Oct 6, 2026
The renderer-side tiling from the comment above is up as #390 (stacked on #386). On the 30-stroke-cell page the threaded decode-plus-render drops from 185 s to 1.4 s; the pages in the regression corpus that use tiling patterns render the same or better (the hatched rules the 17-cell replay cap had truncated are now complete), and need their groundtruth regenerated since a fill is now one shape item and one page image rather than its replayed cells.