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.41.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| unpdf_markdown-0.41.0.tar.gz | 21.0 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| unpdf_markdown-0.41.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 42.0 MB
Release files / unpdf_markdown-0.41.0.tar.gz
| Download URL | unpdf_markdown-0.41.0.tar.gz |
|---|---|
| Size | 21.0 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
714082053c87fac02d35ce69b793fe8e016af38ff0f396a6ac991454402b5e8a
|
|
BLAKE2b-256 checksum How to use checksums |
83588e4d9b4f3bec321521bbd8e242fd7c37981d26ac246695262d6305f64c85
|
| 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.41.0-py3-none-any.whl
| Download URL | unpdf_markdown-0.41.0-py3-none-any.whl |
|---|---|
| Size | 21.0 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
270e52617e486dc0f979583f5f1f6d31d744ed72ca538e6edf506838f8dfed15
|
|
BLAKE2b-256 checksum How to use checksums |
9b70177719ae076d04054951ba54e84ba3c4b75b88ef1ed2ea8509093428e2ff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|