From ac2fe9646c6139dd156640a5af7b074dec90e596 Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:52:39 -0400 Subject: [PATCH] fix: decode image XObject samples only when a page draws them Pages that share one /Resources dictionary, as LibreOffice writes them, list every image of the document. Each page decoder defiltered all of them in set(), so every page result held the decoded pixels of images it never paints: a shared dictionary with 8 Flate photos cost ~50 MB per kept page instead of ~8 MB. parse() now reads only the image dictionary. load_pixels() defilters the samples and the soft mask on the first call to a pixel getter, which only Do_image and save_to_file make. Fixes #424 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- src/parse/pdf_resources/page_xobject_image.h | 68 ++++++++---- tests/test_unit_shared_resource_images.py | 105 +++++++++++++++++++ 2 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 tests/test_unit_shared_resource_images.py diff --git a/src/parse/pdf_resources/page_xobject_image.h b/src/parse/pdf_resources/page_xobject_image.h index fdc6516c3..e99c30f4e 100644 --- a/src/parse/pdf_resources/page_xobject_image.h +++ b/src/parse/pdf_resources/page_xobject_image.h @@ -196,8 +196,8 @@ namespace pdflib std::shared_ptr get_decoded_stream_data() const; bool has_soft_mask_data() const; std::shared_ptr> get_soft_mask_data() const; - int get_soft_mask_width() const { return soft_mask_width; } - int get_soft_mask_height() const { return soft_mask_height; } + int get_soft_mask_width() const { load_pixels(); return soft_mask_width; } + int get_soft_mask_height() const { load_pixels(); return soft_mask_height; } // Determine file extension from filters (e.g. ".jpg", ".jp2", ".jb2", ".bin") std::string pick_extension() const; @@ -216,9 +216,12 @@ namespace pdflib void init_filters(); - void init_stream_data(); - void init_codec_stream_data(); - void init_soft_mask_data(); + // Defilters the samples and the soft mask on first use of a pixel getter. + void load_pixels() const; + + void init_stream_data() const; + void init_codec_stream_data() const; + void init_soft_mask_data() const; // Resolves a /Separation or /DeviceN /ColorSpace array into a colour space // resource, keeping it only when its tint transform is usable. @@ -226,9 +229,11 @@ namespace pdflib private: - QPDFObjectHandle qpdf_xobject; + // mutable: QPDF's stream readers are non-const, and load_pixels() reads + // the stream from the const getters. + mutable QPDFObjectHandle qpdf_xobject; - QPDFObjectHandle qpdf_xobject_dict; + mutable QPDFObjectHandle qpdf_xobject_dict; nlohmann::json json_xobject_dict; std::string xobject_key; @@ -258,21 +263,23 @@ namespace pdflib std::string intent; std::vector image_filters; - // Stream data - std::shared_ptr raw_stream_data; + // Stream data. Loaded lazily by load_pixels(): pages that share one + // /Resources dictionary list every image of the document, but draw few. + mutable std::shared_ptr raw_stream_data; // The raw stream with the transport filters that precede the image codec // undone -- the bytes the codec was handed at encode time. Aliases // raw_stream_data when the codec is the first filter in the chain. - std::shared_ptr codec_stream_data; + mutable std::shared_ptr codec_stream_data; - std::shared_ptr decoded_stream_data; - std::shared_ptr> soft_mask_data; + mutable std::shared_ptr decoded_stream_data; + mutable std::shared_ptr> soft_mask_data; - // set() argument, kept so parse() can consult it + // set() argument, kept so load_pixels() can consult it bool extract_pixels_ = true; - int soft_mask_width = 0; - int soft_mask_height = 0; + mutable bool pixels_loaded_ = false; + mutable int soft_mask_width = 0; + mutable int soft_mask_height = 0; // PDF image semantics std::vector decode_array; // length 2*ncomp when present @@ -326,6 +333,7 @@ namespace pdflib xobject_key = xobject_key_; qpdf_xobject = qpdf_xobject_; extract_pixels_ = extract_pixels; + pixels_loaded_ = false; parse(); @@ -349,15 +357,23 @@ namespace pdflib init_filters(); init_image_properties(); + } + void pdf_resource::load_pixels() const + { // Defiltering the samples and building the alpha plane is the whole cost - // of an image XObject; everything above comes from the dictionary. A + // of an image XObject; everything parse() reads comes from the + // dictionary. Deferring it to the first `Do` keeps a page from holding + // the pixels of images it only lists in a shared /Resources (#424). A // caller that only wants to know where the bitmaps are skips both. - if(extract_pixels_) + if(pixels_loaded_ or not extract_pixels_) { - init_stream_data(); - init_soft_mask_data(); + return; } + pixels_loaded_ = true; + + init_stream_data(); + init_soft_mask_data(); } void pdf_resource::init_image_properties() @@ -1081,7 +1097,7 @@ namespace pdflib } } - void pdf_resource::init_stream_data() + void pdf_resource::init_stream_data() const { LOG_S(INFO) << __FUNCTION__; @@ -1116,7 +1132,7 @@ namespace pdflib init_codec_stream_data(); } - void pdf_resource::init_codec_stream_data() + void pdf_resource::init_codec_stream_data() const { // QPDF declares a whole /Filter chain unfilterable as soon as one of its // filters is an image codec it will not invert, so `decoded_stream_data` @@ -1163,7 +1179,7 @@ namespace pdflib } } - void pdf_resource::init_soft_mask_data() + void pdf_resource::init_soft_mask_data() const { soft_mask_data.reset(); @@ -1581,41 +1597,49 @@ namespace pdflib bool pdf_resource::has_raw_stream_data() const { + load_pixels(); return (raw_stream_data != nullptr && raw_stream_data->getSize() > 0); } std::shared_ptr pdf_resource::get_raw_stream_data() const { + load_pixels(); return raw_stream_data; } bool pdf_resource::has_codec_stream_data() const { + load_pixels(); return (codec_stream_data != nullptr && codec_stream_data->getSize() > 0); } std::shared_ptr pdf_resource::get_codec_stream_data() const { + load_pixels(); return codec_stream_data; } bool pdf_resource::has_decoded_stream_data() const { + load_pixels(); return (decoded_stream_data != nullptr && decoded_stream_data->getSize() > 0); } std::shared_ptr pdf_resource::get_decoded_stream_data() const { + load_pixels(); return decoded_stream_data; } bool pdf_resource::has_soft_mask_data() const { + load_pixels(); return (soft_mask_data != nullptr and not soft_mask_data->empty()); } std::shared_ptr> pdf_resource::get_soft_mask_data() const { + load_pixels(); return soft_mask_data; } diff --git a/tests/test_unit_shared_resource_images.py b/tests/test_unit_shared_resource_images.py new file mode 100644 index 000000000..4db94c8cd --- /dev/null +++ b/tests/test_unit_shared_resource_images.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +"""Images listed in a /Resources dictionary that pages share (#424). + +LibreOffice points every page at one /Resources dictionary that names every +image of the document. Each page decoder defiltered all of them up front, so +every page result held the pixels of images it never draws: a 100-page report +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. +""" + +from __future__ import annotations + +import zlib +from io import BytesIO +from pathlib import Path + +import pytest + +from tests.pdf_builder import build_pdf, content_stream, stream_object + +STATUS = Path("/proc/self/status") + +N_PAGES = 8 +IMAGE_W, IMAGE_H = 4000, 3000 +DECODED_MB = IMAGE_W * IMAGE_H * 3 / 2**20 # ~34 MB + + +def _rss_mb() -> float: + for line in STATUS.read_text().splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) / 1024 + 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} " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", + zlib.compress(pixels, 9), + ) + text = "BT /F1 12 Tf 20 170 Td (shared resources) Tj ET" + draw = "q 160 0 0 120 20 20 cm /Im0 Do Q" + + # 1 catalog, 2 pages, 3 font, 4 image, 5 resources, then page + content pairs + objects: list[str | bytes] = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + image, + "<< /Font << /F1 3 0 R >> /XObject << /Im0 4 0 R >> >>", + ] + kids = [] + for page in range(N_PAGES): + page_obj = len(objects) + 1 + kids.append(f"{page_obj} 0 R") + objects.append( + 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[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(): + from docling_parse.pdf_parser import ( + DecodeConfig, + DoclingThreadedPdfParser, + ThreadedPdfParserConfig, + ) + + parser = DoclingThreadedPdfParser( + parser_config=ThreadedPdfParserConfig( + loglevel="fatal", + threads=1, + max_concurrent_results=N_PAGES, + render_config=None, + ), + decode_config=DecodeConfig(keep_qpdf_warnings=False), + ) + parser.load(BytesIO(_shared_resources_pdf())) + + before = _rss_mb() + try: + kept = list(parser.iterate_results()) + grown = _rss_mb() - before + + 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" + ) + finally: + parser.unload_all()