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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user