From d5a5499721bf0f65e20a5f4f94a9f06c55d7617c Mon Sep 17 00:00:00 2001 From: xinyaoli Date: Wed, 26 Aug 2026 23:27:06 -0700 Subject: [PATCH] fix: Flash reads AES PDFs and guards lazy PyPDF2 failures Add PyCryptodome as PyPDF2's AES backend and force page-tree resolution inside the existing guarded open so an unavailable secondary parser falls back to PDFium instead of crashing. Co-authored-by: Cursor --- .../parser_pdfium_charlevel/pdf_objects.py | 10 +++- pyproject.toml | 1 + requirements.txt | 1 + tests/test_issue_426.py | 53 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue_426.py diff --git a/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py index 7b58d2a00..b582260cb 100644 --- a/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py +++ b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py @@ -105,10 +105,16 @@ def get_fonts(self, full: bool = True): class _PdfDoc: """PyPDF2-backed adapter for raw object and stream access PDFium cannot expose.""" - __slots__ = ("_reader", "_virtual") + __slots__ = ("_reader", "_virtual", "_page_count") def __init__(self, reader): self._reader = reader + # PyPDF2 parses lazily, so a document it cannot read (AES without the + # crypto backend, damaged xref) raises on first object access rather + # than at PdfReader(). Resolving the page tree here keeps that failure + # inside the callers' guarded open, which is what lets them fall back + # to the PDFium-only channel instead of propagating. + self._page_count = len(reader.pages) # Negative pseudo-xrefs for DIRECT (inline) dicts that have no object # number -- text extraction reference resolution treats direct and indirect values alike, # so inline font dicts must be addressable by the same integer-keyed @@ -122,7 +128,7 @@ def register_virtual(self, obj) -> int: @property def page_count(self) -> int: - return len(self._reader.pages) + return self._page_count def __getitem__(self, idx): return _PdfPage(self._reader.pages[idx]) diff --git a/pyproject.toml b/pyproject.toml index f5bec8c9a..85c551a8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ openai = ">=1.70.0" openai-agents = ">=0.18.1" litellm = ">=1.97.0" PyPDF2 = ">=3.0.0" +pycryptodome = ">=3.15.0" pypdfium2 = ">=5" sortedcontainers = ">=2.4.0" regex = ">=2024.0.0" diff --git a/requirements.txt b/requirements.txt index 2406eef11..0edc845a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ openai>=1.70.0 requests>=2.28.0 openai-agents>=0.18.1 # pymupdf # optional +pycryptodome==3.23.0 PyPDF2==3.0.1 pypdfium2==5.13.0 python-dotenv==1.2.2 diff --git a/tests/test_issue_426.py b/tests/test_issue_426.py new file mode 100644 index 000000000..862c70d4b --- /dev/null +++ b/tests/test_issue_426.py @@ -0,0 +1,53 @@ +"""PyPDF2's lazy parse must fail inside the callers' guarded open (#426). + +An AES-encrypted PDF raises DependencyError on first object access, not at +PdfReader(), so the guard around the PyPDF2 open used to miss it and the +error surfaced from _page_pass1's page_count instead — an unhandled crash on +a document PDFium reads fine on its own. +""" +import pytest + +from PyPDF2.errors import DependencyError + + +class _LazyBombReader: + """A reader that parses lazily and fails on first object access, the way + an AES-encrypted document does without the crypto backend installed.""" + + @property + def pages(self): + raise DependencyError("PyCryptodome is required for AES algorithm") + + +def test_pdf_doc_open_fails_at_construction(): + """_PdfDoc resolves the page tree eagerly, so an unreadable document + raises where both callers already guard the open (the sequential pipeline + and the parallel worker init) rather than at first page_count.""" + from pageindex.flash.parser_pdfium_charlevel.pdf_objects import _PdfDoc + + with pytest.raises(DependencyError): + _PdfDoc(_LazyBombReader()) + + +def test_flash_falls_back_to_pdfium_when_pypdf2_channel_is_unreadable( + sample_pdf, monkeypatch): + """The PyPDF2 channel only refines what PDFium already extracts, so losing + it degrades the parse instead of failing the run.""" + from pageindex.flash import page_index_flash + from pageindex.flash.parser_pdfium_charlevel import pipeline + + baseline = page_index_flash(sample_pdf, summary=False, optimize=False) + + opened = [] + + class _BombingPyPDF2: + @staticmethod + def PdfReader(*args, **kwargs): + opened.append(args) + return _LazyBombReader() + + monkeypatch.setattr(pipeline, "_pypdf2", _BombingPyPDF2) + degraded = page_index_flash(sample_pdf, summary=False, optimize=False) + + assert opened, "the PyPDF2 channel never opened; the fallback went untested" + assert degraded == baseline