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.

Two classes do almost everything. 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.

shell
gem install rustpdf
# or in a Gemfile:
#   gem "rustpdf"

Requires Ruby 2.6+ (Fiddle is bundled with the standard library). Verify it loaded:

ruby
require "rustpdf"
puts RustPdf.version   # native library version
On a platform without a prebuilt gem, point RUSTPDF_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:

ruby
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.close

Most methods return the document, so calls chain:

ruby
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.close

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 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:

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:

ruby
RustPdf.activate_license(token)   # raises RustPdf::Error 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

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.

Authoring: create & save

RustPdf::Document.new free

Creates an empty document (A4 default page size). Call close to free the native handle.

MethodDescription
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 = vSet the PDF header version (e.g. 14 → PDF 1.4, 17 → 1.7).
page_countNumber of pages so far.
to_bytesRender the document to a binary String.
save(path)Render and write to a file.
closeFree 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.

MethodDescription
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.
fillFill the current path with the fill color.
strokeStroke the current path with the stroke color.
ruby
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.close

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) → Integer   add_font(data) → Integer
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 it out (or 0) for ordinary text.

ruby
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.close

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: RustPdf::Align::LEFT)
ruby
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.close

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) → IntegerLoad JPEG/PNG from a file; returns the image id.
add_image_png(data) → IntegerRegister a PNG from a byte String.
add_image_jpeg(data) → IntegerRegister 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).
ruby
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.close

PDF/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)
ruby
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.close
PDF/A requires the title to be set for valid metadata: call info(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 H1H6, and figure(..., alt) for described images.

tagged
ruby
doc = 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.close

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 Relationship.

attach_file(name, mime, data, relationship: RustPdf::Relationship::SOURCE, description: "")
ruby
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.close

AcroForm 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.

MethodDescription
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.
ruby
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.close

Fill 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.

ruby
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:)
ruby
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.close

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 array of indices.
extract_pages(indices) → EditableDocNew document containing just those pages.
page_countCurrent page count.
ruby
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.close

Metadata, overlay & form fill

MethodDescription
set_info(key, value)Set one info entry (e.g. "Title").
get_info(key) → StringRead 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) → BooleanFill an AcroForm text field; returns whether it was found.
ruby
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.close

Optimize & compact

MethodDescription
optimizeDrop unreferenced objects, Flate-compress uncompressed streams, dedupe identical objects.
compact(on = true)Pack objects into object streams + emit a cross-reference stream.
ruby
ed = RustPdf::EditableDoc.load_file("big.pdf")
ed.optimize.compact(true)
ed.save("small.pdf")
ed.close

Encryption 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)
ruby
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.close

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

Output & incremental update

MethodDescription
to_bytes → StringSerialize the manipulated document.
save(path)Serialize to a file.
to_bytes_incremental(original) → StringAppend only changes to the original bytes (signature-safe, non-destructive).
ruby
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.close

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 byte Strings. pades: true switches to PAdES-B-B.

RustPdf.sign(pdf, key_der, cert_der, reason:, location:, name:, pades:) → String
ruby
pdf      = 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:) → String
RustPdf.timestamp(pdf, tsa_key_der, tsa_cert_der, date:) → String
ruby
signed = 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 free
ruby
data = File.binread("report.pdf")
puts RustPdf.extract_text(data)

Enums

Pdfa

ValueLevel
RustPdf::Pdfa::A1BPDF/A-1b (basic, PDF 1.4)
RustPdf::Pdfa::A2BPDF/A-2b (basic): default of pdfa
RustPdf::Pdfa::A2APDF/A-2a (accessible: pair with tagged)
RustPdf::Pdfa::A3BPDF/A-3b (basic, allows attachments)
RustPdf::Pdfa::A3APDF/A-3a (accessible + attachments)

Align

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

Relationship

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

Cipher

ValueCipher
RustPdf::Cipher::RC4RC4 (legacy)
RustPdf::Cipher::AES128AES-128
RustPdf::Cipher::AES256AES-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.

ruby
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
end

Utilities

FunctionDescription
RustPdf.version → StringNative library version string.
RustPdf.activate_license(token)Activate a license token (raises on invalid/expired).
Looking for another language? The same API exists in Python, Node / TypeScript, Delphi / Free Pascal, Swift, C#, Go, PHP and Java: browse all docs. They share one core, so behavior is identical.