From 236d5e3fc6511f0bd3914ad29ffe0be12ca94061 Mon Sep 17 00:00:00 2001 From: cyurekli Date: Sun, 27 Sep 2026 00:43:25 +0200 Subject: [PATCH] Recover truncated inline-image PDF text per page with optional PyMuPDF --- packages/markitdown/README.md | 11 +++ packages/markitdown/pyproject.toml | 1 + .../markitdown/converters/_pdf_converter.py | 60 +++++++++++--- .../test_files/inline-truncated-False.pdf | Bin 0 -> 1986 bytes .../test_files/inline-truncated-True.pdf | Bin 0 -> 1012 bytes .../test_files/inline-truncated-mixed.pdf | Bin 0 -> 13785 bytes .../tests/test_files/inline-truncated.txt | 5 ++ .../tests/test_pdf_inline_recovery.py | 75 ++++++++++++++++++ 8 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 packages/markitdown/tests/test_files/inline-truncated-False.pdf create mode 100644 packages/markitdown/tests/test_files/inline-truncated-True.pdf create mode 100644 packages/markitdown/tests/test_files/inline-truncated-mixed.pdf create mode 100644 packages/markitdown/tests/test_files/inline-truncated.txt create mode 100644 packages/markitdown/tests/test_pdf_inline_recovery.py diff --git a/packages/markitdown/README.md b/packages/markitdown/README.md index 2ac98708d6..d29e7d415a 100644 --- a/packages/markitdown/README.md +++ b/packages/markitdown/README.md @@ -53,3 +53,14 @@ trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. + +### Optional PDF text recovery + +Install `markitdown[pdf,pdf-recovery]` to enable local PyMuPDF recovery for +plain-text pages truncated around inline images. Recovery also works with +compressed page streams and empty primary text. A replacement must extend the +primary word sequence; pages already recognized as tables/forms retain their +Markdown. The normal extraction path is retained when no page can be recovered +or the optional backend fails. This is conservative text recovery, not OCR; +it does not recover every PDF layout or text missing inside a table/form. +The `pdf-recovery` extra is separate from `all` and `pdf`. diff --git a/packages/markitdown/pyproject.toml b/packages/markitdown/pyproject.toml index 43144043da..d9d393687b 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"] +pdf-recovery = ["pymupdf>=1.24.3"] 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..1632dd367e 100644 --- a/packages/markitdown/src/markitdown/converters/_pdf_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pdf_converter.py @@ -492,6 +492,45 @@ def _extract_tables_from_words(page: Any) -> list[list[list[str]]]: return [table_rows] +def _recover_inline_image_pages( + pdf_bytes: io.BytesIO, plain_pages: dict[int, str] +) -> dict[int, str]: + """Recover truncated plain pages when the optional PyMuPDF extra is installed.""" + try: + import pymupdf + except ImportError: + return {} + + recovered: dict[int, str] = {} + try: + with pymupdf.open(stream=pdf_bytes.getvalue(), filetype="pdf") as pdf: + for page_index, primary in plain_pages.items(): + try: + page = pdf[page_index] + # Inspect decoded page images, including compressed content + # streams. Inline images have no indirect object reference. + if not any( + image["xref"] == 0 for image in page.get_image_info(xrefs=True) + ): + continue + candidate = page.get_text("text", sort=True).strip() + primary_words = primary.split() + candidate_words = candidate.split() + # Only accept a strict continuation of the primary text; + # length alone can select unrelated or reordered content. + if ( + len(candidate_words) > len(primary_words) + and candidate_words[: len(primary_words)] == primary_words + ): + recovered[page_index] = candidate + except Exception: + # Recovery must not discard an otherwise usable page. + continue + except Exception: + pass + return recovered + + class PdfConverter(DocumentConverter): """ Converts PDFs to Markdown. @@ -547,7 +586,7 @@ def convert( # keep memory usage constant regardless of page count. markdown_chunks: list[str] = [] form_page_count = 0 - plain_page_indices: list[int] = [] + plain_pages: dict[int, str] = {} with pdfplumber.open(pdf_bytes) as pdf: for page_idx, page in enumerate(pdf.pages): @@ -555,23 +594,26 @@ def convert( if page_content is not None: form_page_count += 1 - if page_content.strip(): - markdown_chunks.append(page_content) + markdown_chunks.append(page_content.strip()) else: - plain_page_indices.append(page_idx) - text = page.extract_text() - if text and text.strip(): - markdown_chunks.append(text.strip()) + text = (page.extract_text() or "").strip() + plain_pages[page_idx] = text + markdown_chunks.append(text) page.close() # Free cached page data immediately # If no pages had form-style content, use pdfminer for # the whole document (better text spacing for prose). - if form_page_count == 0: + recovered = _recover_inline_image_pages(pdf_bytes, plain_pages) + for page_idx, text in recovered.items(): + markdown_chunks[page_idx] = text + if form_page_count == 0 and not recovered: pdf_bytes.seek(0) markdown = pdfminer.high_level.extract_text(pdf_bytes) else: - markdown = "\n\n".join(markdown_chunks).strip() + markdown = "\n\n".join( + chunk for chunk in markdown_chunks if chunk + ).strip() except Exception: # Fallback if pdfplumber fails diff --git a/packages/markitdown/tests/test_files/inline-truncated-False.pdf b/packages/markitdown/tests/test_files/inline-truncated-False.pdf new file mode 100644 index 0000000000000000000000000000000000000000..199d37b37a0c9f5af151b995ea455c7aa7138476 GIT binary patch literal 1986 zcmbuA&2HL25PNebK%T6# z5E7cihsrDYZ)f(KKY6AzO0xH?8<5V!?}tC61J|n1T9z=|!f=B^U|l=x+JrE$pqky0 zUXM<;-z4Qii*>c6Be9e<*eD&-LAj_G{v>k==YEx=p z^kb3NQ`aDGj?OsGXR@l)XlNzZKfp73|G>@3mvB0ic_HHJerloAi#!Nj`-YAyEwluS zj%8hK)Eq-GX2uwnlIY7mE{^8#tDgEscI=Zeer(D(Mz1zAZBk_N#AqyPx!b-#Z>28g zf~MuX%8PPIuZyx@*2UrbC|G0Z5&TJ(OT7YSdkht7t)%!$;tA}-XEsb0;8_shHOb*5 zPP5B#`eiWepQj&SB}FdPDNODlosjQ@8L$QTFh?v4=ExcVy*69)Vt0>692n5S5VYDz zpczbQKlJcreFKApoC_6R-rg>g>|JE1zeqYDpWkrEe{`syP15nfpLJ1Ux?v@1s1)Wv zs4d8Qt;Af5*;>M^%C|7z=ev7w`*pCsvk&$)o3&@NCbr$0?L|#&r#(ApV!Q3xjkWaJ zvm0yaw`VujGHA_q8*3T1XE)X|YR_)0B_^xwkLflRll||XX@ylDdnFeH`xddD z7Wkg$c(8z{Jr|p{Sx`RhF&mrS%e~;Wp3SiPztD5uIO|%`vmU|jRjj2lqw%WvDIvhI zQsErKK8G_gnfCmEJ2CeyyocQB+rH23D6mrB-lc#AjFC+Z~HRzwjI zRDYrvNKi=Ghre3dcah6NR74HRY?4uAsboL;QABl)l`ZT2bTj zPof@Js0DNqcN@At>_bL&GD41>7=tuF1z~7@GTJx_l=ZNN0SAjzf5>a5kY!0V!k-$K zu3xrMwI8cXhpk|$Hq#$ZXf9McuiJ#o;fgwVKqE$J3v)u+G$&8PcStpb*+ znnMezzwb%IQl?+P>yTD~w*%h7LYErE$bhaOX*wSf5JXBs*g#4pQ~msWZ*fta&73|~ zYj6F2amIyrUv7Dad-~$h69=aEE!$at=1ID5LF8kse|DtjEm->c@S6QKFS|~z7<+VW z$z1!BEa#k$)=kG_ThrT5?aj{U+q%9xvusgq+NW8Uo~@Wa@6PUH{ewr}p4+3;r0$R0 zc=hzLc>m_XDKH~*}|ZUcHie#f19@D+m*9J4QCtO zM)-0=A{_X7zgr)wygPg?5#O7(x%ALL^R)hZ1NHB3Kfv)fmqu2Pq@tM-ueEl!Q<;~E3@dKsyT&kf0$SvTbHQhiv|axQxnneqaAuYJF<=H>(@GdE3Mi< z0fq$k$6!NIgUF!2 zsYKm_fJBt26X}m@uQ1=6CyAUa%4S&>Y&J71Ib_};$bwVkUBn|uHhZ367F-Uqh2?l_ nfm5(>a=ydL3M}ukiGn1F4m;Ip2rC92Ux6f88I!4CgNykA>OfeW literal 0 HcmV?d00001 diff --git a/packages/markitdown/tests/test_files/inline-truncated-mixed.pdf b/packages/markitdown/tests/test_files/inline-truncated-mixed.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7e8c8199accb7cab05bbd2d3b2d1c462c5800529 GIT binary patch literal 13785 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 2048 + assert "AFTER_IMAGE: line 11" in recovered + table = primary[primary.index("| Item") :] + assert table in recovered + assert "Healthy prose line 44" in recovered + + +@pytest.mark.parametrize( + "primary,candidate,expected", + [ + ("", "recovered text", True), + ("prefix text", "prefix text more words", True), + ("prefix text", "unrelated much longer candidate text", False), + ("same text", "same text", False), + ], +) +def test_only_strict_text_continuations_are_selected( + monkeypatch, primary, candidate, expected +): + page = MagicMock() + page.get_image_info.return_value = [{"xref": 0}] + page.get_text.return_value = candidate + document = MagicMock() + document.__enter__.return_value = document + document.__getitem__.return_value = page + monkeypatch.setitem( + sys.modules, "pymupdf", SimpleNamespace(open=lambda **kw: document) + ) + result = module._recover_inline_image_pages(io.BytesIO(b"pdf"), {0: primary}) + assert result == ({0: candidate} if expected else {}) + document.__exit__.assert_called_once() + + +def test_missing_backend_is_optional(monkeypatch): + monkeypatch.setitem(sys.modules, "pymupdf", None) + assert module._recover_inline_image_pages(io.BytesIO(b"pdf"), {0: ""}) == {} + + +def test_backend_failure_preserves_primary(monkeypatch): + backend = MagicMock() + backend.open.side_effect = RuntimeError("broken backend") + monkeypatch.setitem(sys.modules, "pymupdf", backend) + assert module._recover_inline_image_pages(io.BytesIO(b"pdf"), {0: "primary"}) == {}