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
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
19 changes: 18 additions & 1 deletion packages/markitdown/src/markitdown/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,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 used with --pdf-page-links (required for stdin).",
)
parser.add_argument("filename", nargs="?")
args = parser.parse_args()

Expand Down Expand Up @@ -248,16 +257,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
108 changes: 93 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,45 @@ 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)
# Remove the page terminator, not form feeds within the page text.
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."""
if isinstance(source, Path):
return source.resolve().as_uri()
source = str(source)
parsed = urlsplit(source)
if parsed.scheme in ("http", "https", "file"):
safe = "/:?=&%+@;$,~-._"
return urlunsplit(
(
parsed.scheme,
quote(parsed.netloc, safe=safe + "[]"),
quote(parsed.path, safe=safe),
quote(parsed.query, safe=safe),
"",
)
)
if parsed.scheme and ("://" in source or not Path(source).drive):
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 +581,20 @@ 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 (Path(stream_info.local_path) if stream_info.local_path else None)
)
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 +610,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.
Loading