Skip to content

fix: keep the /AcroForm JSON export out of the document behind a signature - #410

Open
wittjeff wants to merge 4 commits into
docling-project:mainfrom
wittjeff:fix/acroform-json-document-walk
Open

wittjeff wants to merge 4 commits into
docling-project:mainfrom
wittjeff:fix/acroform-json-document-walk

Conversation

@wittjeff

Copy link
Copy Markdown
Contributor

Fixes #399.

Problem

load() on a signed, tagged PDF spent minutes and gigabytes before any page was decoded, holding the GIL. Removing /AcroForm from the catalog made it instant, and page_range made no difference.

Cause

  • process_document_from_bytesio() built the document annotations eagerly, which runs to_json() on /AcroForm.
  • A certification or FieldMDP signature carries /Reference[*]/Data, which Acrobat and iText point at the catalog. to_json() followed that reference into the page tree and the structure tree.
  • to_json() detected cycles with a set of unparse() texts of the objects on the current path, copied into every recursive call. A shared object was expanded once per path, so every /Pg in the structure tree expanded its page again, fonts included. max_level was also not passed down the recursion.

Measured on a 33-page tagged PDF from the regression corpus (11586287754644715199-16.pdf: 15.6k structure elements, 6.4k /Pg references) with one synthetic DocMDP signature added with pikepdf:

variant before after
untouched 0.02 s, 125 MB 0.02 s, 85 MB
signature without /Reference 0.02 s 0.02 s
signature with /Data → catalog 48.6 s, 2.0 GB 0.06 s, 85 MB
same, /StructTreeRoot removed 0.12 s –

(DoclingThreadedPdfParser().load(path, page_range=(1, 1)), peak RSS of the process.) A 91-page untagged PDF with the same signature loaded in 0.02 s before and after: the structure tree is the multiplier.

Change

  1. to_json() stops at nested dictionaries whose /Type is /Catalog, /Pages, /Page, /StructTreeRoot or /StructElem and writes "[skipping /Catalog object '1 0 R']", the way it already skips the /Parent and /P keys. A page or the catalog is never form or annotation content.
  2. The cycle guard keys indirect objects by QPDFObjGen on one set passed by reference and unwound on return: no unparse(), no copies. Direct objects are not tracked, since they cannot close a cycle. The output for every existing case is unchanged; a cycle still yields "[ref to previous object: 'N 0 R']".
  3. max_level is passed down the recursion. The callers that passed 16 now use the default 32, which was the effective depth before; the two font-program callers keep their explicit 2 (descriptor_json and stream_dict_json are not read anywhere).
  4. The document annotations (form, metadata, language, outline) are built on the first get_annotations(), get_meta_xml() or get_table_of_contents() call, under thread_safe_buffer_mutex, instead of during load. A conversion that only reads pages no longer pays for them. The extract_doc_annotations timing is recorded when they are first requested.

Points 1 to 3 are one commit, point 4 a second one, so it can be dropped on its own if you prefer to keep the eager build.

Not included

Releasing the GIL during the native load, as suggested in the issue. After this change the load is qpdf's parse only. Releasing the GIL there needs a mutex around the per-key decoder maps against concurrent load() calls on one parser, which is a separate change.

Tests

  • tests/test_unit_signed_form_json.py: an in-memory signed, tagged fixture. Asserts that the form export keeps the signature fields and never reaches the page tree or the structure tree, with both parsers.
  • The annotation, outline and widget tests pass. Full tests/test_unit_*.py + tests/test_regression_parse.py against the pinned dataset: 269 passed, 6 skipped (pre-existing), 0 failed.

🤖 Generated with Claude Code

wittjeff and others added 2 commits October 16, 2026 17:08
…ature

A certification or FieldMDP signature carries /Reference[*]/Data, which
Acrobat and iText point at the catalog. to_json() followed it into the
page tree and the structure tree, and its cycle guard, a set of unparse()
texts of the objects on the current path copied into every call, expanded
every page again for each /Pg in the structure tree. Loading a signed,
tagged document took minutes and gigabytes before any page was decoded.

- stop at nested /Catalog, /Pages, /Page, /StructTreeRoot and /StructElem
  dictionaries and write a reference, as for the /Parent and /P keys
- key the cycle guard on QPDFObjGen for indirect objects only, on one set
  passed by reference and unwound on return
- pass max_level down the recursion; the callers that passed 16 use the
  default 32, which was the effective depth before

33-page tagged PDF with one DocMDP signature, load(page_range=(1, 1)):
48.6 s and 2.0 GB before, 0.06 s and 85 MB after.

Fixes docling-project#399

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
process_document_from_bytesio() built the form, metadata, language and
outline JSON on every load. They are now built on the first call to
get_annotations(), get_meta_xml() or get_table_of_contents(), under
thread_safe_buffer_mutex, since a worker of the threaded parser may be
copying a page out of qpdf_document at that moment. A conversion that
only reads pages no longer pays for them.

Refs docling-project#399

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @wittjeff, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 16, 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)(?:\(.+\))?(!)?:

@PeterStaar-IBM
PeterStaar-IBM self-requested a review October 22, 2026 15:13
@PeterStaar-IBM

Copy link
Copy Markdown
Member

@wittjeff can you fix the merge conflict?

Resolves the conflict in src/parse/pdf_decoders/document.h with docling-project#415,
which added qpdf_reported_damage next to json_annots: both are kept.

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

Copy link
Copy Markdown
Member

@wittjeff We seem to have another merge conflict (just merged another of your PR's).

Resolve the document.h conflict with docling-project#414: keep the load_error fields and the lazy-annotations comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com>
@wittjeff

Copy link
Copy Markdown
Contributor Author

Merged main into the branch (bccca15). The only conflict was in document.h: #414 added the load-error fields at the same spot where this PR documents the lazily built annotations. I kept both. Full test suite passes locally with the merged build (408 passed, 1 skipped).

This branch has not been deployed

No deployments
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.

load() spends minutes and GBs on the AcroForm while holding the GIL (signed PDFs)

2 participants