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"