Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,36 @@ def convert(
with pdfplumber.open(pdf_bytes) as pdf:
for page_num, page in enumerate(pdf.pages, 1):
markdown_content.append(f"\n## Page {page_num}\n")
text_content = page.extract_text() or ""

# If OCR is enabled, interleave text and images by position
if ocr_service:
images_on_page = self._extract_page_images(pdf_bytes, page_num)
# A page with no extractable text is scanned content. OCR the
# rendered page instead of any small embedded logos or icons.
if not text_content.strip():
try:
ocr_text = self._ocr_page(page, ocr_service)
except Exception as e: # noqa: BLE001
# Rendering can fail for a page even when its embedded
# image stream remains readable. Keep full-page OCR as the
# preferred path, but retain the previous image-level
# recovery behavior in that case.
image_ocr = []
for img_info in self._extract_page_images(page):
ocr_result = ocr_service.extract_text(
img_info["stream"]
)
if ocr_result.text.strip():
image_ocr.append(
f"*[Image OCR]\n{ocr_result.text.strip()}\n[End OCR]*"
)
ocr_text = "\n\n".join(image_ocr) or (
f"*[Error processing page {page_num}: {e!s}]*"
)
markdown_content.append(f"\n\n{ocr_text}\n")
continue

images_on_page = self._extract_page_images(page)

if images_on_page:
# Extract text lines with Y positions
Expand Down Expand Up @@ -227,7 +253,6 @@ def convert(
)
else:
# Fallback: use simple text extraction
text_content = page.extract_text() or ""
lines_with_y = [
{"y": i * 10, "text": line}
for i, line in enumerate(text_content.split("\n"))
Expand Down Expand Up @@ -277,12 +302,10 @@ def convert(
markdown_content.append(img_marker)
else:
# No images detected - just extract regular text
text_content = page.extract_text() or ""
if text_content.strip():
markdown_content.append(text_content.strip())
else:
# No OCR, just extract text
text_content = page.extract_text() or ""
if text_content.strip():
markdown_content.append(text_content.strip())

Expand Down Expand Up @@ -310,33 +333,38 @@ def convert(

return DocumentConverterResult(markdown=markdown)

def _extract_page_images(self, pdf_bytes: io.BytesIO, page_num: int) -> list[dict]:
def _extract_page_images(self, page: Any) -> list[dict]:
"""
Extract images from a PDF page using pdfplumber.

Args:
pdf_bytes: PDF file as BytesIO
page_num: Page number (1-indexed)
page: An open pdfplumber page.

Returns:
List of image info dicts with 'stream', 'bbox', 'name', 'y_pos'
"""
images = []

try:
pdf_bytes.seek(0)
with pdfplumber.open(pdf_bytes) as pdf:
if page_num <= len(pdf.pages):
page = pdf.pages[page_num - 1] # 0-indexed
images = _extract_images_from_page(page)
except Exception:
pass
images = _extract_images_from_page(page)

# Sort by vertical position (top to bottom)
images.sort(key=lambda x: x["y_pos"])

return images

def _ocr_page(self, page: Any, ocr_service: LLMVisionOCRService) -> str:
"""Render and OCR one PDF page."""
page_img = page.to_image(resolution=300)
try:
with io.BytesIO() as img_stream:
page_img.original.save(img_stream, format="PNG")
img_stream.seek(0)
ocr_result = ocr_service.extract_text(img_stream)
if ocr_result.text.strip():
return f"*[Image OCR]\n{ocr_result.text.strip()}\n[End OCR]*"
return "*[No text could be extracted from this page]*"
finally:
page_img.original.close()
page_img.annotated.close()

def _ocr_full_pages(
self, pdf_bytes: io.BytesIO, ocr_service: LLMVisionOCRService
) -> str:
Expand All @@ -360,22 +388,7 @@ def _ocr_full_pages(
try:
markdown_parts.append(f"\n## Page {page_num}\n")

# Render page to image
page_img = page.to_image(resolution=300)
img_stream = io.BytesIO()
page_img.original.save(img_stream, format="PNG")
img_stream.seek(0)

# Run OCR
ocr_result = ocr_service.extract_text(img_stream)

if ocr_result.text.strip():
text = ocr_result.text.strip()
markdown_parts.append(f"*[Image OCR]\n{text}\n[End OCR]*")
else:
markdown_parts.append(
"*[No text could be extracted from this page]*"
)
markdown_parts.append(self._ocr_page(page, ocr_service))

except Exception as e:
markdown_parts.append(
Expand Down
137 changes: 137 additions & 0 deletions packages/markitdown-ocr/tests/test_pdf_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,92 @@ def test_pdf_scanned_fallback_format(svc: MockOCRService) -> None:
), f"_ocr_full_pages must produce:\n{expected!r}\nActual:\n{md!r}"


def test_scanned_page_uses_full_page_ocr(svc: MockOCRService) -> None:
"""An empty-text page is rendered instead of OCRing embedded decorations."""
converter = PdfConverterWithOCR()
page = MagicMock()
page.extract_text.return_value = ""
page.to_image.return_value.original = MagicMock()

pdf = MagicMock()
pdf.pages = [page]
pdf.__enter__.return_value = pdf

with (
patch("pdfplumber.open", return_value=pdf) as open_pdf,
patch.object(converter, "_extract_page_images") as extract_images,
):
result = converter.convert(
io.BytesIO(b"pdf"),
StreamInfo(extension=".pdf"),
ocr_service=svc,
)

assert _OCR_BLOCK in result.text_content
page.to_image.assert_called_once_with(resolution=300)
extract_images.assert_not_called()
open_pdf.assert_called_once()


def test_scanned_page_falls_back_to_embedded_image_ocr_when_rendering_fails(
svc: MockOCRService,
) -> None:
"""A render failure must not discard a decodable embedded scan."""
converter = PdfConverterWithOCR()
page = MagicMock()
page.extract_text.return_value = ""
page.to_image.side_effect = RuntimeError("render failed")

pdf = MagicMock()
pdf.pages = [page]
pdf.__enter__.return_value = pdf
image_stream = io.BytesIO(b"embedded scan")

with (
patch("pdfplumber.open", return_value=pdf),
patch.object(
converter,
"_extract_page_images",
return_value=[{"stream": image_stream, "y_pos": 0, "name": "scan"}],
) as extract_images,
):
result = converter.convert(
io.BytesIO(b"pdf"),
StreamInfo(extension=".pdf"),
ocr_service=svc,
)

assert _OCR_BLOCK in result.text_content
extract_images.assert_called_once_with(page)


def test_convert_reuses_open_page_for_image_extraction(svc: MockOCRService) -> None:
"""Embedded-image extraction must not reopen and reparse the PDF."""
converter = PdfConverterWithOCR()
page = MagicMock()
page.extract_text.return_value = "body"
page.chars = []

pdf = MagicMock()
pdf.pages = [page]
pdf.__enter__.return_value = pdf

with (
patch("pdfplumber.open", return_value=pdf) as open_pdf,
patch.object(
converter, "_extract_page_images", return_value=[]
) as extract_images,
):
converter.convert(
io.BytesIO(b"pdf"),
StreamInfo(extension=".pdf"),
ocr_service=svc,
)

extract_images.assert_called_once_with(page)
open_pdf.assert_called_once()


# ---------------------------------------------------------------------------
# No OCR service — no OCR tags emitted
# ---------------------------------------------------------------------------
Expand All @@ -245,3 +331,54 @@ def test_pdf_no_ocr_service_no_tags() -> None:
md = converter.convert(f, StreamInfo(extension=".pdf")).text_content
assert "*[Image OCR]" not in md
assert "[End OCR]*" not in md


@pytest.mark.parametrize("outcome", ["text", "empty", "ocr_error", "encode_error"])
def test_ocr_page_closes_rendered_images(monkeypatch, outcome: str) -> None:
import pdfplumber

converter = PdfConverterWithOCR()
rendered = []

class OCR:
def extract_text(self, stream):
assert stream.read(8) == b"\x89PNG\r\n\x1a\n"
if outcome == "ocr_error":
raise RuntimeError("OCR failed")
return OCRResult(
text=_MOCK_TEXT if outcome == "text" else "", backend_used="mock"
)

with pdfplumber.open(TEST_DATA_DIR / "pdf_scanned_minimal.pdf") as pdf:
page = pdf.pages[0]
to_image = page.to_image

def capture_render(**kwargs):
image = to_image(**kwargs)
rendered.append(image)
if outcome == "encode_error":

def fail_save(*args, **kwargs):
raise RuntimeError("encoding failed")

monkeypatch.setattr(image.original, "save", fail_save)
return image

monkeypatch.setattr(page, "to_image", capture_render)
if outcome.endswith("error"):
message = "OCR failed" if outcome == "ocr_error" else "encoding failed"
with pytest.raises(RuntimeError, match=message):
converter._ocr_page(page, OCR())
else:
result = converter._ocr_page(page, OCR())
assert result == (
_OCR_BLOCK
if outcome == "text"
else "*[No text could be extracted from this page]*"
)

assert len(rendered) == 1
assert rendered[0].original is not rendered[0].annotated
for image in (rendered[0].original, rendered[0].annotated):
with pytest.raises(ValueError, match="closed image"):
image.getpixel((0, 0))