Metadata-Version: 2.4
Name: pylopdf
Version: 0.11.1
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: WebAssembly :: Emscripten
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Programming Language :: Rust
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Dist: pylopdf-fonts-cjk>=0.1.0 ; extra == 'cjk'
Requires-Dist: pylopdf-ocr-models>=0.1.0,<0.2 ; extra == 'ocr'
Provides-Extra: cjk
Provides-Extra: ocr
License-File: LICENSE
License-File: NOTICE.md
License-File: LICENSES/APAFML.txt
License-File: LICENSES/Apache-2.0.txt
Summary: PDF editing, rendering, extraction, and generation for Python, powered by Rust. MIT, no mandatory Python dependencies, lightweight wheels.
Home-Page: https://github.com/yhay81/pylopdf
Author-email: Yusuke Hayashi <yusuke8h@gmail.com>
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/yhay81/pylopdf/blob/main/CHANGELOG.md
Project-URL: Documentation, https://yhay81.github.io/pylopdf/
Project-URL: Homepage, https://github.com/yhay81/pylopdf
Project-URL: Issues, https://github.com/yhay81/pylopdf/issues
Project-URL: Source, https://github.com/yhay81/pylopdf

# pylopdf

[![PyPI](https://img.shields.io/pypi/v/pylopdf)](https://pypi.org/project/pylopdf/)
[![CI](https://github.com/yhay81/pylopdf/actions/workflows/ci.yml/badge.svg)](https://github.com/yhay81/pylopdf/actions/workflows/ci.yml)
[![Python](https://img.shields.io/pypi/pyversions/pylopdf)](https://pypi.org/project/pylopdf/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

[Japanese README](README.ja.md) /
**Documentation: <https://yhay81.github.io/pylopdf/>** (with a
[pymupdf migration guide](https://yhay81.github.io/pylopdf/migration/) and
[API stability policy](https://yhay81.github.io/pylopdf/stability/))

PDF editing, rendering, extraction, and generation for Python, powered by Rust —
[lopdf](https://github.com/J-F-Liu/lopdf) for editing,
[hayro](https://github.com/LaurenzV/hayro) (the pure-Rust PDF renderer adopted
by Typst) for rendering and extraction, and
[krilla](https://github.com/LaurenzV/krilla) with
[HarfRust](https://github.com/harfbuzz/harfrust) for generated text and form
appearances.

**MIT licensed, no mandatory Python dependencies, lightweight wheels.** Covers
the common pymupdf use cases without the AGPL.

## Why pylopdf?

| | pylopdf | pymupdf | pypdf | pypdfium2 | pdf_oxide | pikepdf |
|---|---|---|---|---|---|---|
| License | **MIT** | AGPL / commercial | BSD | Apache/BSD | MIT/Apache-2.0 | MPL-2.0 |
| Wheel size (MiB) | **~5.0–5.8** | ~17.5–24.7 | small (pure Python) | ~2.7–5.0 | ~9.7–10.9 | ~1.9–4.6 |
| Editing (merge / split / rotate / outlines) | ✅ | ✅ | ✅ | limited | ✅ | ✅ (structure-focused) |
| Rendering (PNG / SVG) | ✅ | ✅ | ❌ | ✅ (PNG) | ❌ | ❌ (docs point to other tools) |
| Text extraction | ✅ (positioned text, tables, Markdown) | ✅ (advanced) | ✅ | ✅ | ✅ (advanced, table detection / Markdown) | ❌ (docs point to other tools) |
| Encryption (AES-256) | ✅ read & write | ✅ | ✅ | read only | undocumented | ✅ (via qpdf) |
| Japanese font fallback / generation | ✅ ([cjk] extra) | ✅ | — | manual | — | — |
| Implementation | **pure Rust** | C/C++ | Python | C++ (PDFium) | Rust | C++ (qpdf) |

Wheel sizes are the ranges of published files for pylopdf 0.10.0, pymupdf
1.28.0, pypdfium2 5.12.1, pdf-oxide 0.3.75, and pikepdf 10.10.0 on 2026-07-25;
the exact artifact depends on platform and Python ABI.

- Fits size-constrained environments such as AWS Lambda
- Safe for commercial projects that need to avoid the AGPL
- abi3: one wheel covers Python 3.10–3.14
- v0.10 includes native `cp314t` wheels for free-threaded Python 3.14
- API modeled after [pymupdf](https://github.com/pymupdf/PyMuPDF)

**Limitations**: multicolumn text follows deterministic whitespace gutters, and
`find_tables()` reconstructs bordered grids from strokes or thin filled rules,
including rectangular merged cells. It conservatively separates repeated text
records when a generator omits internal rules inside an otherwise connected
grid. The opt-in
`find_tables(strategy="text")` handles high-confidence borderless layouts, but
can interpret aligned multicolumn prose as a table. `Document.to_markdown()`
inserts complete bordered tables by default; pass `table_strategy="text"` to
opt into borderless candidates or `None` to preserve plain layout text.
Vertical CJK columns are
reconstructed conservatively and ordered right-to-left; ruby, warichu, and
mixed-orientation Japanese typography are not interpreted semantically. There
is no general-purpose regeneration of arbitrary existing annotation
appearances. AcroForm filling generates appearances for text, choice, checkbox,
radio, and comb text fields, but rich text, pushbuttons, and signature fields
remain out of scope. Typesetting, PDF/A output, and digital signatures are
covered by the ecosystem recipes below. Native OCR returns axis-aligned word
boxes; automatic page orientation, arbitrary deskew, ruby, warichu, and
mixed-orientation typography remain explicit limits.

## Install

```bash
pip install pylopdf
```

To render Japanese PDFs without embedded fonts, or auto-subset a JP font for
Japanese/Han `insert_text` and `insert_textbox`, install the optional font
package (Noto Sans/Serif JP):

```bash
pip install pylopdf[cjk]
```

For local PP-OCRv6 recognition without system executables, shared libraries,
network requests, or an ONNX parser at runtime, install the optional model
wheel:

```bash
pip install "pylopdf[ocr]"
```

See the [offline OCR guide](https://yhay81.github.io/pylopdf/ocr/) for memory
controls, searchable-layer behavior, and current layout boundaries.

### WebAssembly and Cloudflare Workers

pylopdf 0.11 adds a static PyEmscripten wheel for the pinned Python 3.13 /
Pyodide 0.28.3 ABI. Cloudflare Python Workers are the supported public
installation path: every release resolves the wheel from PyPI, bundles the
repository's
[bounded PDF extraction Worker](examples/cloudflare-worker/README.md), starts
local `workerd`, and verifies that a module-scope `import pylopdf` can serve
`/health`.

Pyodide 0.28.3 itself is runtime-tested, but its older `micropip` cannot install
PyPI's PEP 783 wheel tag directly. Native OCR inference is intentionally absent
from Wasm; external OCR results can still be inserted with
`Page.insert_ocr_text_layer()`. See the
[WebAssembly guide](https://yhay81.github.io/pylopdf/wasm/) for the exact
version matrix, local Pyodide workflow, resource policy, and release gates.

Building from source (requires a Rust toolchain):

```bash
uv sync
```

### Concurrency and free-threaded Python

Starting with v0.10, pylopdf supports concurrent work on distinct `Document`
objects. Heavy native operations release the GIL, and the `cp314-cp314t` wheel
keeps the GIL disabled on free-threaded Python 3.14. Calls or edits on the same
`Document` must be serialized; use `Document.render_pages(workers=...)` for
supported parallel rendering within one document.

`Pixmap` is immutable. The cp314t wheel supports read-only, zero-copy
`memoryview(pixmap)`; the Python 3.10-compatible abi3 wheel uses the one-copy
`pixmap.samples` fallback. See the
[full concurrency contract](https://yhay81.github.io/pylopdf/concurrency/).

## Usage

```python
import pylopdf

# Open from a path or bytes
doc = pylopdf.open("input.pdf")
doc = pylopdf.open(stream=pdf_bytes)

# Page count
print(doc.page_count)  # same as len(doc)

# Metadata
print(doc.metadata["title"])
doc.set_metadata({"title": "Monthly Report", "author": "Alice"})

# Text extraction (0-based page numbers)
text = doc.get_page_text(0)

# Positioned text and search (pymupdf-style, top-left origin)
words = doc[0].get_text("words")     # (x0, y0, x1, y1, word, block, line, word_no)
layout = doc[0].get_text("dict")     # blocks -> lines -> spans with bboxes
rects = doc[0].search_for("tax")     # case-insensitive, list[Rect]
tables = doc[0].find_tables(clip=(30, 30, 500, 700))  # complete bordered grids in a region
text_tables = doc[0].find_tables(strategy="text")  # opt-in borderless tables
confidence = text_tables[0].confidence if text_tables else None  # ranking heuristic, not probability
images = doc[0].get_images()         # [{"width", "height", "bbox", "ext", "image"}]
pix = doc[0].get_pixmap(dpi=144, clip=(0, 0, 300, 200))  # cropped RGBA8 pixels for NumPy / PIL

# Rendering
png: bytes = doc.render_page(0)             # 72 dpi; 64 MiB encoded-output cap
png2x: bytes = doc.render_page(0, scale=2)  # 144 dpi
png300 = doc.render_page(0, dpi=300)        # by resolution
png_bg = doc.render_page(0, background=(255, 255, 255))  # white background (default: transparent)
batch = doc.render_pages([0, 1, 2], scale=2, workers=4)  # ordered parallel PNGs
svg: str = doc.render_page_svg(0)

# Delete pages (split)
doc.delete_page(0)
doc.delete_pages([1, 2])

# Keep/reorder pages (repeating a page duplicates it)
doc.select([2, 0])

# Page objects (0-based; negative counts from the end)
page = doc[0]
for page in doc:
    print(page.number, page.rect)
page.set_rotation(90)                # display rotation (multiples of 90)
page.set_mediabox((0, 0, 300, 400))  # page boxes

# Insert / copy pages
doc.new_page()          # blank A4 appended
doc.copy_page(0, to=1)  # duplicate page 0 in front of page 1

# Drawing (coordinates are the same top-left display space as search_for / get_text)
page.insert_image((72, 72, 200, 200), filename="logo.png")   # JPEG passthrough, PNG with alpha
page.insert_image(page.search_for("Approved")[0], stream=stamp_png)  # stamp at a search hit
page.insert_image((300, 72, 500, 200), pixmap=thumbnail, rotate=90)  # direct RGBA, clockwise rotation
page.show_pdf_page(page.rect, letterhead)  # vector overlay; same-document sources also work
page.replace_text("DRAFT", "FINAL")        # bounded, atomic simple-font replacement

# Headers / footers / page numbers (standard-14 fonts, WinAnsi range)
for i, p in enumerate(doc):
    p.insert_text((p.rect.width - 90, p.rect.height - 30), f"Page {i + 1}", fontsize=9)

# Japanese/Han text auto-subsets the JP font with pip install "pylopdf[cjk]"
page.insert_text((40, 80), "社外秘", fontsize=20, color=(0.8, 0, 0))

# Wrap a paragraph into a rectangle; negative means nothing was drawn
spare = page.insert_textbox(
    (40, 100, 300, 220),
    "日本語も空白なしで自然に折り返します。",
    fontsize=12,
    align=pylopdf.TEXT_ALIGN_JUSTIFY,
)

# Annotations: search & highlight / link
page.add_highlight_annot(page.search_for("important"))  # appearance stream included (visible everywhere)
page.add_link_annot(page.search_for("Example")[0], "https://example.com/")
print(page.annots())  # [{"type", "rect", "contents", "uri"}]

# Native offline OCR: model inputs share a 64 MiB cap; add a searchable layer
engine = pylopdf.OcrEngine(threads=4, max_concurrent=1)  # pip install "pylopdf[ocr]"
words = page.get_text_ocr(engine=engine)
page.apply_ocr(engine=engine)  # skips existing searchable text by default
# Correct a sideways scan clockwise for OCR without changing page rotation
page.apply_ocr(engine=engine, rotation=270)

# Or write external OCR results as an invisible text layer
page.insert_ocr_text_layer(ocr_words)  # (x0, y0, x1, y1, text, ...); max 4,096 words / 1 MiB UTF-8 text

# Markdown conversion (RAG / LLM preprocessing; bordered tables are automatic)
md = doc.to_markdown()
md_with_borderless_tables = doc.to_markdown(table_strategy="text")
md_p1 = doc[0].to_markdown()

# Read the PDF/A self-declaration (1 MiB XMP cap; validation belongs to veraPDF)
print(doc.get_pdfa_claim())  # e.g. (2, "B") for PDF/A-2b; None if absent

# Forms (AcroForm): read and fill
print(doc.get_form_fields())        # [{"name", "type", "value"}]
doc.set_form_field("customer", "Taro Yamada")
doc.set_form_field("customer_ja", "山田 太郎")  # auto-subset with pylopdf[cjk]
doc.set_form_field("agree", True)   # checkboxes take bool or a state name

# Page labels (display numbers: roman front matter + decimal body, etc.)
doc.set_page_labels([{"startpage": 0, "style": "r"}, {"startpage": 3, "style": "D"}])
print(doc[4].get_label())  # "2"

# File attachments (e.g. attach the XML data to an invoice PDF)
doc.embfile_add("invoice.xml", xml_bytes, filename="invoice-data.xml")
print(doc.embfile_names())  # ["invoice.xml"]
xml = doc.embfile_get("invoice.xml")  # decoded output is capped at 64 MiB by default
# known_large = doc.embfile_get("archive.bin", max_size=256 * 1024 * 1024)

# Table of contents (page numbers are 1-based here, pymupdf-compatible)
doc.set_toc([[1, "Chapter 1", 1], [2, "Section 1.1", 2]])
print(doc.get_toc())

# Merge (with ranges, reversed order, and an insertion position)
merged = pylopdf.Document()
merged.insert_pdf(pylopdf.open("a.pdf"))
merged.insert_pdf(pylopdf.open("b.pdf"), from_page=0, to_page=2, start_at=0)

# Save
merged.save("merged.pdf")
data: bytes = merged.tobytes()  # 512 MiB output cap; max_size=None opts out

# Optimized save (prune unreferenced objects + compress + object streams)
merged.save("small.pdf", garbage=True, deflate=True, object_streams=True)

# Encrypted save (AES-256; owner_pw alone = open freely, restricted permissions)
merged.save("locked.pdf", user_pw="secret", permissions=pylopdf.Permissions.PRINT)

# Fast metadata probe without parsing the whole file
info = pylopdf.peek_metadata("input.pdf")
print(info["title"], info["page_count"], info["encrypted"], info["repaired"])

# Context manager
with pylopdf.open("input.pdf") as doc:
    print(doc.metadata)

# Encrypted PDFs (RC4-40/128, AES-128, AES-256; empty user passwords open transparently)
doc = pylopdf.open("locked.pdf", password="secret")
doc = pylopdf.open("locked.pdf")
if doc.needs_pass:
    doc.authenticate("secret")  # 0=failed, 2=user, 4=owner, 6=both

# A bounded repair of an incorrect final classic startxref is always visible.
if doc.is_repaired:
    doc.save("normalized.pdf")

# CJK fallback font for PDFs without embedded fonts
# (automatic with pylopdf[cjk]; or bring your own font)
doc.set_fallback_font("NotoSansJP-Regular.otf")
doc.set_fallback_font(font_bytes, kind="serif")
```

Embedded-font `insert_text` shapes each line with HarfRust and asks krilla to
subset and embed the resulting glyphs. With `pylopdf[cjk]`, Japanese and Han
text automatically selects its JP-subset sans font; a Times `fontname` selects
serif. This is one whole-run font selection, not per-glyph fallback. Pass
`fontfile=` / `fontbuffer=` for Hangul, locale-specific Chinese glyph forms,
other scripts, or another typeface. RTL glyph shaping works, but extraction
currently follows visual rather than logical order. Use typst below when full
typesetting is required.

## Ecosystem recipes (typesetting, PDF/A, signatures)

pylopdf stays a lightweight core for editing, extraction, rendering, and bounded
text/form generation; adjacent concerns are solved by pairing it with
established libraries. The recipes below are covered by integration tests
(tests/test_interop.py).

**Typesetting / creating new documents = [typst](https://typst.app/)**
(via [typst-py](https://pypi.org/project/typst/)). Typeset reports with typst and
feed the bytes straight into pylopdf:

```python
import typst
import pylopdf

pdf_bytes = typst.compile("report.typ")   # typesetting: typst
doc = pylopdf.open(stream=pdf_bytes)      # editing / extraction / merging: pylopdf
```

**PDF/A for new documents** is also typst's job (validated export via krilla;
PDF/A-1b through 4 and PDF/UA-1):

```python
pdf_a: bytes = typst.compile("report.typ", pdf_standards="a-2b")
```

**Richly typeset CJK watermarks / headers / footers** can combine typst with
pylopdf. For simple text, use `insert_text(fontfile=...)` directly; for a
full-page composition, typeset one stamp page with typst (fonts get
subset-embedded), then burn it onto every page as vectors with `show_pdf_page`:

```python
from pylopdf_fonts_cjk import sans_path  # pip install pylopdf[cjk] (reuses the Noto fonts)

stamp_typ = """
#set page(width: 595pt, height: 842pt, fill: none)
#set text(font: "Noto Sans JP", size: 48pt, fill: rgb(255, 0, 0, 40%))
#align(center + horizon)[社外秘]
"""
stamp = pylopdf.open(stream=typst.compile(stamp_typ.encode(), font_paths=[str(sans_path().parent)]))
for page in doc:
    page.show_pdf_page((0, 0, page.rect.width, page.rect.height), stamp)
```

Converting or validating *existing* PDFs against PDF/A is a different problem;
[veraPDF](https://verapdf.org/) (Java) is the de-facto validator.

**Digital signatures (PAdES) = [pyHanko](https://pypi.org/project/pyHanko/)** (MIT).
pyHanko signs with an incremental update, so the bytes produced by pylopdf remain
untouched as a prefix of the signed file:

```python
import io
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.sign import signers

signer = signers.SimpleSigner.load("key.pem", "cert.pem")
out = signers.sign_pdf(
    IncrementalPdfFileWriter(io.BytesIO(doc.tobytes())),
    signers.PdfSignatureMetadata(field_name="Signature1"),
    signer=signer,
)
signed_pdf: bytes = out.getvalue()
```

## API

`pylopdf.Document` (`pylopdf.open()` is an alias constructor):

| Method / property | Description |
|---|---|
| `Document(filename=None, stream=None, password=None, max_decompressed_size=None, *, limits=None)` | Open from a path or bytes; empty document if both are None. Use `limits=DocumentLimits.web()` for a complete untrusted-upload policy; `max_decompressed_size` remains the compatible per-stream shorthand |
| `doc[i]` / `load_page(pno)` / `for page in doc` | Get a Page view (negative indices count from the end; re-fetch after structural changes) |
| `needs_pass` / `is_encrypted` | Encryption status (pymupdf-compatible semantics) |
| `is_repaired` | Whether opening repaired an incorrect final classic `startxref`; a `PylopdfWarning` is also emitted and saving normalizes the xref data |
| `authenticate(password)` | Decrypt with a password (returns 0/1/2/4/6, pymupdf-compatible) |
| `page_count` / `len(doc)` | Number of pages |
| `limits` / `complexity` | Immutable open-time resource policy / cheap page, object, stream, encoded-byte, and direct-depth facts without decoding |
| `metadata` | Bounded standard metadata dict (title, author, subject, keywords, creator, producer, creationDate, modDate, format); 1 MiB aggregate Info text |
| `set_metadata(dict)` | Atomically set standard metadata under the 1 MiB input/encoded boundary (empty string deletes the entry) |
| `get_page_text(pno, option="text")` | Extract text (or positioned layout: `"words"` / `"blocks"` / `"dict"`) |
| `render_page(pno, scale=1.0, dpi=None, background=None, max_size=64 MiB)` | Render bounded PNG bytes; `dpi` replaces `scale`, `background` is an RGB(A) fill (max 65,535 px per side / 64 MP total); `None` opts out |
| `render_pages(pages=None, scale=1.0, workers=None, max_size=512 MiB, ...)` | Render up to 4,096 ordered PNGs from one immutable snapshot; up to 4 workers by default, ~512 MB estimated live-work concurrency, and a cumulative encoded-output cap (`None` opts out) |
| `render_page_svg(pno, max_size=64 MiB)` | Render bounded UTF-8 SVG; over-limit output is rejected before Python string conversion, `None` opts out |
| `compress_images(dpi=150, quality=75)` | Lossily downsample and JPEG-recompress safe unmasked DeviceGray/DeviceRGB DCT or Flate XObjects; preserves the largest reuse, skips non-smaller output, and returns typed byte/count statistics |
| `set_fallback_font(font, kind="sans", index=0, max_font_size=64 MiB)` | Set a bounded fallback font (path/bytes) for non-embedded CJK fonts; `font=None` disables auto-detection and `max_font_size=None` opts trusted font input out |
| `select(page_numbers)` | Keep up to 4,096 page entries in the given order (repeats duplicate the page) |
| `delete_page(pno)` / `delete_pages(iterable)` | Delete up to 4,096 page entries per call; an empty iterable is a true no-op |
| `insert_pdf(other, from_page=0, to_page=-1, start_at=-1)` | Merge up to 4,096 pages per call (negative / reversed ranges; `start_at` sets the insertion position) |
| `new_page(pno=-1, width=595, height=842)` / `copy_page(pno, to=-1)` | Insert a blank page / duplicate a page |
| `get_toc()` / `set_toc(toc)` | Read/write cycle-aware bounded outlines as `[[level, title, page], ...]` (page numbers are 1-based here; caps: 4,096 entries/nodes, 8,192 edges, 64 levels, 1 MiB text) |
| `to_markdown(pages=None, table_strategy="lines", max_size=64 MiB)` | Page-at-a-time two-pass Markdown conversion with a bounded linear entry builder, capped at 4,096 pages and cumulative UTF-8 output (`None` opts out); headings, emphasis, CJK joining, lists, columns, vertical order, and bordered/opt-in borderless tables |
| `get_form_fields()` / `set_form_field(name, value, fontfile=, fontbuffer=, fontindex=, max_font_size=64 MiB)` | List and fill AcroForm fields with native text/choice/button appearances; bounded field-tree/name/value/button-state/font interpretation; checkboxes take bool |
| `get_pdfa_claim(max_size=1 MiB)` | Bounded-decode the XMP PDF/A declaration `(part, conformance)` (a self-claim read, not validation); `max_size=None` explicitly opts out |
| `embfile_add(name, data, filename=, desc=)` / `embfile_names()` / `embfile_get(name, max_size=64 MiB)` / `embfile_del(name)` | Add / list / bounded-decode / delete attachments; `max_size=None` explicitly opts out, name trees are capped at 4,096 entries/nodes, and add metadata plus inline FileSpec clone shapes are bounded |
| `get_page_labels()` / `set_page_labels(labels)` | Read/write page label ranges (`{"startpage", "style", "prefix", "firstpagenum"}`); fixed caps: 4,096 entries/nodes, 32 levels, 1 MiB label text |
| `save(filename, garbage=, deflate=, object_streams=, user_pw=, owner_pw=, permissions=)` / `tobytes(same, max_size=512 MiB)` | Atomically replace a file after a complete same-directory streamed write, or return bounded PDF bytes; prune / compress / object streams, or AES-256 encryption via `user_pw` / `owner_pw`; `max_size=None` opts out of the byte-return limit |
| `close()` | Close (supports `with`) |

`pylopdf.Page` (obtained via `doc[i]`):

| Method / property | Description |
|---|---|
| `number` / `parent` | 0-based page number and owning Document |
| `get_label()` | Display label of the page ("iv", "A-2", …; empty string if undefined) |
| `get_text(option="text")` | Text extraction; `"words"` / `"blocks"` / `"dict"` return positioned layout |
| `get_text_ocr(dpi=300, engine=None, tile_size=1408, overlap=192, min_confidence=0.5, rotation=0, clip=None)` | Recognize positioned words locally through `pylopdf[ocr]` without modifying the page; `rotation` corrects rendered input clockwise and `clip` uses display coordinates |
| `apply_ocr(..., rotation=0, clip=None, skip_existing=True)` | Recognize and insert an orientation-aware invisible searchable layer; existing searchable text in the selected region is skipped by default |
| `to_markdown(table_strategy="lines", max_size=64 MiB)` | Single-page Markdown with the same table and UTF-8 output controls as the document method |
| `search_for(needle)` | Case-insensitive text search returning `list[Rect]` |
| `find_tables(strategy="lines", clip=None)` | Detect complete or conservatively refined bordered grids and rectangular merged cells; use `strategy="text"` for opt-in borderless detection; `clip` filters in display coordinates and results expose confidence diagnostics |
| `get_images()` | Extract page images (original JPEG bytes passed through; others as PNG); rejects partial output above 4,096 placements, 64,000,000 cumulative pixels, or 64 MiB of payloads per page |
| `get_drawings()` | Extract interpreted vector fill/stroke paths as typed pymupdf-style dictionaries with display-space line/cubic geometry, RGB/opacity, fill rule, width, cap, join, and dashes |
| `get_pixmap(scale, dpi=, background=, clip=None)` | Render to an immutable `Pixmap`; `clip` is a display-coordinate rectangle (straight RGBA8: `samples` / `width` / `height` / `stride` / `tobytes(max_size=64 MiB)` / streaming, failure-atomic PNG-only `save(path)`; cp314t also supports read-only zero-copy `memoryview()`) |
| `insert_image(rect, filename=/stream=/pixmap=, rotate=0, keep_proportion=True, overlay=True, max_size=64 MiB, max_pixels=64,000,000)` | Draw JPEG without recompression, bounded PNG with alpha, or a rendered RGBA `Pixmap` without a PNG round trip; `None` opts trusted encoded input or PNG pixels out of its boundary; optional clockwise right-angle rotation and rect use display coordinates |
| `show_pdf_page(rect, src, pno=0, keep_proportion=True, overlay=True)` | Overlay a page as vectors from another or the same document; same-document placement uses a stable pre-edit snapshot |
| `insert_text(point, text, fontsize=11, fontname="helv", fontfile=, fontbuffer=, fontindex=, color=, overlay=True, max_font_size=64 MiB)` | Print multiline text with a standard-14 or bounded shaped subset font; `pylopdf[cjk]` auto-selects its JP font for Japanese/Han; `None` opts trusted font input out; upright on rotated pages |
| `insert_textbox(rect, text, fontsize=11, fontname="helv", fontfile=, fontbuffer=, fontindex=, color=, align=0, lineheight=None, expandtabs=8, overlay=True, max_font_size=64 MiB)` | Wrap with UAX #14 and Core 14, bounded explicit OpenType, or auto-selected JP font metrics; returns spare height and draws nothing on overflow |
| `insert_ocr_text_layer(words, rotation=0)` | Write up to 4,096 words / 1 MiB UTF-8 text as an orientation-aware invisible OCR layer (searchable PDFs; no font embedding) |
| `annots()` / `get_links()` | Bounded annotation/link reads, including one cycle-aware named-destination index per call (4,096 annotations and 1 MiB aggregate metadata text; display coordinates) |
| `add_highlight_annot(rects, color=(1,1,0), opacity=0.4, content=None)` | Highlight annotation; feed up to 4,096 `search_for` results directly; appearance stream included; 1 MiB subtype/content budget |
| `add_link_annot(rect, uri)` | URI link annotation (no border; 1 MiB subtype/URI budget) |
| `replace_text(search, replacement, default_char=None, max_size=64 MiB)` | Atomic copy-on-write text replacement (simple-encoded fonts only; bounded input/output; returns the count; no CJK) |
| `render(scale, dpi=, background=)` / `render_svg(max_size=64 MiB)` | PNG / bounded UTF-8 SVG rendering |
| `rotation` / `set_rotation(deg)` | Display rotation (multiples of 90, inheritance-resolved) |
| `mediabox` / `cropbox` / `rect` | Page boxes (`Rect`); `rect` is the rotation-aware visible rectangle |
| `set_mediabox(rect)` / `set_cropbox(rect)` | Set page boxes |

Drawing insertions preflight page `/Contents` before decoding inputs or creating
dependent objects. The resulting array is capped at 4,096 stream references;
the one-time `q`/`Q` isolation pair is included in that total, and failures do
not mutate the document.

Module level:

| Name | Description |
|---|---|
| `peek_metadata(filename/stream, password=None)` | Fast metadata / page-count / encryption probe; `repaired` reports bounded classic-`startxref` recovery |
| `Permissions` | Encryption permission flags (IntFlag) |
| `Rect` | Rectangle NamedTuple with `width` / `height` |
| `ImageCompressionResult` | Typed counts and rewritten source/result byte totals from `compress_images()` |
| `DrawingInfo` / `DrawingItem` | Typed vector-path dictionary and its line/cubic command union |
| `TEXT_ALIGN_LEFT` / `CENTER` / `RIGHT` / `JUSTIFY` | `insert_textbox` alignment constants (0–3, pymupdf-compatible) |
| `OcrEngine` / `OcrWord` / `OcrRotation` | Reusable pure-Rust PP-OCR engine, its typed positioned-word result, and the `0 / 90 / 180 / 270` clockwise-correction contract |
| `TableFinder` / `Table` / `TableDiagnostics` | Owned table geometry, cell text, strategy, and confidence evidence; `Table.to_markdown(max_size=64 MiB)` preflights escaped UTF-8 output |
| `PylopdfWarning` | Recoverable interpretation warning, including bounded xref repair, font resolution, and image decoding |
| Exceptions | `PdfError` (ValueError-compatible base), `PasswordError`, `OcrError`, `DocumentClosedError`, `EncryptedDocumentError`, `StalePageError` |

For low-level access, use `pylopdf.pylopdf_core._Document` (a thin lopdf wrapper) directly.

## Architecture

Follows the division of labor in the 2026 Rust PDF ecosystem:

```
pylopdf.Document (Python, pymupdf-style API)
   └─ _Document (PyO3)
        ├─ lopdf 0.44   … editing: open → modify → save
        ├─ hayro 0.7    … rendering and positioned extraction
        └─ krilla 0.8 + HarfRust 0.12
                         … shaped, subset-embedded text and form appearances
```

```
rust/          # PyO3 bindings
src/pylopdf/   # Python high-level API
tests/         # pytest (Rust behavior is verified through Python tests)
```

```bash
uv sync                    # build + install dependencies
uv run pytest              # tests
uv run ruff check .        # lint
uv run mypy src tests      # type check
uv build --wheel           # build a wheel
```

`uv sync` detects Rust source changes and rebuilds automatically (via `tool.uv.cache-keys`).

## Contributing

Bug reports and focused contributions are welcome. See
[CONTRIBUTING.md](CONTRIBUTING.md) for development commands, test expectations,
and the rules for sharing PDF regression files. Report security vulnerabilities
privately through [GitHub Security
Advisories](https://github.com/yhay81/pylopdf/security/advisories/new).

## Benchmarks

A reproducible benchmark ships with the repo (same corpus, same tasks, medians —
wins and losses are published as-is). See
[bench/results/latest.md](bench/results/latest.md) for the latest numbers with
environment details:

```bash
uv sync --all-extras --group bench && uv run python bench/run.py
```

The separate [native OCR report](bench/results/ocr-latest.md) publishes strict
and NFKC-normalized CER plus elapsed time on two licensed Japanese fixtures,
including an image-only archival scan. It also records a bounded shared-engine
concurrency check:

```bash
uv sync --all-extras && uv run python bench/ocr.py
```

## License

MIT (lopdf and HarfRust are MIT; hayro and krilla are MIT/Apache-2.0)

