Python

Last updated: 2026-06-29

The rustpdf package wraps the rust-pdf C core with idiomatic, context-managed classes. It covers the whole product surface: vector graphics, embedded/subset fonts & Unicode text, paragraphs, images, PDF/A (1b–3a + A-4/4e/4f), tagged/accessible output, attachments, AcroForm fields, manipulation, text extraction, encryption and digital signatures.

Two classes do almost everything. Document authors a new PDF; EditableDoc loads and manipulates an existing one. Both are context managers: use them with with so native handles are always freed.

Installation

Install the package from PyPI. The wheel bundles the native shared library (libpdf_ffi) built from the Rust core for your platform — nothing else to build or configure.

shell
pip install rustpdf

Verify it loaded:

python
import rustpdf
print(rustpdf.version())          # native library version
print(rustpdf.library_path())     # which .dylib/.so/.dll was loaded

Quick start

A one-page document with a filled rectangle, saved to disk:

python
import rustpdf

with rustpdf.Document() as doc:          # A4 by default
    doc.add_page()
    doc.set_fill_rgb(0.86, 0.20, 0.18)
    doc.rect(72, 640, 200, 120)          # x, y, width, height (points)
    doc.fill()
    doc.save("out.pdf")

Most methods return the document, so calls chain:

python
with rustpdf.Document() as doc:
    font = doc.add_font_file("Roboto-Regular.ttf")
    (doc.add_page()
        .set_fill_rgb(0.1, 0.1, 0.12)
        .rect(0, 800, 595, 42).fill()
        .show_text(font, 24, 72, 740, "Olá, açúcar — café"))
    data = doc.to_bytes()                # in-memory bytes instead of a file

Licensing & activation

Basic generation (everything above) is always free. The corporate features (PDF/A, digital signatures/PAdES, encryption, accessibility) require an active license token. Without one, those calls raise PdfError and produce no output.

Activation needs no rebuild. Easiest is an environment variable, auto-activated the first time a corporate feature is used:

shell
export RUSTPDF_LICENSE="010f0000…"           # the token we email you
# or point at a file:
export RUSTPDF_LICENSE_FILE=/etc/rustpdf/license.txt

Or activate explicitly in code:

python
rustpdf.activate_license(token)   # raises PdfError if forged / expired / malformed
Verification is fully offline: signature + expiry checked against a public key embedded in the library. No network callback, no telemetry.

Coordinate system

Threading & concurrency

The core is Send but not Sync: you can build many documents in parallel, but a single handle must never be touched by two threads at once.

python
from concurrent.futures import ThreadPoolExecutor
import rustpdf

def render(i: int) -> bytes:
    with rustpdf.Document() as doc:          # one document per task
        doc.add_page()
        doc.set_fill_rgb(0.1, 0.1, 0.12)
        doc.rect(72, 700, 200, 80).fill()
        return doc.to_bytes()

with ThreadPoolExecutor(max_workers=8) as pool:
    pdfs = list(pool.map(render, range(100)))   # 100 PDFs built concurrently

Authoring: create & save

rustpdf.Document() free

Creates an empty document (A4 default page size). Use as a context manager; call close() manually only if you can't.

MethodDescription
add_page(size=None)Append a page. size is an optional (width, height) tuple in points.
set_default_size(w, h)Default size for subsequently added pages.
set_version(v)Set the PDF header version (0 → 1.4, 1 → 1.5, 2 → 1.7, 3 → 2.0).
page_countProperty: number of pages so far.
to_bytes()Render the document to bytes.
save(path)Render and write to a file.
Validity. A document must have at least one page — serializing an empty document raises an error. Color components (RGB/Gray/CMYK) are clamped to the valid 0–1 range.

Pages & vector graphics

Graphics state and path operators mirror PDF's content-stream model. Colors are RGB in 0.0–1.0.

MethodDescription
set_fill_rgb(r, g, b)Fill color.
set_stroke_rgb(r, g, b)Stroke color.
set_line_width(w)Stroke width in points.
rect(x, y, w, h)Add a rectangle subpath.
fill()Fill the current path with the fill color.
stroke()Stroke the current path with the stroke color.
python
with rustpdf.Document() as doc:
    doc.add_page()
    doc.set_stroke_rgb(0.10, 0.45, 0.90).set_line_width(3)
    doc.rect(72, 600, 300, 160).stroke()
    doc.set_fill_rgb(0.95, 0.77, 0.06)
    doc.rect(120, 640, 120, 80).fill()
    doc.save("shapes.pdf")

Fonts & text

Fonts are embedded and subsetted, with HarfBuzz-quality shaping, kerning and full Unicode (Type0/CIDFontType2 with ToUnicode, so text extracts and copies correctly, provided the embedded font covers those characters). Register a font once, then reference it by its integer id.

add_font_file(path) → int   add_font(data: bytes) → int
show_text(font, size, x, y, text, heading_level=0)

heading_level (1–6) tags the run as H1H6 in an accessible document (see Accessibility); leave 0 for ordinary text.

python
with rustpdf.Document() as doc:
    regular = doc.add_font_file("Roboto-Regular.ttf")
    # …or from bytes you already have in memory:
    # regular = doc.add_font(open("Roboto-Regular.ttf", "rb").read())

    doc.add_page()
    doc.show_text(regular, 28, 72, 760, "Invoice #1024")
    doc.show_text(regular, 12, 72, 720, "日本語 · Ελληνικά · العربية")
    doc.save("text.pdf")
Font coverage. Characters outside the embedded font's coverage are silently dropped — they render as the missing-glyph box and won't extract or copy. The bundled Roboto fallback covers Latin, Greek and Cyrillic but not CJK, Arabic, Hebrew or emoji. Embed a font that covers every script you write.
No NUL bytes in strings. Text passed across the API must not contain a NUL (\x00) character — it truncates the string at the FFI boundary, silently dropping everything after the NUL. This applies to shown text, metadata and field names.

Paragraphs

The paragraph layer wraps, aligns and justifies text inside a fixed width (greedy line breaking using shaped glyph widths).

paragraph(font, size, x, y, width, text, align=Align.LEFT)
python
from rustpdf import Document, Align

intro = ("A long paragraph that wraps to the given width and is justified "
         "automatically; extra space is distributed between words.")

with Document() as doc:
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.paragraph(f, 12, 72, 700, 451, intro, align=Align.JUSTIFY)
    doc.save("paragraph.pdf")

See the Align enum for the alignment options.

Images

JPEGs are embedded verbatim (DCTDecode, no re-encode). PNGs are decoded and re-encoded (FlateDecode); alpha becomes an /SMask, palette becomes an Indexed color space. Register an image once, draw it many times.

MethodDescription
add_image_file(path) → intLoad JPEG/PNG from a file; returns the image id.
add_image_png(data: bytes) → intRegister a PNG from memory.
add_image_jpeg(data: bytes) → intRegister a JPEG from memory.
draw_image(image, x, y, w, h)Draw at (x, y) scaled to w × h points.
figure(image, x, y, w, h, alt)Draw as a tagged /Figure with alt text (accessibility).
python
with rustpdf.Document() as doc:
    logo = doc.add_image_file("logo.png")
    doc.add_page()
    doc.draw_image(logo, 72, 680, 160, 90)
    doc.save("with_image.pdf")

PDF/A licensed

Produce archival-grade output. pdfa() defaults to A-2b; pass a PdfaLevel for a specific level. An embedded sRGB ICC profile, output intent, XMP metadata and document /ID are added automatically; A-1b also forces PDF 1.4 and emits a /CIDSet, and A-4 (ISO 19005-4) is based on PDF 2.0.

pdfa(level=None)
python
from rustpdf import Document, PdfaLevel

with Document() as doc:
    doc.pdfa(PdfaLevel.A2B).set_info(title="Q3 Report", author="Acme Inc.")
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.show_text(f, 20, 72, 760, "Archival report")
    doc.save("report_pdfa.pdf")     # raises PdfError without a license granting PDF/A
A document title is recommended for valid PDF/A metadata, and required for the accessible “a” levels: call set_info(title=…).

Accessibility (Tagged PDF / PDF/UA) licensed

tagged() builds a logical structure tree (PDF/UA-1). Combine with pdfa(PdfaLevel.A2A) for archival and accessible output. Use heading_level on show_text for H1H6, and figure(..., alt=…) for described images.

tagged()
python
from rustpdf import Document, PdfaLevel

with Document() as doc:
    doc.pdfa(PdfaLevel.A2A).tagged().set_info(title="Accessible report")
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.show_text(f, 26, 72, 760, "Annual report", heading_level=1)
    doc.show_text(f, 14, 72, 720, "Overview", heading_level=2)
    doc.show_text(f, 11, 72, 690, "Body paragraph of the section…")
    chart = doc.add_image_file("chart.png")
    doc.figure(chart, 72, 520, 300, 150, alt="Revenue grew 18% year over year")
    doc.save("accessible.pdf")
Figures need a tagged document. figure() only produces an accessible, alt-texted figure inside a tagged/accessible document; on a plain document the alt text has no effect.

Attachments (PDF/A-3) licensed

PDF/A-3 allows embedding arbitrary source files (e.g. the XML behind an e-invoice). Each attachment carries a MIME type and an AFRelationship.

attach_file(name, mime, data, relationship=AFRelationship.SOURCE, description="")
python
from rustpdf import Document, PdfaLevel, AFRelationship

xml = open("invoice.xml", "rb").read()
with Document() as doc:
    doc.pdfa(PdfaLevel.A3B).set_info(title="E-invoice 1024")
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.show_text(f, 18, 72, 760, "Invoice 1024")
    doc.attach_file("invoice.xml", "text/xml", xml,
                    AFRelationship.SOURCE, "Structured invoice data")
    doc.save("einvoice.pdf")
PDF/A-4f needs an attachment. The A4f profile requires at least one embedded file (ISO 19005-4); the library now rejects A4f output that has no attachment, so call attach_file before serializing.
The licence gate is on PDF/A, not on attach_file itself. The licensed badge above reflects the PDF/A-3 workflow shown here. Calling attach_file on a plain (non-PDF/A) document does not require a licence: it succeeds and produces a valid PDF with an /EmbeddedFile. The licence is enforced only when you also request a PDF/A level, which is what makes the attachment archival.

ZUGFeRD / Factur-X e-invoices licensed

Turn the document into a ZUGFeRD / Factur-X electronic invoice: the embedded XML (the Cross-Industry Invoice) is attached as factur-x.xml, the file is marked PDF/A-3, and the Factur-X identification is written into the XMP metadata. The visible PDF is the human-readable invoice; the embedded XML is its machine-readable twin. Validates as PDF/A-3 + Factur-X under veraPDF.

facturx(xml: bytes, profile=FacturxProfile.EN16931)
python
from rustpdf import Document, FacturxProfile

xml = open("factur-x.xml", "rb").read()         # your Cross-Industry Invoice XML
with Document() as doc:
    doc.set_info(title="Invoice INV-2026-001")
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.show_text(f, 18, 72, 760, "Invoice INV-2026-001")
    doc.facturx(xml, FacturxProfile.EN16931)
    doc.save("einvoice.pdf")        # PDF/A-3 + Factur-X; needs a PDF/A license

See the FacturxProfile enum for the conformance levels (MINIMUM … EXTENDED).

AcroForm fields

Build interactive forms with generated appearance streams (no NeedAppearances). Rectangles are (x0, y0, x1, y1); page is a 0-based page index. Dotted names ("a.b.c") create hierarchical fields.

MethodDescription
text_field(name, page, rect, value="", size=0.0)Text input (size=0 → auto font size).
checkbox(name, page, rect, checked=False)Checkbox.
dropdown(name, page, rect, options, selected=None, size=0.0)Combo box from a list of strings.
radio_group(name, page, buttons, selected=None)buttons = list of (rect, export_value) tuples.
python
with rustpdf.Document() as doc:
    doc.add_page()
    doc.text_field("applicant.name", 0, (72, 700, 320, 720), value="")
    doc.checkbox("agree", 0, (72, 660, 88, 676), checked=False)
    doc.dropdown("plan", 0, (72, 620, 240, 640),
                 ["Starter", "Pro", "Enterprise"], selected=1)
    doc.radio_group("billing", 0, [
        ((72, 580, 88, 596), "monthly"),
        ((140, 580, 156, 596), "annual"),
    ], selected=1)
    doc.save("form.pdf")

Fill and flatten fields later with EditableDoc.

Page index and rectangle are validated. A field (or internal link) whose page does not exist, or whose rectangle is degenerate (x1 < x0 or zero area), is rejected when the document is serialized: to_bytes()/save() raise PdfError ("targets page index N…" / "degenerate rectangle…") instead of producing an invisible, never-appearing widget. Pass a 0-based page index that exists and a rectangle with x1 > x0 and y1 > y0.

Add clickable link rectangles to the current page: a web link opens a URL; an internal link jumps to another page (optionally scrolling so a given top y-coordinate sits at the top of the view).

link_uri(rect, uri)   link_to_page(rect, page_index, top=None)
python
with rustpdf.Document() as doc:
    f = doc.add_font_file("Roboto-Regular.ttf")
    doc.add_page()
    doc.show_text(f, 14, 72, 760, "Visit rustpdf.dev (see page 2)")
    doc.link_uri((72, 756, 320, 776), "https://rustpdf.dev/")   # web link
    doc.link_to_page((330, 756, 430, 776), 1, top=800)          # jump to page 2
    doc.add_page()
    doc.save("links.pdf")

Rectangles are (x0, y0, x1, y1) in points; page_index is 0-based.

Bookmarks / outline free

Build a navigable document outline. A Bookmark has a title, a target page and an optional top; nest children with .child(...). A document with bookmarks opens with the outline pane shown.

Bookmark(title, page, top=None)   .child(bookmark)   add_bookmark(bookmark)
python
from rustpdf import Document, Bookmark

with Document() as doc:
    f = doc.add_font_file("Roboto-Regular.ttf")
    for _ in range(3):
        doc.add_page()
    doc.add_bookmark(
        Bookmark("Chapter 1", 0, top=820)
            .child(Bookmark("Section 1.1", 1))
            .child(Bookmark("Section 1.2", 2)))
    doc.add_bookmark(Bookmark("Chapter 2", 2))
    doc.save("outline.pdf")

Metadata

set_info(title=None, author=None, subject=None, keywords=None, creator=None)

Sets the document information dictionary (and, for PDF/A, the matching XMP). Pass only the fields you need.

python
doc.set_info(title="Q3 Report", author="Acme Inc.",
             subject="Quarterly results", keywords="finance, q3")

Manipulation: load an existing PDF

EditableDoc parses an existing document (classic & xref streams, object streams, all standard filters, RC4/AES decryption) into an editable model. Pages are a flat list; the page tree is rebuilt on output.

EditableDoc.load(data: bytes, password=None)
EditableDoc.load_file(path, password=None)
python
from rustpdf import EditableDoc

with EditableDoc.load_file("in.pdf") as ed:
    print(ed.page_count)

# encrypted input:
with EditableDoc.load_file("secured.pdf", password="user-or-owner-pw") as ed:
    ed.save("plain.pdf")
Corrupt or truncated input. A badly damaged file (truncated mid-stream, for example) may still load via the recovery scan but recover zero pages, and load does not raise in that case. Serializing a zero-page document raises PdfError ("document has no pages") instead of writing an invalid file, but check page_count > 0 after loading untrusted input before relying on it.

Pages: merge, split, reorder, rotate

MethodDescription
merge(other)Append all pages of another EditableDoc (objects renumbered & remapped).
rotate_page(index, degrees)Rotate one page (90 / 180 / 270).
delete_page(index)Remove a page.
reorder_pages(order)Reorder with a full permutation list of indices.
extract_pages(indices) → EditableDocNew document containing just those pages.
page_countProperty: current page count.
python
with EditableDoc.load_file("a.pdf") as a, EditableDoc.load_file("b.pdf") as b:
    a.merge(b)                       # a now has a's pages followed by b's
    a.rotate_page(0, 90)
    a.reorder_pages(list(reversed(range(a.page_count))))
    a.save("merged.pdf")

with EditableDoc.load_file("merged.pdf") as doc:
    with doc.extract_pages([0, 2]) as subset:   # pages 1 and 3
        subset.save("subset.pdf")
Page indices are 0-based. An out-of-range index to rotate/delete is silently ignored, and extract skips out-of-range indices. reorder_pages requires a true permutation of every page index (each used exactly once); an invalid argument — wrong length, a repeated index, or out-of-range — is rejected and leaves the page order unchanged — the call is a silent no-op that raises no error, so an invalid reorder cannot be detected from a return value.

Metadata, overlay & form fill

MethodDescription
set_info(key, value)Set one info entry (e.g. "Title").
get_info(key) → strRead an info entry.
set_xmp(xml: bytes)Replace the XMP metadata stream.
overlay_page(index, content: bytes)Overlay a content-stream fragment onto a page (stamps/watermarks).
fill_text_field(name, value) → boolFill an AcroForm text field; returns whether it was found.
python
with EditableDoc.load_file("form.pdf") as ed:
    ed.set_info("Title", "Filled form")
    found = ed.fill_text_field("applicant.name", "Jane Doe")
    print("filled:", found, "| title:", ed.get_info("Title"))
    ed.save("filled.pdf")

Form fill & flatten free

Fill the fields of an existing AcroForm and (optionally) flatten them: filling generates a fresh appearance stream (no NeedAppearances), and flattening bakes every widget's appearance into the page content and removes the interactive form entirely.

MethodDescription
field_names() → list[str]Fully-qualified names of every terminal field.
fill_text_field(name, value) → boolSet a text (or text-style choice) field; returns whether it matched.
set_checkbox(name, checked=True) → boolCheck/uncheck a checkbox.
set_radio(name, export_value) → boolSelect a radio button by its export value.
set_choice(name, value) → boolSet a dropdown / list-box value.
flatten_forms()Bake all fields into static content and drop the /AcroForm.
python
from rustpdf import EditableDoc

with EditableDoc.load_file("form.pdf") as ed:
    print(ed.field_names())              # ['applicant.name', 'agree', 'plan', ...]
    ed.fill_text_field("applicant.name", "Jane Doe")
    ed.set_checkbox("agree", True)
    ed.set_radio("billing", "annual")
    ed.set_choice("plan", "Pro")
    ed.flatten_forms()                  # optional: make it non-editable
    ed.save("filled.pdf")

Watermarks free

Stamp a diagonal text watermark or a centered image watermark across every page, drawn semi-transparently over the existing content. Text uses the standard Helvetica font, so keep it to WinAnsi (Latin-1) for stamps like "CONFIDENTIAL".

watermark_text(text, *, size=64.0, color=(0.5,0.5,0.5), opacity=0.30, rotation_deg=45.0, opaque_background=False)
watermark_image_file(path, width, height, opacity=0.30, rotation_deg=0.0)

Set opaque_background=True to white out the stamped region first (an opaque background behind the mark) instead of drawing over the existing content. watermark_image_file takes a rotation_deg to rotate the image stamp.

python
with EditableDoc.load_file("report.pdf") as ed:
    ed.watermark_text("CONFIDENTIAL", opacity=0.25, rotation_deg=45)
    ed.watermark_image_file("stamp.png", 200, 80, opacity=0.4, rotation_deg=30)
    ed.save("stamped.pdf")

Positioned drawing free

Paint a filled rectangle or a line of text at exact coordinates on an existing page. Coordinates are in the page's visible space (origin lower-left, y up), so content lands where a viewer sees it regardless of the page's /Rotate. Text uses the standard Helvetica font, so keep it to WinAnsi (Latin-1). The classic use is masking a placeholder with an opaque white box, then writing the real value over it.

fill_rect(page_index, x, y, width, height, color=(1,1,1), opacity=1.0) → bool
place_text(page_index, x, y, text, size=12.0, color=(0,0,0), rotation_deg=0.0, align=Align.LEFT, font_id=-1, anchor=VerticalAnchor.BASELINE) → bool
masked_text(page_index, x, y, width, height, text, size=12.0, text_color=(0,0,0), bg_color=(1,1,1), align=Align.LEFT, font_id=-1, valign=VerticalAlign.MIDDLE, padding=None) → bool
draw_image(page_index, image, x, y, width, height, rotation_deg=0.0, anchor=ImageAnchor.CORNER) → bool

color is an RGB tuple (default opaque white for fill_rect, black for place_text); rotation_deg rotates the text/image counter-clockwise about its anchor. draw_image takes raw JPEG/PNG bytes and places the image with its lower-left corner at (x, y) scaled to width × height points. Each returns whether the page existed.

place_text's align shifts the start point along the baseline: Align.LEFT starts at x, Align.RIGHT ends at x, Align.CENTER centers on x. masked_text is the one-call mask-and-stamp convenience: it fills an opaque bg_color box [x, y, x+width, y+height], then writes the text aligned per align and vertically centered in the box — no need to hand-compute the baseline.

python
import rustpdf
from rustpdf import EditableDoc, Align

with EditableDoc.load_file("invoice.pdf") as ed:
    g = rustpdf.measure_page(ed.to_bytes(), 0)   # page geometry, points
    # mask a placeholder with an opaque white box, then write the value over it
    ed.fill_rect(0, 400, g.height - 80, 120, 16, color=(1, 1, 1), opacity=1.0)
    ed.place_text(0, 520, g.height - 76, "R$ 1.234,56", size=12.0, align=Align.RIGHT)
    # or do both in one call: opaque box + centered value
    ed.masked_text(0, 400, g.height - 80, 120, 16, "R$ 1.234,56",
                   size=12.0, align=Align.CENTER)
    # stamp a logo onto the page
    ed.draw_image(0, open("logo.png", "rb").read(), 72, g.height - 90, 120, 48)
    ed.save("invoice-filled.pdf")

Stamping: fonts, anchors & paragraphs free

The positioned-drawing primitives above take optional parameters that unlock embedded fonts, precise vertical anchoring and wrapped paragraphs: the toolkit for filling templates coming from legacy layout engines or legacy PDF libraries coordinates. See the Interactive positioning guide to explore each anchor visually.

Custom fonts

add_font_file(path) → int   add_font(data: bytes) → int

Register a TrueType/OpenType font on the EditableDoc and pass the returned id as font_id to place_text / masked_text / place_paragraph. The font is embedded as a subset, so the stamp renders with the real glyphs and metrics (full Unicode, no WinAnsi limit). font_id=-1 (the default) keeps the built-in Helvetica.

Vertical anchors

place_text's anchor says what y means: VerticalAnchor.BASELINE (the default and historical behavior), TOP (text hangs from y: the baseline lands ascent × size below it, matching legacy fixed-position layout), BOTTOM (the descender line rests on y), and LINE_TOP/LINE_BOTTOM, the top/bottom of the layout line box (font line metrics plus a fixed half-leading), which reproduce legacy layout engines line placement exactly. Ascent/descent come from the selected font's metrics.

masked_text: vertical alignment & padding

valign aligns the line inside the box: VerticalAlign.MIDDLE (the default: cap-height centering), TOP (baseline at y + height − ascent × size, top line-alignment in rectangle-based text APIs) or BOTTOM. padding is the horizontal edge inset (points) for Align.LEFT/Align.RIGHT: None keeps the historical min(0.15 × size, width / 4); pass 0 to start flush with the box edge like rectangle-based DrawString APIs.

Wrapped paragraphs

place_paragraph(page_index, x, y, width, text, size=12.0, color=(0,0,0), align=Align.LEFT, font_id=-1, max_height=None, line_height=1.0, anchor=VerticalAnchor.TOP, rotation_deg=0.0) → bool
place_paragraph_measured(…) → (lines: int, height: float)

Stamps a paragraph with automatic word wrapping: text is broken into lines that fit width points (\n forces a break) and drawn from the anchor (x, y). With the default VerticalAnchor.TOP the first baseline lands ascent × size below y (legacy fixed-position layout) and each further line steps down by size × 1.2 × line_height. The bottom-pinned anchors (BOTTOM/LINE_BOTTOM) rest the block's bottom on y and grow it upward by its real content height. max_height is a ceiling: lines that would overflow it are cut (None = unlimited); Align.JUSTIFY stretches the word gaps of every line but the last of each paragraph. place_paragraph_measured additionally returns how many lines were drawn (detects truncation) and the consumed block height in points, so you can stack blocks without re-measuring.

Coordinate space (Visible vs Media)

stamp_space: StampSpace   set_stamp_space(space)

Sets the coordinate space of all subsequent stamping calls. StampSpace.VISIBLE (default) keeps the historical behavior: coordinates in the page's displayed space, compensating /Rotate so a rotation_deg=0 stamp reads upright on screen. StampSpace.MEDIA interprets coordinates and rotation_deg in the raw PDF user space (the raw-coordinate semantics of legacy layout engines), never composing with the page's /Rotate. Use it to reproduce coordinates computed for legacy PDF libraries on rotated (scanned) pages. Watermarks and redaction are unaffected.

Image anchor

draw_image's anchor controls how a rotated image is anchored at (x, y): ImageAnchor.CORNER (default) rotates the image about its own lower-left corner, sweeping it around the point; ImageAnchor.BOUNDING_BOX lands the rotated image's bounding box with its lower-left at (x, y) (bounding-box layout semantics; e.g. a 90° image occupies [x, x+height] × [y, y+width]).

python
from rustpdf import EditableDoc, Align, VerticalAnchor, VerticalAlign, StampSpace, ImageAnchor

with EditableDoc.load_file("template.pdf") as ed:
    times = ed.add_font_file("times.ttf")            # embedded subset

    # hang the text from y like legacy fixed-position layout
    ed.place_text(0, 72, 700, "Náme with accents ✓", size=12,
                  font_id=times, anchor=VerticalAnchor.TOP)

    # mask a box, text flush with the edge and hung from the top
    ed.masked_text(0, 400, 680, 140, 18, "R$ 1.234,56", size=11,
                   valign=VerticalAlign.TOP, padding=0)

    # wrapped paragraph pinned by its bottom, with a height ceiling
    lines, used = ed.place_paragraph_measured(
        0, 72, 120, 300, "Terms and conditions… " * 12, size=9,
        anchor=VerticalAnchor.BOTTOM, max_height=140, align=Align.JUSTIFY)

    # reproduce legacy raw-space coordinates on a rotated scan
    ed.stamp_space = StampSpace.MEDIA
    ed.place_text(0, 50, 50, "legacy layout engines-space stamp", size=10)
    ed.stamp_space = StampSpace.VISIBLE

    # rotated image anchored by its bounding box (bounding-box layout)
    ed.draw_image(0, open("sig.png", "rb").read(), 420, 100, 120, 40,
                  rotation_deg=90.0, anchor=ImageAnchor.BOUNDING_BOX)
    ed.save("filled.pdf")
Backward compatible. The defaults (font_id=-1, anchor=VerticalAnchor.BASELINE, valign=VerticalAlign.MIDDLE, padding=None, ImageAnchor.CORNER, StampSpace.VISIBLE) reproduce the previous behavior exactly: existing calls keep working unchanged.

Redaction licensed

True redaction: the text and graphics whose origin falls inside a rectangle are removed from the content stream (not just covered), so the data is gone from the file and is no longer extractable. Opaque black boxes are then painted over the regions.

redact(page_index, rects) → bool
python
with EditableDoc.load_file("statement.pdf") as ed:
    # rects = list of (x0, y0, x1, y1) on that page
    ed.redact(0, [(60, 590, 400, 620), (60, 540, 400, 570)])
    ed.save("redacted.pdf")     # raises PdfError without a license granting redaction
Content under a rect is deleted before the file is written, so extract_text on the output no longer returns it.

Convert to PDF/A licensed

Convert an existing PDF to archival PDF/A (a basic profile: A-1b, A-2b or A-3b). An sRGB output intent, PDF/A XMP metadata (synced with /Info) and a document /ID are added. Fails if any font is not embedded (PDF/A requires every font embedded) or a level-A profile is requested.

convert_to_pdfa(level=PdfaLevel.A2B)
python
from rustpdf import EditableDoc, PdfaLevel

with EditableDoc.load_file("in.pdf") as ed:
    ed.convert_to_pdfa(PdfaLevel.A2B)   # raises PdfError if fonts aren't embedded
    ed.save("archival.pdf")             # veraPDF: PDF/A-2b compliant

Optimize & compact

MethodDescription
optimize()Drop unreferenced objects, Flate-compress uncompressed streams, dedupe identical objects.
compact(on=True)Pack objects into object streams + emit a cross-reference stream.
python
with EditableDoc.load_file("big.pdf") as ed:
    ed.optimize().compact(True)
    ed.save("small.pdf")

Encryption licensed

Apply standard-handler encryption at output. AES-256 (V5/R6) uses OS-CSPRNG keys/IVs.

encrypt(user="", owner="", method=Encryption.AES256, read_only=False)
python
from rustpdf import EditableDoc, Encryption

with EditableDoc.load_file("in.pdf") as ed:
    ed.encrypt(user="", owner="owner-secret",
               method=Encryption.AES256, read_only=True)
    ed.save("secured.pdf")          # raises PdfError without an Encryption license

See the Encryption enum for RC4 / AES-128 / AES-256.

Passwords protect, permissions only advise. A non-empty user password is real cryptographic protection: opening with the wrong password is rejected (cross-checked against qpdf for all three ciphers). The read-only permission flags, by contrast, are advisory: they are enforced only by the viewer, and a file with an empty user password opens with no prompt, so any tool can strip the restrictions. Treat read-only as a hint to well-behaved viewers, not as an access control.

Output & incremental update

MethodDescription
to_bytes() → bytesSerialize the manipulated document.
save(path)Serialize to a file.
to_bytes_incremental(original: bytes) → bytesAppend only changes to the original bytes (signature-safe, non-destructive).
python
original = open("in.pdf", "rb").read()
with EditableDoc.load(original) as ed:
    ed.set_info("Subject", "reviewed")
    incremental = ed.to_bytes_incremental(original)   # original bytes preserved verbatim
open("reviewed.pdf", "wb").write(incremental)

Normalize & downgrade free

Remove PDF/A conformance and downgrade the PDF version of an existing document. strip_pdfa drops the archival markers (/OutputIntents, the XMP pdfaid identifier, the catalog /Version); set_version sets the output version; normalize does both in one call, producing a plain PDF at the given version.

MethodDescription
set_version(version)Set the output PDF version (0 → 1.4, 1 → 1.5, 2 → 1.7, 3 → 2.0).
strip_pdfa()Strip PDF/A conformance (output intent, XMP pdfaid, /Version).
normalize(version=2)Strip PDF/A and set the version in one step.
python
with EditableDoc.load_file("archival.pdf") as ed:
    ed.normalize(version=2)          # plain PDF 1.7, PDF/A markers removed
    # or step by step:
    # ed.strip_pdfa().set_version(2)
    ed.save("plain.pdf")

Digital signatures licensed

Sign a PDF with a PKCS#7 detached signature via an incremental update (the original bytes are preserved). Keys and certificates are passed as DER bytes. pades=True switches to PAdES-B-B.

rustpdf.sign(pdf, key_der, cert_der, *, reason=None, location=None, name=None, pades=False) → bytes
python
import rustpdf

pdf_bytes = open("contract.pdf", "rb").read()
key_der   = open("signing-key.pkcs8.der", "rb").read()   # PKCS#8 private key (DER)
cert_der  = open("signing-cert.der", "rb").read()        # X.509 certificate (DER)

signed = rustpdf.sign(pdf_bytes, key_der, cert_der,
                      reason="Approved", location="New York",
                      name="Jane Doe", pades=True)
open("contract.signed.pdf", "wb").write(signed)
# Verify in a shell: pdfsig contract.signed.pdf  →  "Signature is Valid."

Deferred / HSM signing licensed

Sign without ever handing the library a private key. The key stays inside an HSM, cloud KMS, smartcard or PKI token, and only the raw signature crosses back. This works with any PKI (national PKI, eIDAS, AATL): the library builds the CMS / PKCS#7 container and asks your signer for the RSA signature over the document digest.

There are two flavors. Model A (callback) is a single call: you pass a sign_hash callable that the library invokes synchronously to obtain the signature. Model B (two phase) splits preparation and completion, so the digest can travel to a remote signer (a different process, host, or an asynchronous cloud signing service) before you embed the finished container.

Model A: callback signer

The library assembles the CMS signed attributes, calls sign_hash(data) for the raw RSA PKCS#1 v1.5 signature over SHA-256 of data, then embeds the container. The certificate (and any intermediates) are passed as DER, independent of the key.

rustpdf.sign_with(pdf, cert_der, sign_hash, chain=(), options=None) → bytes
python
import rustpdf

pdf_bytes        = open("contract.pdf", "rb").read()
cert_der         = open("signing-cert.der", "rb").read()   # X.509 certificate (DER)
intermediate_der = open("intermediate.der", "rb").read()   # chain cert (DER)

# The signer delegates to an HSM / cloud KMS / smartcard. No key reaches the library.
def sign_hash(to_be_signed: bytes) -> bytes:
    return my_hsm.sign(to_be_signed)   # raw RSA PKCS#1 v1.5 over SHA-256

signed = rustpdf.sign_with(
    pdf_bytes, cert_der, sign_hash,
    chain=[intermediate_der],
    options=rustpdf.SigningOptions(reason="Approved", location="Berlin", pades=True),
)
open("contract.signed.pdf", "wb").write(signed)

Model B: two-phase (begin / complete)

begin_signing returns a SigningSession holding the prepared document (with a zero-filled /Contents placeholder) plus the exact bytes the signature covers and their hash (SHA-256). Send the hash to a remote signer, build the DER CMS / PKCS#7 container, then call complete() (or rustpdf.complete_signature(document, container)). The two halves can run in different processes.

rustpdf.begin_signing(pdf, options=None) → SigningSession
SigningSession.document, .bytes, .hash, .complete(container) → bytes
rustpdf.complete_signature(document, container) → bytes
python
session = rustpdf.begin_signing(pdf_bytes, options=rustpdf.SigningOptions(pades=True))

# Hand off to a remote HSM / cloud signing service (here, a separate step).
container = remote_signer.build_cms(session.hash)   # finished DER CMS / PKCS#7

signed = session.complete(container)
open("contract.signed.pdf", "wb").write(signed)

Inventory existing signatures

Before signing, inspect the signature fields already present (for example to add a second signature without disturbing the first). An empty list means there are no signature fields.

rustpdf.list_signatures(pdf) → list[SignatureField]
python
for field in rustpdf.list_signatures(pdf_bytes):
    print(field.name, "signed:" , field.signed)   # SignatureField(name, signed)

Signing options

Both models accept a SigningOptions. It carries the visible metadata plus the cryptographic profile.

SigningOptions(reason=None, location=None, name=None, pades=False, certify=Certify.NONE, container_size=0, policy=None, visible=False, visible_page=0, visible_rect=(0,0,0,0), visible_text=None, visible_image=None)
python
opts = rustpdf.SigningOptions(
    reason="Approved", pades=True,
    visible=True, visible_page=0, visible_rect=(360, 60, 540, 130),
    visible_text="Jane Doe\n2026-06-30",
    visible_image=open("signature.png", "rb").read(),   # drawn aspect-fit behind the text
)
SignaturePolicy(oid, hash, hash_algorithm_oid=None, uri=None)

The policy oid is the dotted-decimal policy identifier, hash is the policy document digest (under hash_algorithm_oid, defaulting to SHA-256), and uri is an optional location where the policy can be retrieved.

The key never enters the library. Deferred signing exists so that the signing key can stay in dedicated hardware or a managed service. The library only sees the certificate, the bytes to be signed, and the finished signature, so it is a fit for any external-key PKI (national PKI, eIDAS, AATL).

Timestamp & DSS (PAdES LTV) licensed

Build long-term-validation signatures offline. add_dss appends a Document Security Store (/DSS with certs/CRLs, PAdES-B-LT); timestamp appends an RFC 3161 document timestamp (/DocTimeStamp, PAdES-B-LTA).

rustpdf.add_dss(pdf, certs=(), crls=()) → bytes
rustpdf.timestamp(pdf, tsa_key_der, tsa_cert_der, *, date=None) → bytes
python
signed = open("contract.signed.pdf", "rb").read()

# B-LT: embed validation material (caller supplies DER certs/CRLs)
lt = rustpdf.add_dss(signed, certs=[cert_der], crls=[crl_der])

# B-LTA: add a document timestamp signed by a TSA key/cert
lta = rustpdf.timestamp(lt, tsa_key_der, tsa_cert_der)
open("contract.lta.pdf", "wb").write(lta)

Network timestamp (AD-RT)

To timestamp against a real, trusted TSA over the network, use the transport-agnostic helpers: prepare the document, build an RFC 3161 request, POST it to the TSA yourself, then embed the returned token. Free public TSAs work (FreeTSA https://freetsa.org/tsr, DigiCert http://timestamp.digicert.com).

rustpdf.begin_timestamp(pdf) → (document, to_be_signed)
rustpdf.timestamp_request(imprint, nonce=None, cert_req=True) → bytes
rustpdf.timestamp_token_from_response(response) → bytes
python
import hashlib, urllib.request, rustpdf

document, tbs = rustpdf.begin_timestamp(open("contract.signed.pdf", "rb").read())
req = rustpdf.timestamp_request(hashlib.sha256(tbs).digest())   # RFC 3161 DER

# You do the HTTP POST to any RFC 3161 TSA:
http = urllib.request.Request("https://freetsa.org/tsr", data=req,
                              headers={"Content-Type": "application/timestamp-query"})
response = urllib.request.urlopen(http).read()

token = rustpdf.timestamp_token_from_response(response)
lta = rustpdf.complete_signature(document, token)
open("contract.lta.pdf", "wb").write(lta)

Validate signatures licensed

Validate every signature in a PDF: each report recomputes the /ByteRange digest, parses the CMS, and checks that the cryptographic signature is valid, that the messageDigest matches the covered bytes, and whether the signature covers the whole document.

rustpdf.verify_signatures(data: bytes) → list[dict]

Each entry has field_name, sub_filter, signer, covers_whole_document, digest_valid, signature_valid, is_valid and byte_range, plus the certificate fields issuer, serial_number, valid_from, valid_to, algorithm (e.g. SHA256withRSA) and signing_time (all may be None), cert_count (int) and has_timestamp (bool). Note that field_name and signer may be None when absent from the signature. An empty list means the document is unsigned. Signature validation is a licensed feature: this call requires an active license (signatures) even when the document is unsigned, and is not available on the free tier.

python
data = open("contract.signed.pdf", "rb").read()
for sig in rustpdf.verify_signatures(data):
    print(sig["signer"], "valid:", sig["is_valid"],
          "covers whole doc:", sig["covers_whole_document"])
    print("  issuer:", sig["issuer"], "serial:", sig["serial_number"])
    print("  algorithm:", sig["algorithm"], "signed at:", sig["signing_time"])
    print("  valid:", sig["valid_from"], "→", sig["valid_to"],
          "| certs:", sig["cert_count"], "| timestamped:", sig["has_timestamp"])

Text & image extraction

Extract a document's text, mapping shown glyph codes back to Unicode through each font's ToUnicode map, with space/line inference. Raster images can be pulled out too: JPEGs are written verbatim as .jpg, everything else as .png. The output directory is created automatically if it does not already exist.

rustpdf.extract_text(data: bytes) → str free
rustpdf.extract_images_to_dir(data: bytes, out_dir: str) → int free
python
data = open("report.pdf", "rb").read()
print(rustpdf.extract_text(data))

n = rustpdf.extract_images_to_dir(data, "out_images/")   # returns how many were written
print(f"wrote {n} image(s)")

Page geometry free

Read per-page geometry (size, /Rotate, media and crop boxes) without rebuilding the document. Sizes are in PDF points (72 per inch). width/height ignore rotation; rotated_width/rotated_height account for it (swapped for 90/270 pages), so they match what a viewer sees.

rustpdf.measure_pages(pdf: bytes) → list[PageGeometry]
rustpdf.measure_page(pdf: bytes, index: int) → PageGeometry

Each PageGeometry carries page, width, height, rotation, rotated_width, rotated_height, and the media_box / crop_box as PdfRect (fields x0, y0, x1, y1 plus width() / height() properties).

python
data = open("report.pdf", "rb").read()
for g in rustpdf.measure_pages(data):
    print(f"page {g.page}: {g.width:.0f}×{g.height:.0f} pt, "
          f"rotate {g.rotation}, visible {g.rotated_width:.0f}×{g.rotated_height:.0f}")

g0 = rustpdf.measure_page(data, 0)
print(f"crop box {g0.crop_box.width():.0f}×{g0.crop_box.height():.0f} pt")

Document inspection free

Get a non-mutating overview of a PDF: its version, PDF/A level (if any), encryption posture and page count, all without opening or decrypting the file. It works even on password-protected documents (the encryption fields are still reported).

rustpdf.inspect(pdf: bytes) → PdfOverview

PdfOverview carries version (e.g. "1.7"), pdfa_level (None when not PDF/A), encrypted, encryption (one of "None", "RC4", "AES-128", "AES-256"), requires_password, and page_count.

python
data = open("report.pdf", "rb").read()
o = rustpdf.inspect(data)
print(f"PDF {o.version}, {o.page_count} page(s)")
print(f"PDF/A: {o.pdfa_level or 'no'}")
print(f"encryption: {o.encryption}, needs password: {o.requires_password}")

Find every occurrence of a query string and get its position on the page, useful for highlighting, redaction targeting or click regions. Returns a list of TextHit, each with the matched page (0-based), the matched text, and a bounding box x, y, width, height in PDF points with the origin at the lower-left. An empty list means no match. See the searching PDF text guide for background.

rustpdf.find_text(data: bytes, query: str, case_sensitive=False) → list[TextHit]
python
data = open("report.pdf", "rb").read()
for hit in rustpdf.find_text(data, "Invoice", case_sensitive=False):
    print(f"page {hit.page}: '{hit.text}' at "
          f"({hit.x:.1f}, {hit.y:.1f}) {hit.width:.1f}×{hit.height:.1f} pt")

Render a page to an image licensed

Rasterize a page to a PNG image. A native Rust renderer (built on tiny-skia, with no headless browser) interprets the page content stream, painting real glyph outlines, vector graphics, images, color and transparency. Page rendering is a Pro feature; page_count is free.

rustpdf.render_page_to_png(data: bytes, page: int = 0, dpi: float = 150.0) → bytes licensed
rustpdf.page_count(data: bytes) → int free
python
data = open("report.pdf", "rb").read()
print(f"{rustpdf.page_count(data)} page(s)")
png = rustpdf.render_page_to_png(data, page=0, dpi=150.0)
open("page1.png", "wb").write(png)

Enums

PdfaLevel

ValueLevel
PdfaLevel.A1BPDF/A-1b (basic, PDF 1.4)
PdfaLevel.A2BPDF/A-2b (basic): default of pdfa()
PdfaLevel.A2APDF/A-2a (accessible: pair with tagged())
PdfaLevel.A3BPDF/A-3b (basic, allows attachments)
PdfaLevel.A3APDF/A-3a (accessible + attachments)
PdfaLevel.A4PDF/A-4 (ISO 19005-4, based on PDF 2.0)
PdfaLevel.A4EPDF/A-4e (engineering)
PdfaLevel.A4FPDF/A-4f (requires at least one embedded file)

Align

ValueMeaning
Align.LEFTLeft-aligned (default)
Align.RIGHTRight-aligned
Align.CENTERCentered
Align.JUSTIFYJustified (space distributed between words)

VerticalAnchor

ValueMeaning
VerticalAnchor.BASELINEy is the text baseline (default of place_text)
VerticalAnchor.TOPText hangs from y: baseline at y − ascent × size (legacy fixed-position layout); default of place_paragraph
VerticalAnchor.BOTTOMDescender line rests on y; bottom-pins a paragraph block
VerticalAnchor.LINE_TOPTop of the layout line box (matches legacy layout engines line placement exactly)
VerticalAnchor.LINE_BOTTOMBottom of the layout line box

VerticalAlign

ValueMeaning
VerticalAlign.TOPLine hangs from the box's top edge (top line-alignment in rectangle-based text APIs)
VerticalAlign.MIDDLECap-height centered in the box (default of masked_text)
VerticalAlign.BOTTOMDescender line rests on the box's bottom edge

StampSpace

ValueMeaning
StampSpace.VISIBLECoordinates in the page's displayed space, compensating /Rotate (default)
StampSpace.MEDIARaw PDF user space (legacy layout semantics), never composes with /Rotate

ImageAnchor

ValueMeaning
ImageAnchor.CORNERRotate the image about its own lower-left corner at (x, y) (default)
ImageAnchor.BOUNDING_BOXLand the rotated image's bounding box with its lower-left at (x, y) (bounding-box layout)

AFRelationship

ValueMeaning
AFRelationship.SOURCESource data for the document (e.g. the invoice XML)
AFRelationship.DATAData used to derive the visual content
AFRelationship.ALTERNATIVEAlternative representation
AFRelationship.SUPPLEMENTSupplementary material
AFRelationship.UNSPECIFIEDUnspecified relationship

Encryption

ValueCipher
Encryption.RC4RC4 (legacy)
Encryption.AES128AES-128
Encryption.AES256AES-256 (V5/R6): recommended

FacturxProfile

ValueConformance level
FacturxProfile.MINIMUMMinimal header data only
FacturxProfile.BASIC_WLBasic, without line items
FacturxProfile.BASICBasic, with line items
FacturxProfile.EN16931EN 16931 (Comfort): the interoperable core, default
FacturxProfile.EXTENDEDEN 16931 plus extensions

Error handling

Every failing native call raises PdfError (a RuntimeError) carrying the PdfStatus code and the library's last-error message. License failures (missing/expired/forged token, or a feature the token doesn't grant) surface here too. Token activation failures carry the dedicated license status code (12); a gated build, sign or encrypt call instead reports that operation’s own status (for example Serialize = 4 or Sign = 10) with the same “requires a valid license” message.

python
from rustpdf import Document, PdfError

try:
    with Document() as doc:
        doc.pdfa().set_info(title="x")
        doc.add_page()
        doc.save("out.pdf")
except PdfError as e:
    print("failed:", e)        # e.g. PdfStatus=4 (Serialize): feature 'pdfa' requires a valid license

Utilities

FunctionDescription
rustpdf.version() → strNative library version string.
rustpdf.library_path() → PathPath of the loaded shared library.
rustpdf.activate_license(token)Activate a license token (raises on invalid/expired).
Looking for another language? The same API exists in Delphi / Free Pascal, Swift, C#, Go, PHP, Ruby and Node: browse all docs. They share one core, so behavior is identical.