Python
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), tagged/accessible output, attachments, AcroForm fields, manipulation, text extraction, encryption and digital signatures.
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.
pip install rustpdfVerify it loaded:
import rustpdf
print(rustpdf.version()) # native library version
print(rustpdf.library_path()) # which .dylib/.so/.dll was loadedQuick start
A one-page document with a filled rectangle, saved to disk:
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:
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 fileLicensing & 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:
export RUSTPDF_LICENSE="010f0000…" # the token we email you
# or point at a file:
export RUSTPDF_LICENSE_FILE=/etc/rustpdf/license.txtOr activate explicitly in code:
rustpdf.activate_license(token) # raises PdfError if forged / expired / malformedCoordinate system
- Units are points (1 pt = 1/72 inch). A4 is
595 × 842, US Letter612 × 792. - The origin
(0, 0)is the bottom-left corner;ygrows upward. - For text,
(x, y)is the baseline of the first glyph. - Drawing/text always targets the most recently added page.
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.
- Generate in parallel. Give each thread its own
Document/EditableDoc— independent documents share no state and run truly concurrently (ctypesreleases the GIL during each native call). - Move between threads. A handle may be created on one thread and used on another.
- Never share a live handle. Two threads calling into the same
Documentat the same time is unsupported; protect it with your own lock if you really must. - Errors are per-thread. The native last-error is thread-local, so a failure on one thread never clobbers another's —
PdfErroris always raised on the calling thread. - License is process-global.
activate_license(or the env var) applies to every thread; activate once at startup.
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 concurrentlyAuthoring: create & save
rustpdf.Document() freeCreates an empty document (A4 default page size). Use as a context manager; call close() manually only if you can't.
| Method | Description |
|---|---|
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 (e.g. 14 → PDF 1.4, 17 → 1.7). |
page_count | Property: number of pages so far. |
to_bytes() | Render the document to bytes. |
save(path) | Render and write to a file. |
Pages & vector graphics
Graphics state and path operators mirror PDF's content-stream model. Colors are RGB in 0.0–1.0.
| Method | Description |
|---|---|
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. |
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). Register a font once, then reference it by its integer id.
add_font_file(path) → int add_font(data: bytes) → intshow_text(font, size, x, y, text, heading_level=0)heading_level (1–6) tags the run as H1–H6 in an accessible document (see Accessibility); leave 0 for ordinary text.
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")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)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.
| Method | Description |
|---|---|
add_image_file(path) → int | Load JPEG/PNG from a file; returns the image id. |
add_image_png(data: bytes) → int | Register a PNG from memory. |
add_image_jpeg(data: bytes) → int | Register 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). |
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.
pdfa(level=None)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/Aset_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 H1–H6, and figure(..., alt=…) for described images.
tagged()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")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="")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")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.
| Method | Description |
|---|---|
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. |
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 fields later with EditableDoc.fill_text_field.
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.
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)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")Pages: merge, split, reorder, rotate
| Method | Description |
|---|---|
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) → EditableDoc | New document containing just those pages. |
page_count | Property: current page count. |
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")Metadata, overlay & form fill
| Method | Description |
|---|---|
set_info(key, value) | Set one info entry (e.g. "Title"). |
get_info(key) → str | Read 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) → bool | Fill an AcroForm text field; returns whether it was found. |
with EditableDoc.load_file("form.pdf") as ed:
ed.set_info("Title", "Filled form")
found = ed.fill_text_field("applicant.name", "Edivan Teixeira")
print("filled:", found, "| title:", ed.get_info("Title"))
ed.save("filled.pdf")Optimize & compact
| Method | Description |
|---|---|
optimize() | Drop unreferenced objects, Flate-compress uncompressed streams, dedupe identical objects. |
compact(on=True) | Pack objects into object streams + emit a cross-reference stream. |
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)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 licenseSee the Encryption enum for RC4 / AES-128 / AES-256.
Output & incremental update
| Method | Description |
|---|---|
to_bytes() → bytes | Serialize the manipulated document. |
save(path) | Serialize to a file. |
to_bytes_incremental(original: bytes) → bytes | Append only changes to the original bytes (signature-safe, non-destructive). |
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)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) → bytesimport 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="São Paulo",
name="Edivan Teixeira", pades=True)
open("contract.signed.pdf", "wb").write(signed)
# Verify in a shell: pdfsig contract.signed.pdf → "Signature is Valid."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=()) → bytesrustpdf.timestamp(pdf, tsa_key_der, tsa_cert_der, *, date=None) → bytessigned = 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)Text extraction
Extract a document's text, mapping shown glyph codes back to Unicode through each font's ToUnicode map, with space/line inference.
rustpdf.extract_text(data: bytes) → str freedata = open("report.pdf", "rb").read()
print(rustpdf.extract_text(data))Enums
PdfaLevel
| Value | Level |
|---|---|
PdfaLevel.A1B | PDF/A-1b (basic, PDF 1.4) |
PdfaLevel.A2B | PDF/A-2b (basic): default of pdfa() |
PdfaLevel.A2A | PDF/A-2a (accessible: pair with tagged()) |
PdfaLevel.A3B | PDF/A-3b (basic, allows attachments) |
PdfaLevel.A3A | PDF/A-3a (accessible + attachments) |
Align
| Value | Meaning |
|---|---|
Align.LEFT | Left-aligned (default) |
Align.RIGHT | Right-aligned |
Align.CENTER | Centered |
Align.JUSTIFY | Justified (space distributed between words) |
AFRelationship
| Value | Meaning |
|---|---|
AFRelationship.SOURCE | Source data for the document (e.g. the invoice XML) |
AFRelationship.DATA | Data used to derive the visual content |
AFRelationship.ALTERNATIVE | Alternative representation |
AFRelationship.SUPPLEMENT | Supplementary material |
AFRelationship.UNSPECIFIED | Unspecified relationship |
Encryption
| Value | Cipher |
|---|---|
Encryption.RC4 | RC4 (legacy) |
Encryption.AES128 | AES-128 |
Encryption.AES256 | AES-256 (V5/R6): recommended |
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.
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=7: feature 'pdfa' requires a valid licenseUtilities
| Function | Description |
|---|---|
rustpdf.version() → str | Native library version string. |
rustpdf.library_path() → Path | Path of the loaded shared library. |
rustpdf.activate_license(token) | Activate a license token (raises on invalid/expired). |