Ruby
The rustpdf gem wraps the rust-pdf C core with idiomatic, chainable classes over Ruby's built-in Fiddle standard library (pure FFI, no native gem to compile). 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.
RustPdf::Document authors a new PDF; RustPdf::EditableDoc loads and manipulates an existing one. Each holds a native handle, so call close when done to free it promptly (or let the GC do it).Installation
Install from RubyGems. The native library (libpdf_ffi) ships inside platform-specific gems (arm64-darwin, x86_64-linux, aarch64-linux, x64-mingw-ucrt): RubyGems downloads only the gem matching your OS/architecture, so there's nothing to compile.
gem install rustpdf
# or in a Gemfile:
# gem "rustpdf"Requires Ruby 2.6+ (Fiddle is bundled with the standard library). Verify it loaded:
require "rustpdf"
puts RustPdf.version # native library versionRUSTPDF_LIB at a libpdf_ffi you built yourself (cargo build -p pdf-ffi --release) and the loader will use it.Quick start
A one-page document with a filled rectangle, saved to disk:
require "rustpdf"
doc = RustPdf::Document.new # A4 by default
doc.add_page
.fill_rgb(0.86, 0.20, 0.18)
.rect(72, 640, 200, 120) # x, y, width, height (points)
.fill
doc.save("out.pdf")
doc.closeMost methods return the document, so calls chain:
doc = RustPdf::Document.new
font = doc.add_font_file("Roboto-Regular.ttf")
doc.add_page
.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 # a String of bytes instead of a file
doc.closeLicensing & 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 RustPdf::Error 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 RustPdf::Error 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
Every native call is synchronous and the core is Send but not Sync: a single handle must never be touched by two threads at once.
- Documents are independent. Each
Document/EditableDocshares no state, so they're safe to build across threads or processes, one handle per thread. - Don't share a live handle between threads; give each its own.
- Errors are per-thread. The native last-error is thread-local, so a failure in one thread never clobbers another's, and
RustPdf::Errorsurfaces on the calling side. - License is process-global.
activate_license(or the env var) applies to every thread; activate once at startup. - The GVL: a long generation holds Ruby's global VM lock for its duration. For heavy parallel jobs, fan out across processes (e.g. a job queue), one document per worker.
Authoring: create & save
RustPdf::Document.new freeCreates an empty document (A4 default page size). Call close to free the native handle.
| Method | Description |
|---|---|
add_page(width:, height:) | Append a page. width/height are optional keyword points; omit for the default size. |
default_size(w, h) | Default size for subsequently added pages. |
version = v | Set the PDF header version (e.g. 14 → PDF 1.4, 17 → 1.7). |
page_count | Number of pages so far. |
to_bytes | Render the document to a binary String. |
save(path) | Render and write to a file. |
close | Free the native handle. |
Pages & vector graphics
Graphics state and path operators mirror PDF's content-stream model. Colors are RGB in 0.0–1.0.
| Method | Description |
|---|---|
fill_rgb(r, g, b) | Fill color. |
stroke_rgb(r, g, b) | Stroke color. |
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. |
doc = RustPdf::Document.new
doc.add_page
.stroke_rgb(0.10, 0.45, 0.90).line_width(3)
.rect(72, 600, 300, 160).stroke
.fill_rgb(0.95, 0.77, 0.06)
.rect(120, 640, 120, 80).fill
doc.save("shapes.pdf")
doc.closeFonts & 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) → Integer add_font(data) → Integershow_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 it out (or 0) for ordinary text.
doc = RustPdf::Document.new
regular = doc.add_font_file("Roboto-Regular.ttf")
# …or from bytes you already have in memory:
# regular = doc.add_font(File.binread("Roboto-Regular.ttf"))
doc.add_page
.show_text(regular, 28, 72, 760, "Invoice #1024")
.show_text(regular, 12, 72, 720, "日本語 · Ελληνικά · العربية")
doc.save("text.pdf")
doc.closeParagraphs
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: RustPdf::Align::LEFT)intro =
"A long paragraph that wraps to the given width and is justified " \
"automatically; extra space is distributed between words."
doc = RustPdf::Document.new
f = doc.add_font_file("Roboto-Regular.ttf")
doc.add_page
.paragraph(f, 12, 72, 700, 451, intro, align: RustPdf::Align::JUSTIFY)
doc.save("paragraph.pdf")
doc.closeSee 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) → Integer | Load JPEG/PNG from a file; returns the image id. |
add_image_png(data) → Integer | Register a PNG from a byte String. |
add_image_jpeg(data) → Integer | Register a JPEG from a byte String. |
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). |
doc = RustPdf::Document.new
logo = doc.add_image_file("logo.png")
doc.add_page.draw_image(logo, 72, 680, 160, 90)
doc.save("with_image.pdf")
doc.closePDF/A licensed
Produce archival-grade output. pdfa defaults to A-2b; pass a Pdfa level for a specific one. 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 = nil)doc = RustPdf::Document.new
doc.pdfa(RustPdf::Pdfa::A2B).info(title: "Q3 Report", author: "Acme Inc.")
f = doc.add_font_file("Roboto-Regular.ttf")
doc.add_page.show_text(f, 20, 72, 760, "Archival report")
doc.save("report_pdfa.pdf") # raises RustPdf::Error without a license granting PDF/A
doc.closeinfo(title: …).Accessibility (Tagged PDF / PDF/UA) licensed
tagged builds a logical structure tree (PDF/UA-1). Combine with pdfa(RustPdf::Pdfa::A2A) for archival and accessible output. Use heading_level: on show_text for H1–H6, and figure(..., alt) for described images.
taggeddoc = RustPdf::Document.new
doc.pdfa(RustPdf::Pdfa::A2A).tagged.info(title: "Accessible report")
f = doc.add_font_file("Roboto-Regular.ttf")
doc.add_page
.show_text(f, 26, 72, 760, "Annual report", heading_level: 1) # H1
.show_text(f, 14, 72, 720, "Overview", heading_level: 2) # H2
.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, "Revenue grew 18% year over year")
doc.save("accessible.pdf")
doc.closeAttachments (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 Relationship.
attach_file(name, mime, data, relationship: RustPdf::Relationship::SOURCE, description: "")xml = File.binread("invoice.xml")
doc = RustPdf::Document.new
doc.pdfa(RustPdf::Pdfa::A3B).info(title: "E-invoice 1024")
f = doc.add_font_file("Roboto-Regular.ttf")
doc.add_page.show_text(f, 18, 72, 760, "Invoice 1024")
doc.attach_file("invoice.xml", "text/xml", xml,
relationship: RustPdf::Relationship::SOURCE,
description: "Structured invoice data")
doc.save("einvoice.pdf")
doc.closeAcroForm fields
Build interactive forms with generated appearance streams (no NeedAppearances). Rectangles are [x0, y0, x1, y1] arrays; page is a 0-based page index. Dotted names ("a.b.c") create hierarchical fields.
| Method | Description |
|---|---|
text_field(name, page, rect, value:, size:) | Text input (size: 0 → auto font size). |
checkbox(name, page, rect, checked) | Checkbox (checked is a boolean). |
dropdown(name, page, rect, options, selected:, size:) | Combo box from an array of strings. |
radio_group(name, page, buttons, selected:) | buttons = array of [rect, "export"] pairs. |
doc = RustPdf::Document.new
doc.add_page
.text_field("applicant.name", 0, [72, 700, 320, 720], value: "")
.checkbox("agree", 0, [72, 660, 88, 676], false)
.dropdown("plan", 0, [72, 620, 240, 640],
["Starter", "Pro", "Enterprise"], selected: 1)
.radio_group("billing", 0, [
[[72, 580, 88, 596], "monthly"],
[[140, 580, 156, 596], "annual"],
], selected: 1)
doc.save("form.pdf")
doc.closeFill fields later with EditableDoc#fill_text_field.
Metadata
info(title:, author:, subject:, keywords:, creator:)Sets the document information dictionary (and, for PDF/A, the matching XMP). Pass only the keywords you need.
doc.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.
RustPdf::EditableDoc.load(data, password:)RustPdf::EditableDoc.load_file(path, password:)ed = RustPdf::EditableDoc.load_file("in.pdf")
puts ed.page_count
ed.close
# encrypted input:
sec = RustPdf::EditableDoc.load_file("secured.pdf", password: "user-or-owner-pw")
sec.save("plain.pdf")
sec.closePages: 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 array of indices. |
extract_pages(indices) → EditableDoc | New document containing just those pages. |
page_count | Current page count. |
a = RustPdf::EditableDoc.load_file("a.pdf")
b = RustPdf::EditableDoc.load_file("b.pdf")
a.merge(b) # a now has a's pages followed by b's
a.rotate_page(0, 90)
a.reorder_pages((0...a.page_count).to_a.reverse)
a.save("merged.pdf")
b.close
subset = a.extract_pages([0, 2]) # pages 1 and 3
subset.save("subset.pdf")
subset.close
a.closeMetadata, overlay & form fill
| Method | Description |
|---|---|
set_info(key, value) | Set one info entry (e.g. "Title"). |
get_info(key) → String | Read an info entry. |
set_xmp(xml) | Replace the XMP metadata stream. |
overlay_page(index, content) | Overlay a content-stream fragment onto a page (stamps/watermarks). |
fill_text_field(name, value) → Boolean | Fill an AcroForm text field; returns whether it was found. |
ed = RustPdf::EditableDoc.load_file("form.pdf")
ed.set_info("Title", "Filled form")
found = ed.fill_text_field("applicant.name", "Jane Doe")
puts "filled: #{found} | title: #{ed.get_info('Title')}"
ed.save("filled.pdf")
ed.closeOptimize & 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. |
ed = RustPdf::EditableDoc.load_file("big.pdf")
ed.optimize.compact(true)
ed.save("small.pdf")
ed.closeEncryption licensed
Apply standard-handler encryption at output. AES-256 (V5/R6) uses OS-CSPRNG keys/IVs.
encrypt(method: RustPdf::Cipher::AES256, user: "", owner: "", read_only: false)ed = RustPdf::EditableDoc.load_file("in.pdf")
ed.encrypt(method: RustPdf::Cipher::AES256, owner: "owner-secret", read_only: true)
ed.save("secured.pdf") # raises RustPdf::Error without an Encryption license
ed.closeSee the Cipher enum for RC4 / AES-128 / AES-256.
Output & incremental update
| Method | Description |
|---|---|
to_bytes → String | Serialize the manipulated document. |
save(path) | Serialize to a file. |
to_bytes_incremental(original) → String | Append only changes to the original bytes (signature-safe, non-destructive). |
original = File.binread("in.pdf")
ed = RustPdf::EditableDoc.load(original)
ed.set_info("Subject", "reviewed")
incremental = ed.to_bytes_incremental(original) # original bytes preserved verbatim
File.binwrite("reviewed.pdf", incremental)
ed.closeDigital 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 byte Strings. pades: true switches to PAdES-B-B.
RustPdf.sign(pdf, key_der, cert_der, reason:, location:, name:, pades:) → Stringpdf = File.binread("contract.pdf")
key_der = File.binread("signing-key.pkcs8.der") # PKCS#8 private key (DER)
cert_der = File.binread("signing-cert.der") # X.509 certificate (DER)
signed = RustPdf.sign(pdf, key_der, cert_der,
reason: "Approved", location: "New York",
name: "Jane Doe", pades: true)
File.binwrite("contract.signed.pdf", 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:) → StringRustPdf.timestamp(pdf, tsa_key_der, tsa_cert_der, date:) → Stringsigned = File.binread("contract.signed.pdf")
# 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)
File.binwrite("contract.lta.pdf", 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) → String freedata = File.binread("report.pdf")
puts RustPdf.extract_text(data)Enums
Pdfa
| Value | Level |
|---|---|
RustPdf::Pdfa::A1B | PDF/A-1b (basic, PDF 1.4) |
RustPdf::Pdfa::A2B | PDF/A-2b (basic): default of pdfa |
RustPdf::Pdfa::A2A | PDF/A-2a (accessible: pair with tagged) |
RustPdf::Pdfa::A3B | PDF/A-3b (basic, allows attachments) |
RustPdf::Pdfa::A3A | PDF/A-3a (accessible + attachments) |
Align
| Value | Meaning |
|---|---|
RustPdf::Align::LEFT | Left-aligned (default) |
RustPdf::Align::RIGHT | Right-aligned |
RustPdf::Align::CENTER | Centered |
RustPdf::Align::JUSTIFY | Justified (space distributed between words) |
Relationship
| Value | Meaning |
|---|---|
RustPdf::Relationship::SOURCE | Source data for the document (e.g. the invoice XML) |
RustPdf::Relationship::DATA | Data used to derive the visual content |
RustPdf::Relationship::ALTERNATIVE | Alternative representation |
RustPdf::Relationship::SUPPLEMENT | Supplementary material |
RustPdf::Relationship::UNSPECIFIED | Unspecified relationship |
Cipher
| Value | Cipher |
|---|---|
RustPdf::Cipher::RC4 | RC4 (legacy) |
RustPdf::Cipher::AES128 | AES-128 |
RustPdf::Cipher::AES256 | AES-256 (V5/R6): recommended |
Error handling
Every failing native call raises RustPdf::Error (a StandardError) carrying the status (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.
begin
doc = RustPdf::Document.new
doc.pdfa.info(title: "x")
doc.add_page
doc.save("out.pdf")
doc.close
rescue RustPdf::Error => e
warn "failed: #{e.status} #{e.message}"
# e.g. PdfStatus=7: feature 'pdfa' requires a valid license
endUtilities
| Function | Description |
|---|---|
RustPdf.version → String | Native library version string. |
RustPdf.activate_license(token) | Activate a license token (raises on invalid/expired). |