From f95e493751c2fbab21310ebe8d1fffea6c8fb32f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 16 May 2026 05:50:59 +0800 Subject: [PATCH 1/3] fix: recover PDF text after inline images --- README.md | 1 + packages/markitdown/pyproject.toml | 1 + .../markitdown/converters/_pdf_converter.py | 54 ++++++++- packages/markitdown/tests/test_pdf.py | 105 +++++++++++++++++- 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fa33f6b343..6afccf36ea 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ At the moment, the following optional dependencies are available: * `[xlsx]` Installs dependencies for Excel files * `[xls]` Installs dependencies for older Excel files * `[pdf]` Installs dependencies for PDF files +* `[pymupdf]` Enables an optional PDF text recovery fallback for some malformed inline images * `[outlook]` Installs dependencies for Outlook messages * `[az-doc-intel]` Installs dependencies for Azure Document Intelligence * `[az-content-understanding]` Installs dependencies for Azure Content Understanding diff --git a/packages/markitdown/pyproject.toml b/packages/markitdown/pyproject.toml index 43144043da..a0c76e4263 100644 --- a/packages/markitdown/pyproject.toml +++ b/packages/markitdown/pyproject.toml @@ -56,6 +56,7 @@ docx = ["mammoth~=1.11.0", "lxml"] xlsx = ["pandas", "openpyxl"] xls = ["pandas", "xlrd"] pdf = ["pdfminer.six>=20251230", "pdfplumber>=0.11.9"] +pymupdf = ["PyMuPDF>=1.24.0"] outlook = ["olefile"] audio-transcription = ["pydub", "SpeechRecognition"] youtube-transcription = ["youtube-transcript-api~=1.2.3"] diff --git a/packages/markitdown/src/markitdown/converters/_pdf_converter.py b/packages/markitdown/src/markitdown/converters/_pdf_converter.py index ffbcbd990c..cf3c988563 100644 --- a/packages/markitdown/src/markitdown/converters/_pdf_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pdf_converter.py @@ -1,6 +1,7 @@ import sys import io import re +import warnings from typing import BinaryIO, Any from .._base_converter import DocumentConverter, DocumentConverterResult @@ -9,6 +10,11 @@ # Pattern for MasterFormat-style partial numbering (e.g., ".1", ".2", ".10") PARTIAL_NUMBERING_PATTERN = re.compile(r"^\.\d+$") +INLINE_IMAGE_START_PATTERN = re.compile(rb"(?:^|\s)BI\s+") +INLINE_IMAGE_DATA_PATTERN = re.compile(rb"\sID\s+") +INLINE_IMAGE_END_PATTERN = re.compile(rb"\sEI(?:\s|$)") +PYMUPDF_RECOVERY_MARGIN = 500 +PYMUPDF_RECOVERY_RATIO = 1.3 def _merge_partial_numbering_lines(text: str) -> str: @@ -57,6 +63,49 @@ def _merge_partial_numbering_lines(text: str) -> str: return "\n".join(result_lines) +def _contains_inline_image(pdf_bytes: bytes) -> bool: + return ( + INLINE_IMAGE_START_PATTERN.search(pdf_bytes) is not None + and INLINE_IMAGE_DATA_PATTERN.search(pdf_bytes) is not None + and INLINE_IMAGE_END_PATTERN.search(pdf_bytes) is not None + ) + + +def _extract_text_with_pymupdf(pdf_bytes: bytes) -> str | None: + try: + import fitz # type: ignore[import-not-found] + except ImportError: + return None + + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + try: + return "\n".join(page.get_text("text") for page in doc).strip() + finally: + doc.close() + + +def _maybe_recover_inline_image_text(pdf_bytes: bytes, markdown: str) -> str: + if not _contains_inline_image(pdf_bytes): + return markdown + + pymupdf_text = _extract_text_with_pymupdf(pdf_bytes) + if not pymupdf_text: + if len((markdown or "").strip()) < 1024: + warnings.warn( + "PDF text extraction may be incomplete after inline image data. " + "Install the optional PyMuPDF extra to enable recovery for this case.", + RuntimeWarning, + stacklevel=2, + ) + return markdown + + current_len = len((markdown or "").strip()) + if len(pymupdf_text) > current_len * PYMUPDF_RECOVERY_RATIO + PYMUPDF_RECOVERY_MARGIN: + return pymupdf_text + + return markdown + + # Load dependencies _dependency_exc_info = None try: @@ -530,9 +579,9 @@ def convert( extension=".pdf", feature="pdf", ) - ) from _dependency_exc_info[1].with_traceback( + ) from _dependency_exc_info[1].with_traceback( # type: ignore[union-attr] _dependency_exc_info[2] - ) # type: ignore[union-attr] + ) assert isinstance(file_stream, io.IOBase) @@ -585,5 +634,6 @@ def convert( # Post-process to merge MasterFormat-style partial numbering with following text markdown = _merge_partial_numbering_lines(markdown) + markdown = _maybe_recover_inline_image_text(pdf_bytes.getvalue(), markdown) return DocumentConverterResult(markdown=markdown) diff --git a/packages/markitdown/tests/test_pdf.py b/packages/markitdown/tests/test_pdf.py index c4f8c64a9c..9766e88ccd 100644 --- a/packages/markitdown/tests/test_pdf.py +++ b/packages/markitdown/tests/test_pdf.py @@ -1,8 +1,12 @@ """PDF conversion, table extraction, numbering, and page cleanup.""" +import io import os import re -from unittest.mock import patch +import sys +import types +import warnings +from unittest.mock import MagicMock, patch import pytest @@ -1384,5 +1388,104 @@ def test_markitdown_remote() -> None: assert test_string in result.text_content + + +class TestPdfInlineImageRecovery: + """Recover text that pdfplumber/pdfminer drop after inline image data. + + Some PDFs embed images with the inline-image operators (BI / ID / EI) in + the middle of a content stream. pdfminer truncates extraction at the raw + image bytes, so everything after the image is silently lost. When the + bytes contain inline-image markers, the converter compares against an + optional PyMuPDF extraction and keeps whichever recovers more text. + """ + + @staticmethod + def _inline_image_pdf_bytes() -> bytes: + return ( + b"%PDF-1.7\n" + b"1 0 obj <<>> stream\n" + b"BT (BEFORE_IMAGE) Tj ET\n" + b"BI /W 1 /H 1 /BPC 1 /IM true ID\n" + b"abc\n" + b"EI\n" + b"BT (AFTER_IMAGE) Tj ET\n" + b"endstream endobj\n%%EOF\n" + ) + + @staticmethod + def _plain_page(): + page = MagicMock() + page.width = 612 + page.close = MagicMock() + page.extract_words.return_value = [ + { + "text": "This is a long paragraph of plain text.", + "x0": 50, + "x1": 550, + "top": 10, + "bottom": 20, + }, + ] + page.extract_text.return_value = "This is a long paragraph of plain text." + return page + + @staticmethod + def _pdfplumber_open(pages): + def mock_open(stream): + mock_pdf = MagicMock() + mock_pdf.pages = pages + mock_pdf.__enter__ = MagicMock(return_value=mock_pdf) + mock_pdf.__exit__ = MagicMock(return_value=False) + return mock_pdf + + return mock_open + + def _convert(self, pages, monkeypatch=None, fitz_module=...): + from markitdown import StreamInfo + + with ( + patch("markitdown.converters._pdf_converter.pdfplumber") as mock_pdfplumber, + patch("markitdown.converters._pdf_converter.pdfminer") as mock_pdfminer, + ): + mock_pdfplumber.open.side_effect = self._pdfplumber_open(pages) + # pdfminer only sees the text before the image, matching what a + # truncated extraction produces for a stream with inline image data. + mock_pdfminer.high_level.extract_text.return_value = "BEFORE_IMAGE" + if fitz_module is not ...: + monkeypatch.setitem(sys.modules, "fitz", fitz_module) + result = MarkItDown().convert_stream( + io.BytesIO(self._inline_image_pdf_bytes()), + stream_info=StreamInfo(extension=".pdf", mimetype="application/pdf"), + ) + return result + + def test_uses_pymupdf_when_it_recovers_more_text(self, monkeypatch): + class FakePage: + def get_text(self, mode): + assert mode == "text" + return "BEFORE_IMAGE\nAFTER_IMAGE\n" + ("Recovered body. " * 50) + + class FakeDoc: + def __iter__(self): + return iter([FakePage()]) + + def close(self): + pass + + fake_fitz = types.SimpleNamespace(open=lambda *, stream, filetype: FakeDoc()) + result = self._convert([self._plain_page()], monkeypatch, fake_fitz) + assert "AFTER_IMAGE" in result.text_content + + def test_warns_when_pymupdf_is_missing(self, monkeypatch): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = self._convert( + [self._plain_page()], monkeypatch, None + ) + assert result.text_content == "BEFORE_IMAGE" + assert any("inline image data" in str(item.message) for item in caught) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) From ce290eee2eec828a58cf0df52056a9e55498dad1 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:28:14 +0800 Subject: [PATCH 2/3] fix: see through Flate-compressed content streams in inline-image detection 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. --- .../markitdown/converters/_pdf_converter.py | 25 ++++++++-- packages/markitdown/tests/test_pdf.py | 46 +++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_pdf_converter.py b/packages/markitdown/src/markitdown/converters/_pdf_converter.py index cf3c988563..1f7d25d958 100644 --- a/packages/markitdown/src/markitdown/converters/_pdf_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pdf_converter.py @@ -2,6 +2,7 @@ import io import re import warnings +import zlib from typing import BinaryIO, Any from .._base_converter import DocumentConverter, DocumentConverterResult @@ -13,6 +14,7 @@ INLINE_IMAGE_START_PATTERN = re.compile(rb"(?:^|\s)BI\s+") INLINE_IMAGE_DATA_PATTERN = re.compile(rb"\sID\s+") INLINE_IMAGE_END_PATTERN = re.compile(rb"\sEI(?:\s|$)") +INLINE_IMAGE_STREAM_PATTERN = re.compile(rb"stream\r?\n(.*?)endstream", re.DOTALL) PYMUPDF_RECOVERY_MARGIN = 500 PYMUPDF_RECOVERY_RATIO = 1.3 @@ -63,14 +65,29 @@ def _merge_partial_numbering_lines(text: str) -> str: return "\n".join(result_lines) -def _contains_inline_image(pdf_bytes: bytes) -> bool: +def _has_inline_image_operators(data: bytes) -> bool: return ( - INLINE_IMAGE_START_PATTERN.search(pdf_bytes) is not None - and INLINE_IMAGE_DATA_PATTERN.search(pdf_bytes) is not None - and INLINE_IMAGE_END_PATTERN.search(pdf_bytes) is not None + INLINE_IMAGE_START_PATTERN.search(data) is not None + and INLINE_IMAGE_DATA_PATTERN.search(data) is not None + and INLINE_IMAGE_END_PATTERN.search(data) is not None ) +def _contains_inline_image(pdf_bytes: bytes) -> bool: + if _has_inline_image_operators(pdf_bytes): + return True + # Content streams are usually Flate-compressed, which hides the operators + # from the raw-byte search. Decode every Flate stream and look again. + for match in INLINE_IMAGE_STREAM_PATTERN.finditer(pdf_bytes): + try: + decoded = zlib.decompress(match.group(1).rstrip(b"\r\n")) + except zlib.error: + continue + if _has_inline_image_operators(decoded): + return True + return False + + def _extract_text_with_pymupdf(pdf_bytes: bytes) -> str | None: try: import fitz # type: ignore[import-not-found] diff --git a/packages/markitdown/tests/test_pdf.py b/packages/markitdown/tests/test_pdf.py index 9766e88ccd..1784b87659 100644 --- a/packages/markitdown/tests/test_pdf.py +++ b/packages/markitdown/tests/test_pdf.py @@ -6,6 +6,7 @@ import sys import types import warnings +import zlib from unittest.mock import MagicMock, patch import pytest @@ -1413,6 +1414,26 @@ def _inline_image_pdf_bytes() -> bytes: b"endstream endobj\n%%EOF\n" ) + @staticmethod + def _inline_image_pdf_bytes_compressed() -> bytes: + """Same inline image, but inside a Flate-compressed content stream.""" + content = ( + b"BT (BEFORE_IMAGE) Tj ET\n" + b"BI /W 1 /H 1 /BPC 1 /IM true ID\n" + b"abc\n" + b"EI\n" + b"BT (AFTER_IMAGE) Tj ET\n" + ) + packed = zlib.compress(content) + return ( + b"%PDF-1.7\n" + b"1 0 obj << /Length " + + str(len(packed)).encode() + + b" /Filter /FlateDecode >> stream\n" + + packed + + b"\nendstream endobj\n%%EOF\n" + ) + @staticmethod def _plain_page(): page = MagicMock() @@ -1441,7 +1462,7 @@ def mock_open(stream): return mock_open - def _convert(self, pages, monkeypatch=None, fitz_module=...): + def _convert(self, pages, pdf_bytes, monkeypatch=None, fitz_module=...): from markitdown import StreamInfo with ( @@ -1455,7 +1476,7 @@ def _convert(self, pages, monkeypatch=None, fitz_module=...): if fitz_module is not ...: monkeypatch.setitem(sys.modules, "fitz", fitz_module) result = MarkItDown().convert_stream( - io.BytesIO(self._inline_image_pdf_bytes()), + io.BytesIO(pdf_bytes), stream_info=StreamInfo(extension=".pdf", mimetype="application/pdf"), ) return result @@ -1474,18 +1495,33 @@ def close(self): pass fake_fitz = types.SimpleNamespace(open=lambda *, stream, filetype: FakeDoc()) - result = self._convert([self._plain_page()], monkeypatch, fake_fitz) + result = self._convert( + [self._plain_page()], self._inline_image_pdf_bytes(), monkeypatch, fake_fitz + ) assert "AFTER_IMAGE" in result.text_content - def test_warns_when_pymupdf_is_missing(self, monkeypatch): + @pytest.mark.parametrize( + "pdf_bytes_factory", + [_inline_image_pdf_bytes.__func__, _inline_image_pdf_bytes_compressed.__func__], + ) + def test_warns_when_pymupdf_is_missing(self, monkeypatch, pdf_bytes_factory): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") result = self._convert( - [self._plain_page()], monkeypatch, None + [self._plain_page()], pdf_bytes_factory(), monkeypatch, None ) assert result.text_content == "BEFORE_IMAGE" assert any("inline image data" in str(item.message) for item in caught) + def test_detection_reads_flate_content_streams(self): + # The BI/ID/EI operators live in the page's content stream; when that + # stream is Flate-compressed the raw bytes show nothing. + from markitdown.converters._pdf_converter import _contains_inline_image + + assert _contains_inline_image(self._inline_image_pdf_bytes()) + assert _contains_inline_image(self._inline_image_pdf_bytes_compressed()) + assert not _contains_inline_image(b"%PDF-1.7\nno images here") + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) From 284264f68bbaeba68811da502e579097dd5cc56a Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:50:27 +0800 Subject: [PATCH 3/3] fix: don't trim checksum bytes off Flate streams in inline-image detection 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. --- .../markitdown/converters/_pdf_converter.py | 4 +- packages/markitdown/tests/test_pdf.py | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/markitdown/src/markitdown/converters/_pdf_converter.py b/packages/markitdown/src/markitdown/converters/_pdf_converter.py index 1f7d25d958..f47eb2fd4b 100644 --- a/packages/markitdown/src/markitdown/converters/_pdf_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pdf_converter.py @@ -80,7 +80,9 @@ def _contains_inline_image(pdf_bytes: bytes) -> bool: # from the raw-byte search. Decode every Flate stream and look again. for match in INLINE_IMAGE_STREAM_PATTERN.finditer(pdf_bytes): try: - decoded = zlib.decompress(match.group(1).rstrip(b"\r\n")) + # Trailing CR/LF may belong to the binary checksum. zlib ignores + # the PDF delimiter after its end-of-stream marker. + decoded = zlib.decompress(match.group(1)) except zlib.error: continue if _has_inline_image_operators(decoded): diff --git a/packages/markitdown/tests/test_pdf.py b/packages/markitdown/tests/test_pdf.py index 1784b87659..45dd548207 100644 --- a/packages/markitdown/tests/test_pdf.py +++ b/packages/markitdown/tests/test_pdf.py @@ -1523,5 +1523,54 @@ def test_detection_reads_flate_content_streams(self): assert not _contains_inline_image(b"%PDF-1.7\nno images here") + + +@pytest.mark.parametrize("padding", [170, 237]) +@pytest.mark.parametrize("delimiter", [b"\n", b"\r\n"]) +def test_flate_checksum_bytes_are_not_trimmed(padding, delimiter): + # Contributed-by-cagdasyurekli case: a compressed stream whose Adler-32 + # checksum ends in 0x0a or 0x0d must not be trimmed as PDF whitespace. + from pdfminer.pdfdocument import PDFDocument + from pdfminer.pdfpage import PDFPage + from pdfminer.pdfparser import PDFParser + from markitdown.converters._pdf_converter import _contains_inline_image + + content = ( + b"q\nBI /W 1 /H 1 /BPC 8 /CS /G ID\n\x00\nEI\nQ\n% " + b"A" * padding + b"\n" + ) + packed = zlib.compress(content) + # Adler-32 ends in 0x0a or 0x0d: these are data, not PDF whitespace. + assert packed[-1:] in (b"\n", b"\r") + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] " + b"/Resources << >> /Contents 4 0 R >>", + b"<< /Length " + + str(len(packed)).encode() + + b" /Filter /FlateDecode >>" + + b"\nstream\n" + + packed + + delimiter + + b"endstream", + ] + pdf = b"%PDF-1.7\n" + offsets = [0] + for number, obj in enumerate(objects, 1): + offsets.append(len(pdf)) + pdf += f"{number} 0 obj\n".encode() + obj + b"\nendobj\n" + xref = len(pdf) + pdf += b"xref\n0 5\n0000000000 65535 f \n" + for offset in offsets[1:]: + pdf += f"{offset:010d} 00000 n \n".encode() + pdf += b"trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n" + pdf += str(xref).encode() + b"\n%%EOF\n" + + # A real PDF parser must recognize the page and decode the intact stream. + document = PDFDocument(PDFParser(io.BytesIO(pdf))) + page = next(PDFPage.create_pages(document)) + assert page.contents[0].get_data() == content + assert _contains_inline_image(pdf) + if __name__ == "__main__": raise SystemExit(pytest.main([__file__]))