From c993857752d22e2234234a386b66535325a72e47 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 25 Apr 2026 00:48:25 -0700 Subject: [PATCH] Fix Form XObject cycle detection in image xref scan (#1321) The 2024 guard against runaway recursion in _find_image_xrefs_container only deduplicated image xrefs, but Form XObject xrefs are never added to include_xrefs/exclude_xrefs, so a self-referential or DAG-shaped Form graph re-entered every branch until the depth limit fired -- producing the reported flood of warnings (and minutes-long hangs) on PowerPoint exports. Thread a visited_forms set through the recursion so each Form XObject is descended into at most once per document. With memoization in place the depth limit is no longer a cycle defense, so demote its log to debug. Add a regression test that synthesises a circular-Form PDF from the existing formxobject.pdf fixture (no new binary fixture, no license issues) and asserts zero "Recursion depth exceeded" warnings. --- src/ocrmypdf/optimize.py | 16 ++++++++++++++-- tests/test_optimize.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 08958e61..6aaf5833 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -260,10 +260,19 @@ def _find_image_xrefs_container( exclude_xrefs: MutableSet[Xref], pageno_for_xref: dict[Xref, int], depth: int = 0, + visited_forms: MutableSet[Xref] | None = None, ): """Find all image XRefs or Form XObject and add to the include/exclude sets.""" + # Form XObjects are not added to include/exclude_xrefs, so the dedup + # check below doesn't catch Form-XObject cycles or DAGs. Track them in + # a shared set so each Form is only descended into once per document + # (issue #1321). + if visited_forms is None: + visited_forms = set() if depth > 10: - log.warning("Recursion depth exceeded in _find_image_xrefs_page") + # With visited_forms memoization, this is a soft DAG-height guard + # rather than a cycle defense, so a debug log is sufficient. + log.debug("Recursion depth exceeded in _find_image_xrefs_page") return try: xobjs = container.Resources.XObject @@ -276,7 +285,9 @@ def _find_image_xrefs_container( if xref in include_xrefs or xref in exclude_xrefs: continue # Already processed if Name.Subtype in image and image.Subtype == Name.Form: - # Recurse into Form XObjects + if xref in visited_forms: + continue + visited_forms.add(xref) log.debug(f"Recursing into Form XObject {_imname} in page {pageno}") _find_image_xrefs_container( pdf, @@ -286,6 +297,7 @@ def _find_image_xrefs_container( exclude_xrefs, pageno_for_xref, depth + 1, + visited_forms, ) continue if Name.SMask in image: diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 710ffb6a..c35af697 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -215,6 +215,46 @@ def test_find_formx(resources): assert pagenos[xref] == 0 +def test_find_formx_circular_reference(resources, tmp_path, caplog): + """Regression for issue #1321. + + Some PDFs (notably PowerPoint exports) contain Form XObjects that + reference themselves or each other in a cycle. The recursion guard in + _find_image_xrefs_container only deduplicates *image* xrefs, so a Form + XObject cycle would re-enter every branch until the depth limit fired, + producing thousands of "Recursion depth exceeded" warnings (and minutes + of wall-clock time on real-world inputs). + """ + import logging + + src = resources / 'formxobject.pdf' + out = tmp_path / 'circular_form.pdf' + with pikepdf.open(src) as pdf: + # /Form1 lives at xref 10. Replace its Resources.XObject with three + # entries that all point back to /Form1 itself, creating a fan-out + # cycle of branching factor 3. + form = pdf.pages[0].obj.Resources.XObject.Form1 + form.Resources.XObject = Dictionary( + {'/Fm0': form, '/Fm1': form, '/Fm2': form} + ) + pdf.save(out) + + caplog.set_level(logging.WARNING, logger='ocrmypdf.optimize') + with pikepdf.open(out) as pdf: + opt._find_image_xrefs(pdf) + + n_warnings = sum( + 1 + for r in caplog.records + if 'Recursion depth exceeded' in r.getMessage() + ) + # Without the fix this is in the tens of thousands. + assert n_warnings == 0, ( + f"Form XObject cycle should be detected without depth-limit warnings; " + f"got {n_warnings}" + ) + + def test_extract_image_filter_with_pdf_image(): image = Dictionary() image.Subtype = Name.Image