Skip to content

fix: recover PDF text after inline images - #1889

Open
Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/pdf-inline-image-recovery
Open

Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/pdf-inline-image-recovery

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Summary

  • add a lazy PyMuPDF text-recovery pass for PDFs with inline image operators
  • keep PyMuPDF optional: no default dependency change, and the fallback only runs when the package is installed
  • warn instead of silently returning likely partial text when inline-image recovery is needed but PyMuPDF is unavailable
  • add regression coverage for both the recovery and warning paths

Addresses #1870.

Test plan

  • python -m pytest packages/markitdown/tests/test_pdf_memory.py -q -k "inline_image"
  • python -m pytest packages/markitdown/tests/test_pdf_memory.py packages/markitdown/tests/test_pdf_tables.py -q
  • python -m py_compile packages/markitdown/src/markitdown/converters/_pdf_converter.py packages/markitdown/tests/test_pdf_memory.py
  • python -m mypy --ignore-missing-imports packages/markitdown/src/markitdown/converters/_pdf_converter.py packages/markitdown/tests/test_pdf_memory.py
  • python -m pip install -e "packages/markitdown[pymupdf]"
  • git diff --check

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased onto current main; no conflicts.

Focused validation after the rebase:

python -m py_compile packages\markitdown\src\markitdown\converters\_pdf_converter.py packages\markitdown\tests\test_pdf_memory.py
$env:PYTHONPATH='C:\dev\GITHUB-clean\markitdown-1889\packages\markitdown\src'; python -m pytest packages\markitdown\tests\test_pdf_memory.py -q
git diff --check origin/main..HEAD

Result: 8 passed, 2 skipped for the focused PDF memory tests.

@he-yufeng
Yufeng He (he-yufeng) force-pushed the fix/pdf-inline-image-recovery branch from b75be1c to 678aa48 Compare June 12, 2026 00:38
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Gentle nudge — freshly rebased with green checks. Would appreciate a look when someone has a minute.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Still open on main: _pdf_converter.py has no fallback for inline images, so PDFs that only carry images inline still lose that content entirely. A look when convenient would be appreciated.

@cagdasyurekli

Copy link
Copy Markdown
Contributor

_contains_inline_image() at 678aa48 searches the raw PDF bytes for BI, ID and EI. Those operators belong to a page's decoded content stream; when that stream uses Flate compression, the raw-file search misses them.

I checked this with a generated PDF containing an inline image in a compressed content stream: the decoded stream contains BI ... ID ... EI, but _contains_inline_image(pdf_bytes) returns False. Consequently _maybe_recover_inline_image_text() exits before either recovery or the warning. This check establishes the detection gap; it does not claim that every compressed inline image triggers #1870.

Could the detection inspect decoded page content streams, and include a valid compressed-PDF fixture in the regression? The current fixture is uncompressed and both extractors are mocked, so it cannot catch this case.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 7cdf6e8, exactly the gap you described. Detection now walks stream..endstream blocks and zlib-decodes the Flate ones when the raw-byte pass finds nothing, so a compressed inline image reaches both the PyMuPDF recovery and the missing-PyMuPDF warning. The warn-path regression now runs against an uncompressed and a Flate-compressed fixture, and there is a direct _contains_inline_image check for both. Your "not every compressed inline image triggers #1870" scoping is preserved: detection still only gates the recovery/warning, nothing else.

On the decoded-stream alternative you suggested: I went with zlib over stream blocks rather than PyMuPDF read_contents() so the check stays dependency-free and the warning path still works when PyMuPDF is not installed. It only recognizes FlateDecode, which is the common case; a stream using another filter just keeps the old behavior.

@cagdasyurekli

Copy link
Copy Markdown
Contributor

Thanks, the Flate detection case is now covered; your focused suite passes (10 passed, 2 skipped). I found one binary-data edge case in the new decode step: .rstrip(b"\r\n") also removes valid checksum bytes if the compressed stream itself ends in 0x0a or 0x0d. zlib.decompress then raises an incomplete-stream error and detection silently returns false.

I prepared a small follow-up on top of 7cdf6e8: cdb9e9f (patch). Feel free to cherry-pick it. Passing the untrimmed bytes to zlib preserves the checksum; zlib already ignores the PDF newline delimiter after its end-of-stream marker.

The four regression cases build complete PDFs with a page tree and cross-reference table, cover both checksum endings and LF/CRLF delimiters, and first verify that pdfminer decodes the page stream correctly. All four fail at the detection assertion before the fix and pass afterward. No PyMuPDF dependency is needed for these tests.

Validation on Python 3.12: focused PDF suite 14 passed, 2 skipped; full core suite 311 passed, 33 skipped; Black 23.7.0 and git diff --check pass. Remote/API tests were skipped. The separate filter/whole-document recovery limitations remain outside this small patch.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Sharp catch, thank you. Verified your analysis before landing it: zlib.decompress does ignore the trailing PDF delimiter, and a stream ending in 0x0a exists and fails after the rstrip. Your fix is in 4554569, converter plus your four regression cases unchanged (the padding values land the Adler-32 on both byte endings, nice trick). GitHub's .patch endpoint refuses to serve patches containing PDF bytes, so I applied the two files by hand from the API view and credited you in the commit message; full pdf suite passes 50 passed / 2 skipped here.

…ection

Review on 678aa48: the BI/ID/EI operators live in a page's decoded content
stream, so the raw-byte search misses them whenever the stream is Flate
compressed, and neither recovery nor the warning fired. Detection now walks
stream..endstream blocks and zlib-decodes the Flate ones before giving up.
The regression runs the warn path against both an uncompressed fixture and a
compressed one, plus a direct detection check on both.
…ction

cagdasyurekli's follow-up on 7cdf6e8: rstrip(b"\r\n") also cuts a compressed
stream whose Adler-32 checksum happens to end in 0x0a or 0x0d, and the
truncated bytes then fail to decompress so detection silently misses.
Pass the bytes to zlib untrimmed; it already ignores the PDF newline
delimiter after its end-of-stream marker. His four regression cases cover
both checksum endings and LF/CRLF delimiters, verified against pdfminer's
own decode. Applied by hand since GitHub's .patch endpoint filters PDF
bytes; authorship credit to cagdasyurekli.
@he-yufeng
Yufeng He (he-yufeng) force-pushed the fix/pdf-inline-image-recovery branch from 4554569 to 284264f Compare October 9, 2026 03:56
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and resolved the conflicts.

The repo's test consolidation (#2579) had absorbed test_pdf_memory.py into test_pdf.py and switched that area to real-file fixtures, so I ported the PR's tests instead of carrying the old mock helpers over: TestPdfInlineImageRecovery now lives in tests/test_pdf.py with the two recovery/warning tests (parametrized over the raw and Flate-compressed fixtures), the Flate-detection unit test, and the Adler-32 checksum regression as a module-level test. All self-contained, no changes to the consolidated fixtures.

The converter logic is unchanged in spirit: same inline-image detection (now reading through Flate streams), same PyMuPDF recovery comparison, same warning when PyMuPDF is absent.

Local verification on this head: tests/test_pdf.py 45 passed (the two test_module_misc failures around speech/LLM options also fail on clean main here, they need optional extras). Should be mergeable again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants