Skip to main content

unpdf

Python bindings for unpdf - High-performance PDF content extraction to Markdown, text, and JSON.

Installation

pip install unpdf-markdown

The distribution is unpdf-markdown; the import name is unpdf.

Quick Start

import unpdf

# Convert PDF to Markdown
markdown = unpdf.to_markdown("document.pdf")
print(markdown)

# Convert PDF to plain text
text = unpdf.to_text("document.pdf")
print(text)

# Convert PDF to JSON
json_data = unpdf.to_json("document.pdf", pretty=True)
print(json_data)

# Get document information
info = unpdf.get_info("document.pdf")
print(info)

# Get page count
pages = unpdf.get_page_count("document.pdf")
print(f"Total pages: {pages}")

# Check if file is a valid PDF
is_valid = unpdf.is_pdf("document.pdf")
print(f"Is valid PDF: {is_valid}")

Input: path or bytes

Every function takes its PDF as PdfSource — a path (str, or any os.PathLike such as pathlib.Path) or the PDF's own bytes. Types are unambiguous: str is always a path, bytes is always content, and bytes go through the native in-memory parser rather than a temporary file.

from pathlib import Path
import unpdf

unpdf.to_markdown("document.pdf")
unpdf.to_markdown(Path("document.pdf"))
unpdf.to_markdown(pdf_bytes)

API Reference

Every function below also accepts an optional options: dict keyword argument — see Parsing options.

to_markdown(source: PdfSource, options: dict | None = None, render_options: dict | None = None) -> str

Convert a PDF file to Markdown format. render_options is a dict of rendering settings; every key is optional and an absent one keeps the default:

Key Value
include_frontmatter bool — YAML frontmatter with the document metadata
page_markers "none" (default) or "comment" — <!-- page N --> at each page boundary
escape_special_chars bool (default True) — escape text that would read as Markdown syntax
table_fallback "markdown" (default), "html" or "ascii" — how a table Markdown cannot express is written
max_heading_level int 1–6 (default 6) — deeper headings take this level
preserve_line_breaks bool — keep line breaks inside paragraphs
list_marker one character (default "-")
cleanup_preset "none", "minimal", "standard" (default) or "aggressive"
refine bool — the lossless shape-refinement pass
line_width int — wrap long lines (0, the default, does not wrap)
pages "all", {"range": {"from": 1, "to": 3}} or {"pages": [1, 4]}
image_path_prefix str — prefix of the image paths written
ai_refine {"base_url", "api_key", "model", "instructions"} — a generative rewrite pass

A key outside this table, or a value the library cannot honour, raises UnpdfError with kind == ErrorKind.INVALID_ARGUMENT; a page the document does not have, with ErrorKind.PAGE_OUT_OF_RANGE.

import unpdf

markdown = unpdf.to_markdown(
    "document.pdf",
    render_options={"include_frontmatter": True, "page_markers": "comment"},
)

to_text(source: PdfSource, options: dict | None = None) -> str

Convert a PDF file to plain text.

to_json(source: PdfSource, pretty: bool = False, options: dict | None = None) -> str

Convert a PDF file to JSON format.

get_info(source: PdfSource, options: dict | None = None) -> dict

Get document metadata. Keys: section_count (the page count), resource_count, plus title / author only when the document sets them.

get_page_count(source: PdfSource, options: dict | None = None) -> int

Get the number of pages in a PDF file.

is_pdf(source: PdfSource, options: dict | None = None) -> bool

Check if a file is a valid PDF.

version() -> str

Get the version of the native library.

get_extraction_quality(source: PdfSource, options: dict | None = None) -> dict

Document-level extraction diagnostics: char_count, word_count, replacement_char_count, encrypted, is_scan_pdf, suppressed_ocr_pages, suppressed_text_runs, undecodable_content_streams, pages_incomplete, declared_page_count, unresolved_page_nodes, skipped_object_count, unsupported_image_count, ai_fallback_count. See "Incomplete extraction" below.

get_page_stats(source: PdfSource, page_number: int, options: dict | None = None) -> dict

Per-page content-stream operator counts (1-indexed): page, text_op_count, image_op_count, form_op_count, ocr_text_suppressed, suppressed_text_runs, undecodable_content_streams. Text and images a page paints through Form XObjects count as the page's own; form_op_count counts the form paints. The last two are this page's share of the document-level counts of the same name.

unreadable_fonts says which fonts the suppressed_text_runs came from: a list of {"name", "reason", "runs"}, one per font and reason in order of first loss ([] when nothing was discarded). name is the font's /BaseFont, or its resource name when it has none. reason is "composite_unresolved" (a Type0/CID font whose codes map to no text) or "binary_density" (a simple font whose decode was binary noise); treat any other value as a generic "unreadable". The runs sum to suppressed_text_runs.

How well the page was read: rotation (its /Rotate), image_coverage (share of the page painted by images, 0–1 — a full-page scan is near 1, a logo a few hundredths), rotated_text_runs, ruled_grids / ruled_tables (ruling-line grids drawn vs. tables built from them), reading_regions, column_count and ambiguous_layout_regions (regions read across although their text looked like two columns — where the reading order guessed).

get_tables(source: PdfSource, tsv: bool = False, options: dict | None = None) -> list[dict]

Every table as CSV (RFC 4180), or tab-separated with tsv=True, in reading order: {"page", "index", "text"} — the page, the table's place among that page's tables (from 1), and the text. A merged cell's text is in its top-left position and the positions it covers are empty, so every record has the same number of fields: pandas.read_csv(io.StringIO(t["text"])) reads one. Document.get_tables(tsv=False) does the same on an open document.

get_resource_ids(source: PdfSource, options: dict | None = None) -> list[str]

get_resource_info(source: PdfSource, resource_id: str, options: dict | None = None) -> dict

get_resource_data(source: PdfSource, resource_id: str, options: dict | None = None) -> bytes

List and retrieve extracted embedded resources (images). Resource info carries page — the page the resource was collected from (1-based); read it from there rather than from the id, whose format is not part of the contract. See Embedded resources — these only return anything once extract_resources is enabled via options.

Parsing options

Every function accepts options: dict | None = None. Every key is optional; an absent key keeps unpdf's own default:

Key Type Default Meaning
error_mode "strict" | "lenient" "lenient" Fail on any parse error, or skip invalid content and continue. Lenient is the default, so a successful call can return less than the document held — the quality fields below say how much.
extract_text bool true Extract each page's text. false is structure only: pages are still produced, with none of their content blocks. Independent of extract_resources.
extract_resources bool False Populate the resource inventory get_resource_ids etc. read from. Off by default — bounds peak memory on large PDFs.
min_image_dimension int 64 Images below this on either axis are dropped as decorative (logos, rule lines, tracking pixels). 0 keeps every image.
parallel bool True Multi-threaded page processing.
password str — Password for encrypted documents.
suppress_low_confidence_ocr bool True Drop an invisible OCR text layer whose recognized text is not readable.
info = unpdf.get_info("document.pdf", options={"extract_resources": True})
print(info["resource_count"])

Rendering pages

Document keeps a parsed PDF open, so pages can be rendered — and their statistics read — without parsing it again for every call. The page rendered is the page text extraction reads.

import unpdf

with unpdf.Document("report.pdf") as doc:
    stats = doc.get_page_stats(3)
    if stats["image_coverage"] > 0.9:
        page = doc.render_page(3, dpi=150)          # region="crop" (default) or "media"
        open("page3.png", "wb").write(page.png)
        print(page.width, page.height, page.gaps)   # gaps: what could not be painted
        print(page.substituted_text_runs)           # text in a stand-in for a font not embedded

unpdf.render_page(source, page_number, dpi=150, region="crop") renders one page in one call (parsing the document for it).

Embedded resources

options = {"extract_resources": True, "min_image_dimension": 0}
data = open("document.pdf", "rb").read()

for resource_id in unpdf.get_resource_ids(data, options=options):
    info = unpdf.get_resource_info(data, resource_id, options=options)
    image_bytes = unpdf.get_resource_data(data, resource_id, options=options)
    # ...

Each call above re-parses the document — options must be passed identically to every call in the sequence, since Python has no persistent document handle the way the C#/Rust APIs do.

Detecting scanned (image-only) PDFs

Empty output can mean a scanned document, a genuinely blank page, or a parse failure. The introspection surface tells them apart:

import unpdf

if unpdf.get_extraction_quality("scan.pdf")["is_scan_pdf"]:
    print("Scanned document - OCR required")

stats = unpdf.get_page_stats("scan.pdf", 1)
if stats["text_op_count"] == 0 and stats["image_op_count"] > 0:
    print("Page 1 is image-only (scanned)")
elif stats["text_op_count"] == 0:
    print("Page 1 is genuinely blank")

Note: a searchable scan (page image plus an invisible OCR text layer) reports text_op_count > 0 — combine the check with ocr_text_suppressed, which flags pages whose unreadable OCR layer was dropped.

Incomplete extraction

A damaged PDF does not always fail. When the cross-reference table survives but the objects it points at do not, the parser returns the pages it could read — a success over an incomplete page set. Check before indexing or archiving, because a page that silently never arrived is indistinguishable from a page that never existed:

quality = unpdf.get_extraction_quality("document.pdf")
if quality["pages_incomplete"]:
    print(f"incomplete - document declares {quality['declared_page_count']} page(s)")
Field Meaning
pages_incomplete Pages are known to be missing. The one field to branch on.
declared_page_count Page count the document declares, or None if unreadable.
unresolved_page_nodes Unreadable page-tree nodes — non-zero means incomplete, not a page count.
skipped_object_count Objects that could not be loaded. Most cost no page.

Also note that get_info()["resource_count"] counts the extracted-resource inventory, which is empty unless options={"extract_resources": True} (see Parsing options) — it is not a count of images on the page. Use get_page_stats for scan detection.

Handling failures

UnpdfError carries a kind so you can branch on the reason a call failed instead of matching on message text:

from unpdf import to_text, ErrorKind, UnpdfError

try:
    text = to_text("document.pdf")
except UnpdfError as e:
    if e.kind == ErrorKind.ENCRYPTED:
        print("Password required")
    elif e.kind in (ErrorKind.CORRUPTED, ErrorKind.PDF_PARSE):
        print("The file is damaged")
    else:
        print(f"Extraction failed ({e.kind.name}): {e}")

UnpdfError subclasses RuntimeError, so existing except RuntimeError handlers keep working. ErrorKind values are part of the native ABI: new reasons take new numbers and existing ones are never renumbered, so treat an unrecognised value as a generic failure.

Native library

The package ships the native library for your platform. To load another build of it — one you compiled from the Rust crate, say — set UNPDF_LIB_PATH to that file. A path that names no file is an error: importing raises OSError naming the path, rather than quietly loading the packaged library instead.

License

MIT License

Metadata

Release files for unpdf-markdown 0.39.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for unpdf-markdown 0.39.0
File Size Uploaded
unpdf_markdown-0.39.0.tar.gz 20.9 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for unpdf-markdown 0.39.0
File Interpreter ABI Platform
unpdf_markdown-0.39.0-py3-none-any.whl Python 3 none any Details

Total release size: 41.9 MB

Release files / unpdf_markdown-0.39.0.tar.gz

Download URL unpdf_markdown-0.39.0.tar.gz
Size 20.9 MB
Tags Source
SHA-256 checksum
How to use checksums
dcd9a609bfefa4f77e8beb9475c9b75f3676d378b72b437d54ffdd5d835ea90f
BLAKE2b-256 checksum
How to use checksums
c6ae47b87d5ee3695c8fc77358eb58c5c99bf6c2af7de1a5cb4e22b334d37712
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / unpdf_markdown-0.39.0-py3-none-any.whl

Download URL unpdf_markdown-0.39.0-py3-none-any.whl
Size 21.0 MB
Tags Python 3
SHA-256 checksum
How to use checksums
f4fc30d155c04196a3cac1000fd355c8e5a41a65e6d93df45d900f0cda980354
BLAKE2b-256 checksum
How to use checksums
fdd19fa77cd20031961ca15adca2bab2ec12dd32640f267962231f4de28d187f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.41.0

2 release files

0.40.0

2 release files

This release

0.39.0 This release

2 release files

0.38.0

2 release files

0.37.0

2 release files

0.22.0

2 release files

0.21.0

2 release files

0.20.0

2 release files

0.19.0

2 release files

0.17.0

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.2

1 release file

0.12.1

1 release file

0.12.0

1 release file

0.11.0

1 release file

0.10.0

1 release file

0.9.0

1 release file

0.8.0

1 release file

0.7.1

1 release file

0.7.0

1 release file

0.6.4

1 release file

0.6.3

1 release file

0.6.2

1 release file

0.6.1

1 release file

0.6.0

1 release file

0.5.0

1 release file

0.4.6

1 release file

0.4.5

1 release file

0.4.4

1 release file

0.4.3

1 release file

0.4.2

1 release file

0.4.1

1 release file

0.4.0

1 release file

0.2.3

1 release file

0.2.1

1 release file

0.1.10

1 release file

0.1.9

1 release file

0.1.8

1 release file

0.1.6

1 release file

0.1.5

1 release file

0.1.1

1 release file

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page