Skip to content

Commit 60c5aa8

Browse files
author
Muhtasim-Munif-Fahim
committed
fix pdf truncation after inline images
1 parent e144e0a commit 60c5aa8

4 files changed

Lines changed: 117 additions & 1 deletion

File tree

packages/markitdown/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ From PyPI:
1616
pip install markitdown[all]
1717
```
1818

19+
If you need the optional PyMuPDF fallback for some PDFs:
20+
21+
```bash
22+
pip install markitdown[pymupdf]
23+
```
24+
1925
From source:
2026

2127
```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",
@@ -55,6 +56,7 @@ docx = ["mammoth~=1.11.0", "lxml"]
5556
xlsx = ["pandas", "openpyxl"]
5657
xls = ["pandas", "xlrd"]
5758
pdf = ["pdfminer.six>=20251230", "pdfplumber>=0.11.9"]
59+
pymupdf = ["pymupdf"]
5860
outlook = ["olefile"]
5961
audio-transcription = ["pydub", "SpeechRecognition"]
6062
youtube-transcription = ["youtube-transcript-api"]

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

Lines changed: 46 additions & 1 deletion
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",
@@ -536,6 +558,9 @@ def convert(
536558

537559
assert isinstance(file_stream, io.IOBase)
538560

561+
markdown_chunks: list[str] = []
562+
has_images = False
563+
539564
# Read file stream into BytesIO for compatibility with pdfplumber
540565
pdf_bytes = io.BytesIO(file_stream.read())
541566

@@ -545,12 +570,14 @@ def convert(
545570
# pages are collected separately. page.close() is called
546571
# after each page to free pdfplumber's cached objects and
547572
# keep memory usage constant regardless of page count.
548-
markdown_chunks: list[str] = []
549573
form_page_count = 0
550574
plain_page_indices: list[int] = []
551575

552576
with pdfplumber.open(pdf_bytes) as pdf:
553577
for page_idx, page in enumerate(pdf.pages):
578+
has_images = has_images or bool(getattr(page, "images", None))
579+
580+
# Try form-style word position extraction
554581
page_content = _extract_form_content_from_words(page)
555582

556583
if page_content is not None:
@@ -583,6 +610,24 @@ def convert(
583610
pdf_bytes.seek(0)
584611
markdown = pdfminer.high_level.extract_text(pdf_bytes)
585612

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

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 (
@@ -432,6 +434,67 @@ def test_exceptions() -> None:
432434
assert type(exc_info.value.attempts[0].converter).__name__ == "PptxConverter"
433435

434436

437+
def test_pdf_converter_prefers_pymupdf_when_primary_extraction_is_truncated(
438+
monkeypatch: pytest.MonkeyPatch,
439+
) -> None:
440+
class _FakePage:
441+
images = [object()]
442+
443+
def extract_words(self, keep_blank_chars=True, x_tolerance=3, y_tolerance=3):
444+
return []
445+
446+
def extract_text(self):
447+
return "BEFORE_IMAGE: this text should be extracted"
448+
449+
class _FakePdf:
450+
pages = [_FakePage()]
451+
452+
def __enter__(self):
453+
return self
454+
455+
def __exit__(self, exc_type, exc, tb):
456+
return False
457+
458+
class _FakePyMuPdfPage:
459+
def get_text(self, kind):
460+
assert kind == "text"
461+
return (
462+
"BEFORE_IMAGE: this text should be extracted\n"
463+
"AFTER_IMAGE: this text should also be extracted"
464+
)
465+
466+
class _FakePyMuPdfDoc:
467+
def __enter__(self):
468+
return self
469+
470+
def __exit__(self, exc_type, exc, tb):
471+
return False
472+
473+
def __iter__(self):
474+
return iter([_FakePyMuPdfPage()])
475+
476+
monkeypatch.setattr(pdf_converter_module.pdfplumber, "open", lambda _: _FakePdf())
477+
monkeypatch.setattr(
478+
pdf_converter_module.pdfminer.high_level,
479+
"extract_text",
480+
lambda _: "BEFORE_IMAGE: this text should be extracted",
481+
)
482+
monkeypatch.setattr(
483+
pdf_converter_module.fitz,
484+
"open",
485+
lambda *args, **kwargs: _FakePyMuPdfDoc(),
486+
)
487+
488+
converter = pdf_converter_module.PdfConverter()
489+
result = converter.convert(
490+
io.BytesIO(b"%PDF-1.4 fake"),
491+
StreamInfo(mimetype="application/pdf", extension=".pdf"),
492+
)
493+
494+
assert "BEFORE_IMAGE: this text should be extracted" in result.markdown
495+
assert "AFTER_IMAGE: this text should also be extracted" in result.markdown
496+
497+
435498
@pytest.mark.skipif(
436499
skip_exiftool,
437500
reason="do not run if exiftool is not installed",

0 commit comments

Comments
 (0)