Skip to content

Commit 0a99efc

Browse files
author
Muhtasim-Munif-Fahim
committed
fix pdf truncation after inline images
1 parent 4a5340f commit 0a99efc

4 files changed

Lines changed: 114 additions & 0 deletions

File tree

packages/markitdown/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ From PyPI:
1313
pip install markitdown[all]
1414
```
1515

16+
If you need the optional PyMuPDF fallback for some PDFs:
17+
18+
```bash
19+
pip install markitdown[pymupdf]
20+
```
21+
1622
From source:
1723

1824
```bash

packages/markitdown/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ all = [
4242
"lxml",
4343
"pdfminer.six>=20251230",
4444
"pdfplumber>=0.11.9",
45+
"pymupdf",
4546
"olefile",
4647
"pydub",
4748
"SpeechRecognition",
@@ -54,6 +55,7 @@ docx = ["mammoth~=1.11.0", "lxml"]
5455
xlsx = ["pandas", "openpyxl"]
5556
xls = ["pandas", "xlrd"]
5657
pdf = ["pdfminer.six>=20251230", "pdfplumber>=0.11.9"]
58+
pymupdf = ["pymupdf"]
5759
outlook = ["olefile"]
5860
audio-transcription = ["pydub", "SpeechRecognition"]
5961
youtube-transcription = ["youtube-transcript-api"]

packages/markitdown/src/markitdown/converters/_pdf_converter.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ def _merge_partial_numbering_lines(text: str) -> str:
5757
return "\n".join(result_lines)
5858

5959

60+
def _extract_with_pymupdf(pdf_bytes: io.BytesIO) -> str | None:
61+
"""Extract text with PyMuPDF when the primary PDF parsers look truncated."""
62+
if fitz is None:
63+
return None
64+
65+
pdf_bytes.seek(0)
66+
chunks: list[str] = []
67+
with fitz.open(stream=pdf_bytes.read(), filetype="pdf") as doc:
68+
for page in doc:
69+
text = page.get_text("text")
70+
if text and text.strip():
71+
chunks.append(text.strip())
72+
73+
markdown = "\n\n".join(chunks).strip()
74+
return markdown or None
75+
76+
6077
# Load dependencies
6178
_dependency_exc_info = None
6279
try:
@@ -66,6 +83,11 @@ def _merge_partial_numbering_lines(text: str) -> str:
6683
except ImportError:
6784
_dependency_exc_info = sys.exc_info()
6885

86+
try:
87+
import fitz
88+
except ImportError:
89+
fitz = None
90+
6991

7092
ACCEPTED_MIME_TYPE_PREFIXES = [
7193
"application/pdf",
@@ -537,6 +559,7 @@ def convert(
537559
assert isinstance(file_stream, io.IOBase)
538560

539561
markdown_chunks: list[str] = []
562+
has_images = False
540563

541564
# Read file stream into BytesIO for compatibility with pdfplumber
542565
pdf_bytes = io.BytesIO(file_stream.read())
@@ -548,6 +571,8 @@ def convert(
548571

549572
with pdfplumber.open(pdf_bytes) as pdf:
550573
for page in pdf.pages:
574+
has_images = has_images or bool(getattr(page, "images", None))
575+
551576
# Try form-style word position extraction
552577
page_content = _extract_form_content_from_words(page)
553578

@@ -581,6 +606,24 @@ def convert(
581606
pdf_bytes.seek(0)
582607
markdown = pdfminer.high_level.extract_text(pdf_bytes)
583608

609+
# Recover from inline-image truncation cases where the primary parsers
610+
# return a much shorter body than an optional PyMuPDF pass.
611+
if fitz is not None and has_images and markdown and len(markdown) < 2048:
612+
try:
613+
pymupdf_markdown = _extract_with_pymupdf(pdf_bytes)
614+
except Exception:
615+
pymupdf_markdown = None
616+
else:
617+
if pymupdf_markdown is not None:
618+
primary_length = len(markdown.strip())
619+
pymupdf_length = len(pymupdf_markdown.strip())
620+
if pymupdf_length > primary_length and (
621+
primary_length == 0
622+
or pymupdf_length >= primary_length * 1.5
623+
or pymupdf_length - primary_length >= 200
624+
):
625+
markdown = pymupdf_markdown
626+
584627
# Post-process to merge MasterFormat-style partial numbering with following text
585628
markdown = _merge_partial_numbering_lines(markdown)
586629

packages/markitdown/tests/test_module_misc.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import pytest
77
from unittest.mock import MagicMock
88

9+
import markitdown.converters._pdf_converter as pdf_converter_module
10+
911
from markitdown._uri_utils import parse_data_uri, file_uri_to_path
1012

1113
from markitdown import (
@@ -382,6 +384,67 @@ def test_exceptions() -> None:
382384
assert type(exc_info.value.attempts[0].converter).__name__ == "PptxConverter"
383385

384386

387+
def test_pdf_converter_prefers_pymupdf_when_primary_extraction_is_truncated(
388+
monkeypatch: pytest.MonkeyPatch,
389+
) -> None:
390+
class _FakePage:
391+
images = [object()]
392+
393+
def extract_words(self, keep_blank_chars=True, x_tolerance=3, y_tolerance=3):
394+
return []
395+
396+
def extract_text(self):
397+
return "BEFORE_IMAGE: this text should be extracted"
398+
399+
class _FakePdf:
400+
pages = [_FakePage()]
401+
402+
def __enter__(self):
403+
return self
404+
405+
def __exit__(self, exc_type, exc, tb):
406+
return False
407+
408+
class _FakePyMuPdfPage:
409+
def get_text(self, kind):
410+
assert kind == "text"
411+
return (
412+
"BEFORE_IMAGE: this text should be extracted\n"
413+
"AFTER_IMAGE: this text should also be extracted"
414+
)
415+
416+
class _FakePyMuPdfDoc:
417+
def __enter__(self):
418+
return self
419+
420+
def __exit__(self, exc_type, exc, tb):
421+
return False
422+
423+
def __iter__(self):
424+
return iter([_FakePyMuPdfPage()])
425+
426+
monkeypatch.setattr(pdf_converter_module.pdfplumber, "open", lambda _: _FakePdf())
427+
monkeypatch.setattr(
428+
pdf_converter_module.pdfminer.high_level,
429+
"extract_text",
430+
lambda _: "BEFORE_IMAGE: this text should be extracted",
431+
)
432+
monkeypatch.setattr(
433+
pdf_converter_module.fitz,
434+
"open",
435+
lambda *args, **kwargs: _FakePyMuPdfDoc(),
436+
)
437+
438+
converter = pdf_converter_module.PdfConverter()
439+
result = converter.convert(
440+
io.BytesIO(b"%PDF-1.4 fake"),
441+
StreamInfo(mimetype="application/pdf", extension=".pdf"),
442+
)
443+
444+
assert "BEFORE_IMAGE: this text should be extracted" in result.markdown
445+
assert "AFTER_IMAGE: this text should also be extracted" in result.markdown
446+
447+
385448
@pytest.mark.skipif(
386449
skip_exiftool,
387450
reason="do not run if exiftool is not installed",

0 commit comments

Comments
 (0)