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.
This commit is contained in:
James R. Barlow
2026-04-25 00:48:25 -07:00
parent 84f5fe9ee0
commit c993857752
2 changed files with 54 additions and 2 deletions
+14 -2
View File
@@ -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:
+40
View File
@@ -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