Skip to content

fix: keep only the resources a page uses in its one-page buffer - #427

Open
wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:fix/drop-unreferenced-page-resources
Open

wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:fix/drop-unreferenced-page-resources

Conversation

@wittjeff

@wittjeff wittjeff commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #425, as requested in #425 (comment) and #425 (comment).

Problem

The threaded parser writes every page to a standalone one-page PDF, so that pages can be decoded in parallel, and each page result keeps that buffer. The copy of the page takes along everything its /Resources names. When all pages share one /Resources (LibreOffice output, #424), every page buffer carries the compressed stream of every image in the document, whether the page draws it or not. On the #424 repro this was about 55 MB per kept page.

Change

get_thread_safe_page_buffer() now removes, from the copied page, the /Font and /XObject entries that no content stream of that page can use, before QPDFWriter::write(). The page belongs to the one-page out_pdf, so the source document and the other pages do not change. QPDFWriter does not write streams that are no longer reachable.

The new src/parse/qpdf/page_resources.h does the walk. It does not call QPDFPageObjectHelper::removeUnreferencedResources(), for two reasons:

  1. Size and run time. qpdf's helper gives every form its own copy of a shared /Resources, and the copies write the other resource categories (/ExtGState, ...) inline again. On page 2 of 2508.13113v2.pdf from the regression set, 2298 figure forms share one /Resources: qpdf 12.4.2 --pages . 2 -- --remove-unreferenced-resources=yes takes 11.9 s instead of 0.4 s and writes 94 MB instead of 1.5 MB, which is the opposite of what the page buffer needs. The walk here removes entries in place, so shared dictionaries stay shared. It also visits each stream and each dictionary once: a walk with forEachFormXObject() over the unchanged dictionaries calls its action 5.27 million times on that page, and a first version of this PR that did so took the file from 31 s to over an hour.
  2. Names that qpdf does not see. The stream decoder also resolves names against the page resources from Type3 glyph procedures without /Resources (9.6.5), tiling pattern cells, soft-mask groups and annotation appearance streams. qpdf scans only the page content and form XObjects, so it can remove an entry that docling-parse still uses.

The walk collects every name operand from all of these streams. An entry whose name occurs in none of them cannot be resolved, so it is removed. The page is written as before when a stream cannot be parsed (qpdf throws or warns), when qpdf cannot resolve an object, or when streams nest deeper than 32 levels. The walk also reaches forms that are never drawn, so the depth cap keeps a crafted chain of them from exhausting the stack.

Results

Tests

New in tests/test_unit_shared_resource_images.py:

  • test_pages_that_do_not_draw_a_shared_image_do_not_hold_its_stream: the RSS bound above (fails on main).
  • test_type3_glyph_keeps_an_image_it_takes_from_the_page_resources: a Type3 glyph draws an image that only the page resources name. The image must stay.
  • test_forms_that_share_one_xobject_dictionary_are_visited_once: 1000 forms share one /XObject dictionary; the page must parse in a few seconds.

Unit tests and the regression suite pass on HF revision 6d3458e2.

@lazariv, thank you for the offer: a run on the production PDFs behind #424 would be very welcome.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

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

@mergify

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

@wittjeff
wittjeff force-pushed the fix/drop-unreferenced-page-resources branch from 675fefb to 1b42ab6 Compare October 27, 2026 21:05
The threaded parser writes every page to a standalone one-page PDF and
keeps that buffer with the page result. When pages share one /Resources
dictionary (docling-project#424), each buffer carried the compressed stream of every
image in it, drawn or not.

Before the buffer is written, remove the /Font and /XObject entries that
no content stream of the page can resolve. The walk covers the page
content, form XObjects, Type3 glyph procedures, tiling patterns,
soft-mask groups and annotation appearance streams, since the stream
decoder resolves names from all of them against the page resources. It
visits each stream and dictionary once and edits only the /Font and
/XObject dictionaries, so shared dictionaries stay shared. qpdf's
removeUnreferencedResources() gives every form its own copy of a shared
/Resources: on page 2 of a regression PDF whose 2298 figure forms share
one /Resources, qpdf 12.4.2 spends 12 s on the page instead of 0.4 s and
writes 94 MB instead of 1.5 MB.

The page is written unchanged when a stream cannot be parsed, when qpdf
fails to resolve an object, or when streams nest deeper than 32 levels.

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

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.

1 participant