Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ You can also pipe content:
cat path-to-file.pdf | markitdown
```

To link extracted text back to each physical PDF page:

```bash
markitdown --pdf-page-links path-to-file.pdf -o document.md
cat path-to-file.pdf | markitdown --extension pdf --pdf-page-links --pdf-source https://example.org/paper.pdf
```

The Python equivalent is `convert(path, pdf_page_links=True)`. For streams,
provide `pdf_source="https://example.org/paper.pdf"` or a source in `StreamInfo`.
Each page begins with `[Page N](<source#page=N>)`, including blank pages.
Page numbers are physical positions (starting at 1), not printed page labels.
Local links contain encoded absolute file paths; use `pdf_source` to override
them before sharing Markdown. Following `#page=N` depends on your PDF viewer.
The option is off by default and does not extract figure images or add OCR.

### Optional Dependencies
MarkItDown has optional dependencies for activating various file formats. Earlier in this document, we installed all optional dependencies with the `[all]` option. However, you can also install them individually for more control. For example:

Expand Down
25 changes: 20 additions & 5 deletions packages/markitdown/src/markitdown/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ def main():
description="Convert various file formats to markdown.",
prog="markitdown",
formatter_class=argparse.RawDescriptionHelpFormatter,
usage=dedent(
"""
usage=dedent("""
SYNTAX:

markitdown <OPTIONAL: FILENAME>
Expand All @@ -44,8 +43,7 @@ def main():
OR

markitdown example.pdf > example.md
"""
).strip(),
""").strip(),
)

parser.add_argument(
Expand Down Expand Up @@ -142,6 +140,15 @@ def main():
help="Keep data URIs (like base64-encoded images) in the output. By default, data URIs are truncated.",
)

parser.add_argument(
"--pdf-page-links",
action="store_true",
help="Add links to physical PDF pages. Local files expose their absolute path.",
)
parser.add_argument(
"--pdf-source",
help="Source path or URL for PDF page links (required for stdin).",
)
parser.add_argument("filename", nargs="?")
args = parser.parse_args()

Expand Down Expand Up @@ -248,16 +255,24 @@ def main():
else:
markitdown = MarkItDown(enable_plugins=args.use_plugins)

pdf_kwargs = {}
if args.pdf_page_links:
pdf_kwargs = {"pdf_page_links": True, "pdf_source": args.pdf_source}

if args.filename is None:
# Windows pipe-backed stdin can report seekable() even though it cannot rewind.
result = markitdown.convert_stream(
io.BytesIO(sys.stdin.buffer.read()),
stream_info=stream_info,
keep_data_uris=args.keep_data_uris,
**pdf_kwargs,
)
else:
result = markitdown.convert(
args.filename, stream_info=stream_info, keep_data_uris=args.keep_data_uris
args.filename,
stream_info=stream_info,
keep_data_uris=args.keep_data_uris,
**pdf_kwargs,
)

_handle_output(args, result)
Expand Down
94 changes: 79 additions & 15 deletions packages/markitdown/src/markitdown/converters/_pdf_converter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import sys
import io
import re
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
from typing import BinaryIO, Any

from .._base_converter import DocumentConverter, DocumentConverterResult
Expand Down Expand Up @@ -62,6 +64,10 @@ def _merge_partial_numbering_lines(text: str) -> str:
try:
import pdfminer
import pdfminer.high_level
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager
from pdfminer.pdfpage import PDFPage
import pdfplumber
except ImportError:
_dependency_exc_info = sys.exc_info()
Expand All @@ -75,6 +81,33 @@ def _merge_partial_numbering_lines(text: str) -> str:
ACCEPTED_FILE_EXTENSIONS = [".pdf"]


def _extract_pdfminer_pages(stream: BinaryIO) -> list[str]:
"""Use pdfminer's text extraction, retaining physical page boundaries."""
pages = []
resources = PDFResourceManager()
with io.StringIO() as output, TextConverter(
resources, output, laparams=LAParams()
) as device:
interpreter = PDFPageInterpreter(resources, device)
for page in PDFPage.get_pages(stream):
output.seek(0)
output.truncate(0)
interpreter.process_page(page)
pages.append(output.getvalue().removesuffix("\f"))
return pages


def _pdf_source_uri(source: str | Path) -> str:
"""Encode a source for Markdown links; fragments are reserved for page numbers."""
source = str(source)
parsed = urlsplit(source)
if parsed.scheme in ("http", "https", "file"):
return quote(urlunsplit(parsed._replace(fragment="")), safe="/:?=&%+@;$,~-._")
if "://" in source or parsed.scheme in ("javascript", "data"):
raise ValueError("PDF source must be a local path or an HTTP(S)/file URL")
return Path(source).resolve().as_uri()


def _to_markdown_table(table: list[list[str]], include_separator: bool = True) -> str:
"""Convert a 2D list (rows/columns) into a nicely aligned Markdown table.

Expand Down Expand Up @@ -536,6 +569,18 @@ def convert(

assert isinstance(file_stream, io.IOBase)

page_links = kwargs.get("pdf_page_links", False)
source_uri = None
if page_links:
source = (
kwargs.get("pdf_source") or stream_info.url or stream_info.local_path
)
if not source:
raise ValueError(
"PDF page links require a source path or URL (pdf_source)"
)
source_uri = _pdf_source_uri(source)

# Read file stream into BytesIO for compatibility with pdfplumber
pdf_bytes = io.BytesIO(file_stream.read())

Expand All @@ -551,32 +596,51 @@ def convert(

with pdfplumber.open(pdf_bytes) as pdf:
for page_idx, page in enumerate(pdf.pages):
page_content = _extract_form_content_from_words(page)

if page_content is not None:
form_page_count += 1
if page_content.strip():
markdown_chunks.append(page_content)
else:
plain_page_indices.append(page_idx)
text = page.extract_text()
if text and text.strip():
markdown_chunks.append(text.strip())

page.close() # Free cached page data immediately
try:
page_content = _extract_form_content_from_words(page)

if page_content is not None:
form_page_count += 1
if page_links or page_content.strip():
markdown_chunks.append(page_content)
else:
plain_page_indices.append(page_idx)
text = page.extract_text()
if page_links or (text and text.strip()):
markdown_chunks.append((text or "").strip())
finally:
page.close() # Free cached page data, including on failure

# If no pages had form-style content, use pdfminer for
# the whole document (better text spacing for prose).
if form_page_count == 0:
pdf_bytes.seek(0)
markdown = pdfminer.high_level.extract_text(pdf_bytes)
if page_links:
markdown_chunks = _extract_pdfminer_pages(pdf_bytes)
markdown = ""
else:
markdown = pdfminer.high_level.extract_text(pdf_bytes)
else:
markdown = "\n\n".join(markdown_chunks).strip()

except Exception:
# Fallback if pdfplumber fails
pdf_bytes.seek(0)
markdown = pdfminer.high_level.extract_text(pdf_bytes)
if page_links:
markdown_chunks = _extract_pdfminer_pages(pdf_bytes)
markdown = ""
else:
markdown = pdfminer.high_level.extract_text(pdf_bytes)

if page_links:
if not markdown_chunks:
raise ValueError("PDF contains no pages")
markdown = "\n\n".join(
f"[Page {index}](<{source_uri}#page={index}>)\n\n"
+ _merge_partial_numbering_lines(body).strip()
for index, body in enumerate(markdown_chunks, 1)
)
return DocumentConverterResult(markdown=markdown)

# Fallback if still empty
if not markdown:
Expand Down
Binary file not shown.
143 changes: 143 additions & 0 deletions packages/markitdown/tests/test_pdf_page_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Page references preserve physical positions without changing default output."""

import io
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from pdfminer.pdfparser import PDFSyntaxError

from markitdown import MarkItDown, StreamInfo
from markitdown.converters import PdfConverter
from markitdown.converters._pdf_converter import (
_extract_pdfminer_pages,
_pdf_source_uri,
)

PDF = Path(__file__).parent / "test_files/test.pdf"


def test_default_output_is_unchanged():
converter = MarkItDown()
assert (
converter.convert(PDF).markdown
== converter.convert(PDF, pdf_page_links=False).markdown
)


def test_real_pages_and_pdfminer_fallback_match():
with PDF.open("rb") as stream:
pages = _extract_pdfminer_pages(stream)
assert pages
converter = MarkItDown()
result = converter.convert(PDF, pdf_page_links=True).markdown
for index, body in enumerate(pages, 1):
assert f"[Page {index}](<{PDF.resolve().as_uri()}#page={index}>)" in result
assert body.strip() in result
with patch(
"markitdown.converters._pdf_converter.pdfplumber.open",
side_effect=RuntimeError("open failed"),
):
assert converter.convert(PDF, pdf_page_links=True).markdown == result


def test_real_blank_middle_page_keeps_physical_index():
path = PDF.with_name("test_pdf_blank_middle.pdf")
with path.open("rb") as stream:
pages = _extract_pdfminer_pages(stream)
assert len(pages) == 3
assert [page.strip() for page in pages] == ["First page", "", "Third page"]
result = MarkItDown().convert(path, pdf_page_links=True).markdown
sections = result.split("[Page ")[1:]
assert len(sections) == 3
assert sections[0].startswith("1]") and sections[2].startswith("3]")
assert sections[1].startswith("2]")
assert not sections[1].split(">)", 1)[1].strip()


def test_mixed_pages_keep_blank_page_and_newline_cleanup():
pages = [MagicMock(), MagicMock(), MagicMock()]
pdf = MagicMock()
pdf.__enter__.return_value.pages = pages
pages[1].extract_text.return_value = ""
pages[2].extract_text.return_value = ".1\nlast page"
with patch(
"markitdown.converters._pdf_converter.pdfplumber.open", return_value=pdf
), patch(
"markitdown.converters._pdf_converter._extract_form_content_from_words",
side_effect=["| A | B |", None, None],
):
result = (
PdfConverter()
.convert(
io.BytesIO(b"pdf"),
StreamInfo(url="https://example.org/paper.pdf?download=1#old"),
pdf_page_links=True,
)
.markdown
)
assert result == (
"[Page 1](<https://example.org/paper.pdf?download=1#page=1>)\n\n| A | B |\n\n"
"[Page 2](<https://example.org/paper.pdf?download=1#page=2>)\n\n\n\n"
"[Page 3](<https://example.org/paper.pdf?download=1#page=3>)\n\n.1 last page"
)
for page in pages:
page.close.assert_called_once()


def test_encoded_sources_and_override(tmp_path):
path = tmp_path / "r\u00e9sum\u00e9 # (1).pdf"
assert _pdf_source_uri(str(path)) == path.as_uri()
assert _pdf_source_uri("https://example.org/a b.pdf?x=1#old") == (
"https://example.org/a%20b.pdf?x=1"
)
result = (
MarkItDown()
.convert(PDF, pdf_page_links=True, pdf_source="https://example.org/paper.pdf")
.markdown
)
assert "https://example.org/paper.pdf#page=1" in result
assert "file:" not in result


def test_stream_requires_source_and_invalid_pdf_still_fails():
with PDF.open("rb") as stream:
with pytest.raises(ValueError, match="require a source"):
PdfConverter().convert(stream, StreamInfo(), pdf_page_links=True)
with pytest.raises(PDFSyntaxError):
PdfConverter().convert(
io.BytesIO(b"invalid pdf"),
StreamInfo(local_path="paper.pdf"),
pdf_page_links=True,
)
with pytest.raises(ValueError, match="local path"):
_pdf_source_uri("javascript:alert(1)")


def test_pdfminer_boundaries_do_not_depend_on_form_feeds():
bodies = ["first\finside\f\f", "\f", "third\f"]
with patch(
"markitdown.converters._pdf_converter.PDFPage.get_pages",
return_value=iter(bodies),
), patch(
"markitdown.converters._pdf_converter.PDFPageInterpreter.process_page",
autospec=True,
side_effect=lambda interpreter, body: interpreter.device.outfp.write(body),
):
assert _extract_pdfminer_pages(io.BytesIO()) == ["first\finside\f", "", "third"]


def test_cli_forwards_pdf_options(monkeypatch, capsys):
from markitdown import DocumentConverterResult
from markitdown.__main__ import main

convert = MagicMock(return_value=DocumentConverterResult(markdown="linked"))
monkeypatch.setattr("markitdown.__main__.MarkItDown.convert", convert)
monkeypatch.setattr(
"sys.argv",
["markitdown", "--pdf-page-links", "--pdf-source", "paper.pdf", "input.pdf"],
)
main()
assert convert.call_args.kwargs["pdf_page_links"] is True
assert convert.call_args.kwargs["pdf_source"] == "paper.pdf"
assert capsys.readouterr().out.strip() == "linked"