Skip to content

Page decode of dense CAD drawings 50x+ slower since 7.13.0 (minutes instead of seconds), independent of shapes_content_level #375

Open

Description

@racso-dev

Bug

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.

Reproduction

import sys, time
from docling_parse.pdf_parser import ContentConfig, ContentLevel, DecodeConfig, DoclingPdfParser

C, M, K = ContentLevel.COMPUTE, ContentLevel.COMPUTE_AND_MATERIALIZE, ContentLevel.SKIP
cfg = ContentConfig(char_cells_content_level=C, word_cells_content_level=M, line_cells_content_level=M,
                    shapes_content_level=K, bitmaps_content_level=M, include_bitmap_bytes=False)
t0 = time.time()
doc = DoclingPdfParser(loglevel="fatal").load(path_or_stream=sys.argv[1], lazy=True,
                                               decode_config=DecodeConfig(enforce_same_font=True))
page = doc.get_page(1, content_config=cfg)
print(f"{time.time() - t0:.1f}s lines={len(page.textline_cells)}")

The drawings are customer documents I cannot attach. I can run any build or instrumented script on them and report back.

Workaround

We pin docling-parse==7.12.1 under docling 2.121, which still satisfies docling's >=7.12.0.

Possibly related: docling-project/docling#4490 (the threaded parser on the same kind of page).

Activity

  1. racso-dev commented on Oct 2, 2026

    @racso-dev
    Author

    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.

  2. wittjeff commented on Oct 6, 2026

    @wittjeff
    Contributor

    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:

    page content 7.12.1 7.13.0 7.22.2
    500 tiling-pattern fills 0.06 s 0.87 s 1.06 s
    500 flat fills (control) 0.06 s 0.06 s 0.04 s
    500 clipped strokes (control) 0.06 s 0.06 s 0.04 s

    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:

    1. 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.
    2. 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.

    Prototype fix

    Two small changes, in #386:

    • 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:

    import sys
    from docling_parse.pdf_parser import ContentConfig, ContentLevel, DoclingPdfParser
    C, M, K = ContentLevel.COMPUTE, ContentLevel.COMPUTE_AND_MATERIALIZE, ContentLevel.SKIP
    cfg = ContentConfig(char_cells_content_level=C, word_cells_content_level=M, line_cells_content_level=M,
                        shapes_content_level=K, bitmaps_content_level=M, include_bitmap_bytes=False)
    doc = DoclingPdfParser(loglevel="info").load(path_or_stream=sys.argv[1], lazy=True)
    doc.get_page(1, content_config=cfg)
    python check.py facade.pdf 2>&1 | grep -c "painting tiling pattern"
    
  3. wittjeff commented on Oct 6, 2026

    @wittjeff
    Contributor

    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

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

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

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

  4. wittjeff commented on Oct 6, 2026

    @wittjeff
    Contributor

    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.

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