PHP
The rust-pdf/rustpdf package wraps the rust-pdf C core with idiomatic, chainable classes over PHP's built-in FFI extension (PHP 8.1+, no compilation). 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. Each holds a native handle freed automatically on destruction; call close() to free it promptly.Installation
Install from Packagist with Composer:
composer require rust-pdf/rustpdfRequires PHP 8.1+ with the FFI extension. The package itself is pure PHP; the native library (libpdf_ffi) for your OS/architecture is fetched automatically on first use (see Native library). Verify it loaded:
<?php
require 'vendor/autoload.php';
echo RustPdf\Pdf::version(), "\n"; // native library versionphp.ini:
extension=ffi
ffi.enable=true ; or, hardened: ffi.enable=preload + ffi.preload=...Quick start
A one-page document with a filled rectangle, saved to disk:
<?php
require 'vendor/autoload.php';
use RustPdf\Document;
$doc = new Document(); // A4 by default
$doc->addPage()
->setFillRgb(0.86, 0.20, 0.18)
->rect(72, 640, 200, 120) // x, y, width, height (points)
->fill()
->save('out.pdf');Most methods return the document, so calls chain. toBytes() returns the PDF as a binary string instead of writing a file:
$doc = new Document();
$font = $doc->addFontFile('Roboto-Regular.ttf');
$doc->addPage()
->setFillRgb(0.1, 0.1, 0.12)
->rect(0, 800, 595, 42)->fill()
->showText($font, 24, 72, 740, 'Olá, açúcar — café');
$data = $doc->toBytes(); // binary string
file_put_contents('out.pdf', $data);Native library
The binding loads a prebuilt libpdf_ffi matching your platform. It is resolved in this order:
RUSTPDF_LIB: an absolute path to a library you provide (always wins);- the copy bundled into the package under
lib/<os>-<arch>/; - a development build under
target/{debug,release}(monorepo checkout); - lazy download of the matching prebuilt library on first use.
Because Composer runs install scripts only for the root project (never a dependency), the download cannot fire automatically on composer require. The lazy fetch (step 4) covers most setups with no configuration. If your production filesystem is read-only, fetch the library once at deploy/CI time instead:
php vendor/rust-pdf/rustpdf/bin/rustpdf-install-libOr wire it into your root composer.json:
"scripts": {
"post-install-cmd": "@php vendor/rust-pdf/rustpdf/bin/rustpdf-install-lib",
"post-update-cmd": "@php vendor/rust-pdf/rustpdf/bin/rustpdf-install-lib"
}RUSTPDF_NO_DOWNLOAD=1 to forbid the network fetch; you must then supply the library through RUSTPDF_LIB. Prebuilt platforms: macOS (arm64, x86_64), Linux (x86_64, aarch64) and Windows (x86_64).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 throw PdfException 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\Pdf::activateLicense($token); // throws PdfException 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.
Handles & lifetime
Each Document/EditableDoc owns a native handle. It is freed automatically when the object is garbage-collected (__destruct), so explicit cleanup is optional. For long-running workers or tight loops, call close() to release it immediately.
- The core is Send but not Sync: never share one live handle across threads/processes. Build independent documents instead; each shares no state.
- Native errors are thread-local, so a failure in one request never clobbers another's.
- The license is process-global: activate once at startup.
Authoring: create & save
new Document() freeCreates an empty document (A4 default page size). The handle is freed on destruction or via close().
| Method | Description |
|---|---|
addPage(?w = null, ?h = null) | Append a page, optionally sized in points. |
setDefaultSize(w, h) | Default size for subsequently added pages. |
setVersion(v) | Set the PDF header version (e.g. 14 → PDF 1.4, 17 → 1.7). |
pageCount() | Number of pages so far. |
toBytes() | 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 |
|---|---|
setFillRgb(r, g, b) | Fill color. |
setStrokeRgb(r, g, b) | Stroke color. |
setLineWidth(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 = new Document();
$doc->addPage()
->setStrokeRgb(0.10, 0.45, 0.90)->setLineWidth(3)
->rect(72, 600, 300, 160)->stroke()
->setFillRgb(0.95, 0.77, 0.06)
->rect(120, 640, 120, 80)->fill()
->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.
addFontFile(path): int addFont(data: string): intshowText(font, size, x, y, text, headingLevel = 0): selfheadingLevel (1–6) tags the run as H1–H6 in an accessible document (see Accessibility); leave it 0 for ordinary text.
$doc = new Document();
$regular = $doc->addFontFile('Roboto-Regular.ttf');
// …or from bytes you already have in memory:
// $regular = $doc->addFont(file_get_contents('Roboto-Regular.ttf'));
$doc->addPage()
->showText($regular, 28, 72, 760, 'Invoice #1024')
->showText($regular, 12, 72, 720, '日本語 · Ελληνικά · العربية')
->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): selfuse RustPdf\{Document, Align};
$intro =
'A long paragraph that wraps to the given width and is justified '
. 'automatically; extra space is distributed between words.';
$doc = new Document();
$f = $doc->addFontFile('Roboto-Regular.ttf');
$doc->addPage()
->paragraph($f, 12, 72, 700, 451, $intro, Align::Justify)
->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 |
|---|---|
addImageFile(path): int | Load JPEG/PNG from a file; returns the image id. |
addImagePng(data: string): int | Register a PNG from memory. |
addImageJpeg(data: string): int | Register a JPEG from memory. |
drawImage(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 = new Document();
$logo = $doc->addImageFile('logo.png');
$doc->addPage()
->drawImage($logo, 72, 680, 160, 90)
->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(?PdfaLevel $level = null): selfuse RustPdf\{Document, PdfaLevel};
$doc = new Document();
$doc->pdfa(PdfaLevel::A2b)->setInfo(title: 'Q3 Report', author: 'Acme Inc.');
$f = $doc->addFontFile('Roboto-Regular.ttf');
$doc->addPage()
->showText($f, 20, 72, 760, 'Archival report')
->save('report_pdfa.pdf'); // throws PdfException without a PDF/A licensesetInfo(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 headingLevel on showText for H1–H6, and figure(..., alt) for described images.
tagged(): selfuse RustPdf\{Document, PdfaLevel};
$doc = new Document();
$doc->pdfa(PdfaLevel::A2a)->tagged()->setInfo(title: 'Accessible report');
$f = $doc->addFontFile('Roboto-Regular.ttf');
$doc->addPage()
->showText($f, 26, 72, 760, 'Annual report', 1) // H1
->showText($f, 14, 72, 720, 'Overview', 2) // H2
->showText($f, 11, 72, 690, 'Body paragraph of the section…');
$chart = $doc->addImageFile('chart.png');
$doc->figure($chart, 72, 520, 300, 150, 'Revenue grew 18% year over year')
->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.
attachFile(name, mime, data, rel = AFRelationship::Source, description = ''): selfuse RustPdf\{Document, PdfaLevel, AFRelationship};
$xml = file_get_contents('invoice.xml');
$doc = new Document();
$doc->pdfa(PdfaLevel::A3b)->setInfo(title: 'E-invoice 1024');
$f = $doc->addFontFile('Roboto-Regular.ttf');
$doc->addPage()
->showText($f, 18, 72, 760, 'Invoice 1024')
->attachFile('invoice.xml', 'text/xml', $xml,
AFRelationship::Source, 'Structured invoice data')
->save('einvoice.pdf');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.
| Method | Description |
|---|---|
textField(name, page, rect, value = '', size = 0) | Text input (size=0 → auto font size). |
checkbox(name, page, rect, checked) | Checkbox. |
dropdown(name, page, rect, options, ?selected, size = 0) | Combo box from an array of strings. |
radioGroup(name, page, buttons, ?selected) | buttons = array of [rect, export] pairs. |
$doc = new Document();
$doc->addPage()
->textField('applicant.name', 0, [72, 700, 320, 720], '')
->checkbox('agree', 0, [72, 660, 88, 676], false)
->dropdown('plan', 0, [72, 620, 240, 640],
['Starter', 'Pro', 'Enterprise'], 1)
->radioGroup('billing', 0, [
[[72, 580, 88, 596], 'monthly'],
[[140, 580, 156, 596], 'annual'],
], 1)
->save('form.pdf');Fill fields later with EditableDoc::fillTextField.
Metadata
setInfo(?title, ?author, ?subject, ?keywords, ?creator): selfSets the document information dictionary (and, for PDF/A, the matching XMP). Pass only the fields you need; named arguments keep it readable.
$doc->setInfo(
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: string, ?password = null): selfEditableDoc::loadFile(path: string, ?password = null): selfuse RustPdf\EditableDoc;
$ed = EditableDoc::loadFile('in.pdf');
echo $ed->pageCount(), "\n";
$ed->close();
// encrypted input:
$sec = EditableDoc::loadFile('secured.pdf', 'user-or-owner-pw');
$sec->save('plain.pdf');Pages: merge, split, reorder, rotate
| Method | Description |
|---|---|
merge(other) | Append all pages of another EditableDoc (objects renumbered & remapped). |
rotatePage(index, degrees) | Rotate one page (90 / 180 / 270). |
deletePage(index) | Remove a page. |
reorderPages(order) | Reorder with a full permutation array of indices. |
extractPages(indices): EditableDoc | New document containing just those pages. |
pageCount() | Current page count. |
$a = EditableDoc::loadFile('a.pdf');
$b = EditableDoc::loadFile('b.pdf');
$a->merge($b); // a now has a's pages followed by b's
$a->rotatePage(0, 90);
$a->reorderPages(array_reverse(range(0, $a->pageCount() - 1)));
$a->save('merged.pdf');
$subset = $a->extractPages([0, 2]); // pages 1 and 3
$subset->save('subset.pdf');Metadata, overlay & form fill
| Method | Description |
|---|---|
setInfo(key, value) | Set one info entry (e.g. "Title"). |
getInfo(key): string | Read an info entry. |
setXmp(xml: string) | Replace the XMP metadata stream. |
overlayPage(index, content: string) | Overlay a content-stream fragment onto a page (stamps/watermarks). |
fillTextField(name, value): bool | Fill an AcroForm text field; returns whether it was found. |
$ed = EditableDoc::loadFile('form.pdf');
$ed->setInfo('Title', 'Filled form');
$found = $ed->fillTextField('applicant.name', 'Jane Doe');
printf("filled: %s | title: %s\n", $found ? 'yes' : 'no', $ed->getInfo('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. |
$ed = EditableDoc::loadFile('big.pdf');
$ed->optimize()->compact(true)->save('small.pdf');Encryption licensed
Apply standard-handler encryption at output. AES-256 (V5/R6) uses OS-CSPRNG keys/IVs.
encrypt(method = Encryption::Aes256, user = '', owner = '', readOnly = false): selfuse RustPdf\{EditableDoc, Encryption};
$ed = EditableDoc::loadFile('in.pdf');
$ed->encrypt(Encryption::Aes256, owner: 'owner-secret', readOnly: true)
->save('secured.pdf'); // throws PdfException without an Encryption licenseSee the Encryption enum for RC4 / AES-128 / AES-256.
Output & incremental update
| Method | Description |
|---|---|
toBytes(): string | Serialize the manipulated document. |
save(path) | Serialize to a file. |
toBytesIncremental(original: string): string | Append only changes to the original bytes (signature-safe, non-destructive). |
$original = file_get_contents('in.pdf');
$ed = EditableDoc::load($original);
$ed->setInfo('Subject', 'reviewed');
$incremental = $ed->toBytesIncremental($original); // original bytes preserved verbatim
file_put_contents('reviewed.pdf', $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 binary strings. pades: true switches to PAdES-B-B.
Pdf::sign(pdf, keyDer, certDer, ?reason, ?location, ?name, pades = false): stringuse RustPdf\Pdf;
$pdf = file_get_contents('contract.pdf');
$keyDer = file_get_contents('signing-key.pkcs8.der'); // PKCS#8 private key (DER)
$certDer = file_get_contents('signing-cert.der'); // X.509 certificate (DER)
$signed = Pdf::sign($pdf, $keyDer, $certDer,
reason: 'Approved', location: 'New York',
name: 'Jane Doe', pades: true);
file_put_contents('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. addDss appends a Document Security Store (/DSS with certs/CRLs, PAdES-B-LT); timestamp appends an RFC 3161 document timestamp (/DocTimeStamp, PAdES-B-LTA).
Pdf::addDss(pdf, certs = [], crls = []): stringPdf::timestamp(pdf, tsaKeyDer, tsaCertDer, ?date = null): string$signed = file_get_contents('contract.signed.pdf');
// B-LT: embed validation material (caller supplies DER certs/CRLs)
$lt = Pdf::addDss($signed, [$certDer], [$crlDer]);
// B-LTA: add a document timestamp signed by a TSA key/cert
$lta = Pdf::timestamp($lt, $tsaKeyDer, $tsaCertDer);
file_put_contents('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.
Pdf::extractText(pdf: string): string free$data = file_get_contents('report.pdf');
echo RustPdf\Pdf::extractText($data);Enums
All enums are PHP 8.1 backed enums in the RustPdf namespace.
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 throws PdfException (a RuntimeException) 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.
use RustPdf\{Document, PdfException};
try {
$doc = new Document();
$doc->pdfa()->setInfo(title: 'x')->addPage()->save('out.pdf');
} catch (PdfException $e) {
fprintf(STDERR, "failed: %d %s\n", $e->status, $e->getMessage());
// e.g. PdfStatus=7: feature 'pdfa' requires a valid license
}Utilities
| Function | Description |
|---|---|
Pdf::version(): string | Native library version string. |
Pdf::activateLicense(token) | Activate a license token (throws on invalid/expired). |