From 67aa71188d35bdab18e1bff640984a432b52a3d2 Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:28:10 -0400 Subject: [PATCH] fix: keep only the resources a page uses in its one-page buffer 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 (#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 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- src/parse/pdf_decoders/document.h | 5 + src/parse/qpdf/page_resources.h | 309 ++++++++++++++++++++++ tests/test_unit_shared_resource_images.py | 168 ++++++++++-- 3 files changed, 460 insertions(+), 22 deletions(-) create mode 100644 src/parse/qpdf/page_resources.h diff --git a/src/parse/pdf_decoders/document.h b/src/parse/pdf_decoders/document.h index 21459d33b..8b6ea7f31 100644 --- a/src/parse/pdf_decoders/document.h +++ b/src/parse/pdf_decoders/document.h @@ -17,6 +17,7 @@ #include #include #include +#include namespace pdflib { @@ -417,6 +418,10 @@ namespace pdflib source_acroform_helper()); } + // Without this, every page buffer carries the streams of all images + // that pages sharing one /Resources name, drawn or not. + qpdf_page_resources::remove_unreferenced_resources(out_page); + QPDFWriter writer(out_pdf); writer.setOutputMemory(); writer.setObjectStreamMode(qpdf_o_preserve); diff --git a/src/parse/qpdf/page_resources.h b/src/parse/qpdf/page_resources.h new file mode 100644 index 000000000..34583708a --- /dev/null +++ b/src/parse/qpdf/page_resources.h @@ -0,0 +1,309 @@ +//-*-C++-*- + +#ifndef QPDF_PAGE_RESOURCES_H +#define QPDF_PAGE_RESOURCES_H + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace pdflib +{ + namespace qpdf_page_resources + { + // Every name operand of a content stream. It collects more than the + // `Tf` and `Do` operands, which only keeps more entries. + class name_collector : public QPDFObjectHandle::ParserCallbacks + { + public: + + std::set& names; + + explicit name_collector(std::set& names_): + names(names_) + {} + + void handleObject(QPDFObjectHandle obj) override + { + if(obj.isName()) + { + names.insert(obj.getName()); + } + } + + void handleEOF() override {} + }; + + // Walks every content stream the stream decoder may run for one page + // and every resource dictionary those streams can resolve a name in. + // + // The decoder resolves a name against the innermost resources first and + // then against the enclosing ones up to the page: in form XObjects, in + // Type3 glyph procedures (9.6.5), in tiling pattern cells, in soft-mask + // groups, and in appearance streams, which fall back to the page fonts + // and XObjects. A /Font or /XObject entry whose name occurs in none of + // these streams can therefore never be resolved. + // + // Each stream and dictionary is visited once. The forms of one figure + // often share a single /XObject dictionary that names all of them, and + // qpdf's forEachFormXObject() calls its action once per entry of every + // visit: on a page with 2301 such forms that is millions of calls. + class resource_walk + { + public: + + std::set names; + + // The /Font and /XObject dictionaries seen, each once. + std::vector groups; + + // False when a stream could not be parsed, so that its names are + // unknown, or when the streams nest deeper than max_depth. + bool complete = true; + + void visit_page(QPDFPageObjectHelper& page) + { + parse(page.getObjectHandle(), [&page](name_collector& collector) + { + page.parseContents(&collector); + }); + + visit_resources(page.getObjectHandle().getKey("/Resources")); + visit_annotations(page.getObjectHandle()); + } + + private: + + // Every level of nesting -- a form in a form, a Type3 font used in a + // glyph procedure, a pattern in a pattern -- passes through + // visit_resources(). The walk recurses, and it also reaches forms that + // are never drawn, so a crafted chain of them must not exhaust the + // stack. + static constexpr int max_depth = 32; + int depth = 0; + + std::set seen; + + // Direct objects have no identity, so they are always visited; only + // indirect ones can be shared or form a cycle. + bool first_visit(QPDFObjectHandle obj) + { + return not obj.isIndirect() or seen.insert(obj.getObjGen()).second; + } + + template + void parse(QPDFObjectHandle obj, parser_type parser) + { + // qpdf reports a bad token as a warning and goes on, so a token it + // skipped could have been a name. + QPDF* owner = obj.getOwningQPDF(); + size_t warnings = owner ? owner->numWarnings() : 0; + + try + { + name_collector collector(names); + parser(collector); + } + catch(const std::exception&) + { + complete = false; + } + + if(owner and owner->numWarnings() > warnings) + { + complete = false; + } + } + + void scan(QPDFObjectHandle stream) + { + if(not stream.isStream() or not first_visit(stream)) + { + return; + } + + parse(stream, [&stream](name_collector& collector) + { + stream.parseAsContents(&collector); + }); + + visit_resources(stream.getDict().getKey("/Resources")); + } + + void visit_annotations(QPDFObjectHandle page) + { + QPDFObjectHandle annots = page.getKey("/Annots"); + if(not annots.isArray()) + { + return; + } + + for(auto annot : annots.getArrayAsVector()) + { + QPDFObjectHandle ap = annot.isDictionary() ? annot.getKey("/AP") : QPDFObjectHandle::newNull(); + if(not ap.isDictionary()) + { + continue; + } + + for(const char* key : {"/N", "/R", "/D"}) + { + QPDFObjectHandle appearance = ap.getKey(key); + if(appearance.isStream()) + { + scan(appearance); + } + else if(appearance.isDictionary()) + { + // one stream per appearance state, e.g. /On and /Off + for(auto const& state : appearance.getDictAsMap()) + { + scan(state.second); + } + } + } + } + } + + void visit_resources(QPDFObjectHandle resources) + { + if(not complete or not resources.isDictionary() or not first_visit(resources)) + { + return; + } + + if(depth >= max_depth) + { + complete = false; + return; + } + + depth += 1; + visit_groups(resources); + depth -= 1; + } + + void visit_groups(QPDFObjectHandle resources) + { + QPDFObjectHandle fonts = resources.getKey("/Font"); + if(fonts.isDictionary() and first_visit(fonts)) + { + groups.push_back(fonts); + for(auto const& font : fonts.getDictAsMap()) + { + visit_type3_font(font.second); + } + } + + QPDFObjectHandle xobjects = resources.getKey("/XObject"); + if(xobjects.isDictionary() and first_visit(xobjects)) + { + groups.push_back(xobjects); + for(auto const& xobject : xobjects.getDictAsMap()) + { + if(xobject.second.isFormXObject()) + { + scan(xobject.second); + } + } + } + + QPDFObjectHandle patterns = resources.getKey("/Pattern"); + if(patterns.isDictionary() and first_visit(patterns)) + { + for(auto const& pattern : patterns.getDictAsMap()) + { + // a tiling pattern is a stream, a shading pattern is not + scan(pattern.second); + } + } + + QPDFObjectHandle states = resources.getKey("/ExtGState"); + if(states.isDictionary() and first_visit(states)) + { + for(auto const& state : states.getDictAsMap()) + { + QPDFObjectHandle mask = state.second.isDictionary() ? state.second.getKey("/SMask") : QPDFObjectHandle::newNull(); + if(mask.isDictionary()) + { + scan(mask.getKey("/G")); + } + } + } + } + + void visit_type3_font(QPDFObjectHandle font) + { + if(not font.isDictionary() + or not font.getKey("/Subtype").isNameAndEquals("/Type3") + or not first_visit(font)) + { + return; + } + + QPDFObjectHandle procs = font.getKey("/CharProcs"); + if(procs.isDictionary()) + { + for(auto const& proc : procs.getDictAsMap()) + { + scan(proc.second); + } + } + + visit_resources(font.getKey("/Resources")); + } + }; + + // Removes the fonts and XObjects that a page copied out of its document + // can never use (docling-parse#424). A producer such as LibreOffice + // points every page at one /Resources dictionary that names every image + // of the document, so without this each one-page buffer carries the + // compressed streams of all of them. + // + // The dictionaries are changed in place, so the page must belong to a + // document of its own. The streams they named stay in the document but + // are no longer reachable, and QPDFWriter does not write them. The page + // is left as it is when the walk is not complete or fails. + inline bool remove_unreferenced_resources(QPDFPageObjectHelper& page) + { + resource_walk walk; + try + { + walk.visit_page(page); + } + catch(const std::exception&) + { + // a damaged object qpdf could not resolve + return false; + } + + if(not walk.complete) + { + return false; + } + + bool removed = false; + for(auto& group : walk.groups) + { + for(auto const& key : group.getKeys()) + { + if(walk.names.count(key) == 0) + { + group.removeKey(key); + removed = true; + } + } + } + + return removed; + } + } +} + +#endif diff --git a/tests/test_unit_shared_resource_images.py b/tests/test_unit_shared_resource_images.py index 4db94c8cd..f6e6f456f 100644 --- a/tests/test_unit_shared_resource_images.py +++ b/tests/test_unit_shared_resource_images.py @@ -7,17 +7,26 @@ with a handful of Flate photos cost gigabytes as soon as a caller kept its pages. Only an image a page actually paints with `Do` may cost that page its pixels. + +The threaded parser decodes each page from a one-page copy of the document, +and the page result keeps that copy. The copy took along every stream the +shared dictionary names, so a page also held the compressed bytes of images +it never draws. That costs little for a flat-colour image and as much as the +pixels for a photograph. """ from __future__ import annotations +import random +import time import zlib from io import BytesIO from pathlib import Path import pytest -from tests.pdf_builder import build_pdf, content_stream, stream_object +from tests.pdf_builder import build_pdf, content_stream, render_page, stream_object +from tests.rendering_regression import average_color, coverage_ratio, region_image STATUS = Path("/proc/self/status") @@ -25,6 +34,9 @@ IMAGE_W, IMAGE_H = 4000, 3000 DECODED_MB = IMAGE_W * IMAGE_H * 3 / 2**20 # ~34 MB +NOISE_W, NOISE_H = 2000, 2000 +NOISE_MB = NOISE_W * NOISE_H * 3 / 2**20 # ~11 MB, and Flate cannot shrink it + def _rss_mb() -> float: for line in STATUS.read_text().splitlines(): @@ -33,14 +45,16 @@ def _rss_mb() -> float: raise AssertionError("no VmRSS in /proc/self/status") -def _shared_resources_pdf() -> bytes: - """Pages 1..N share one /Resources naming one large image; page 1 draws it.""" - pixels = bytes([200, 40, 40]) * (IMAGE_W * IMAGE_H) - image = stream_object( - f"/Type /XObject /Subtype /Image /Width {IMAGE_W} /Height {IMAGE_H} " +def _flate_rgb_image(width: int, height: int, pixels: bytes, level: int) -> bytes: + return stream_object( + f"/Type /XObject /Subtype /Image /Width {width} /Height {height} " "/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", - zlib.compress(pixels, 9), + zlib.compress(pixels, level), ) + + +def _shared_resources_pdf(image: bytes, *, drawn: bool = True) -> bytes: + """Pages 1..N share one /Resources naming one image; page 1 draws it if `drawn`.""" text = "BT /F1 12 Tf 20 170 Td (shared resources) Tj ET" draw = "q 160 0 0 120 20 20 cm /Im0 Do Q" @@ -60,14 +74,22 @@ def _shared_resources_pdf() -> bytes: f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " f"/Resources 5 0 R /Contents {page_obj + 1} 0 R >>" ) - objects.append(content_stream(text + ("\n" + draw if page == 0 else ""))) + objects.append( + content_stream(text + ("\n" + draw if drawn and page == 0 else "")) + ) objects[1] = f"<< /Type /Pages /Kids [{' '.join(kids)}] /Count {N_PAGES} >>" return build_pdf(objects) -@pytest.mark.skipif(not STATUS.exists(), reason="reads VmRSS from /proc") -def test_pages_that_do_not_draw_a_shared_image_do_not_hold_its_pixels(): +def _keep_all_pages( + pdf: bytes, *, n_pages: int = N_PAGES +) -> tuple[dict[int, int], float]: + """Parse every page and keep the results. + + Returns the number of bitmaps on each page and the RSS growth in MB while + the results are alive. + """ from docling_parse.pdf_parser import ( DecodeConfig, DoclingThreadedPdfParser, @@ -78,28 +100,130 @@ def test_pages_that_do_not_draw_a_shared_image_do_not_hold_its_pixels(): parser_config=ThreadedPdfParserConfig( loglevel="fatal", threads=1, - max_concurrent_results=N_PAGES, + max_concurrent_results=n_pages, render_config=None, ), decode_config=DecodeConfig(keep_qpdf_warnings=False), ) - parser.load(BytesIO(_shared_resources_pdf())) + parser.load(BytesIO(pdf)) before = _rss_mb() try: kept = list(parser.iterate_results()) grown = _rss_mb() - before - assert [r.success for r in kept] == [True] * N_PAGES + assert [r.success for r in kept] == [True] * n_pages drawn = {r.page_number: len(r.get_page().bitmap_resources) for r in kept} - assert drawn == {1: 1, **dict.fromkeys(range(2, N_PAGES + 1), 0)} - - # Page 1 holds the image once (as decoded samples); the other pages - # hold nothing of it. Decoding it on every page grows by N_PAGES times - # that. - assert grown < 3 * DECODED_MB, ( - f"keeping {N_PAGES} page results grew RSS by {grown:.0f} MB; " - f"one decoded copy of the shared image is {DECODED_MB:.0f} MB" - ) + return drawn, grown finally: parser.unload_all() + + +@pytest.mark.skipif(not STATUS.exists(), reason="reads VmRSS from /proc") +def test_pages_that_do_not_draw_a_shared_image_do_not_hold_its_pixels(): + pixels = bytes([200, 40, 40]) * (IMAGE_W * IMAGE_H) + pdf = _shared_resources_pdf(_flate_rgb_image(IMAGE_W, IMAGE_H, pixels, 9)) + + drawn, grown = _keep_all_pages(pdf) + + assert drawn == {1: 1, **dict.fromkeys(range(2, N_PAGES + 1), 0)} + + # Page 1 holds the image once (as decoded samples); the other pages + # hold nothing of it. Decoding it on every page grows by N_PAGES times + # that. + assert grown < 3 * DECODED_MB, ( + f"keeping {N_PAGES} page results grew RSS by {grown:.0f} MB; " + f"one decoded copy of the shared image is {DECODED_MB:.0f} MB" + ) + + +@pytest.mark.skipif(not STATUS.exists(), reason="reads VmRSS from /proc") +def test_pages_that_do_not_draw_a_shared_image_do_not_hold_its_stream(): + pixels = random.Random(424).randbytes(NOISE_W * NOISE_H * 3) + image = _flate_rgb_image(NOISE_W, NOISE_H, pixels, 1) + pdf = _shared_resources_pdf(image, drawn=False) + + drawn, grown = _keep_all_pages(pdf) + + assert drawn == dict.fromkeys(range(1, N_PAGES + 1), 0) + + # No page draws the image, so no page result needs any of it. Copying + # the stream into every page grows by N_PAGES times its size. + assert grown < 2 * NOISE_MB, ( + f"keeping {N_PAGES} page results grew RSS by {grown:.0f} MB; " + f"the shared image stream is {NOISE_MB:.0f} MB" + ) + + +def test_type3_glyph_keeps_an_image_it_takes_from_the_page_resources(): + """A Type3 font without /Resources draws from the page's (9.6.5). + + The page content names only the font, never the image, but the image is + still in use and must stay in the page's copy of the document. + """ + red = bytes([0xFF, 0x00, 0x00]) + pdf = build_pdf( + [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + "/Resources 9 0 R /Contents 4 0 R >>", + content_stream("BT\n/T3 100 Tf\n50 50 Td\n(a) Tj\nET\n"), + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] " + "/FontMatrix [0.001 0 0 0.001 0 0] /CharProcs 6 0 R " + "/Encoding << /Type /Encoding /Differences [97 /square] >> " + "/FirstChar 97 /LastChar 97 /Widths [1000] >>", + "<< /square 7 0 R >>", + stream_object("", b"1000 0 d0\nq 1000 0 0 1000 0 0 cm /Im Do Q\n"), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources 9 0 R >>", + "<< /Font << /T3 5 0 R >> /XObject << /Im 10 0 R >> >>", + stream_object( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 " + "/ColorSpace /DeviceRGB /BitsPerComponent 8", + red * 4, + ), + ] + ) + + image = region_image(render_page(pdf), (50.0, 50.0, 150.0, 150.0)) + + assert coverage_ratio(image) > 0.8, coverage_ratio(image) + red_, green, blue = average_color(image) + assert red_ > 200 and green < 60 and blue < 60, (red_, green, blue) + + +def test_forms_that_share_one_xobject_dictionary_are_visited_once(): + """The forms of one figure may share an /XObject dictionary naming all. + + A walk that visits each form once per entry naming it (as qpdf's + forEachFormXObject does) takes N * N steps, and parsing the content of + each form on every step took a 23-page arXiv paper with 2301 such forms + from half a minute to over an hour. + """ + n_forms = 1000 + # 1 catalog, 2 pages, 3-4 pages, 5 resources, 6 content, 7 /XObject, forms + names = " ".join(f"/Fm{i} {8 + i} 0 R" for i in range(n_forms)) + form = stream_object( + "/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject 7 0 R >>", + b"0 0 1 1 re f", + ) + pdf = build_pdf( + [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + "/Resources 5 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources 5 0 R >>", + "<< /XObject 7 0 R >>", + content_stream("q 100 0 0 100 50 50 cm /Fm0 Do Q"), + f"<< {names} >>", + *([form] * n_forms), + ] + ) + + start = time.monotonic() + drawn, _ = _keep_all_pages(pdf, n_pages=2) + elapsed = time.monotonic() - start + + assert drawn == {1: 0, 2: 0} + assert elapsed < 10, f"parsing 2 pages took {elapsed:.1f} s"