Swift
The RustPdf package wraps the rust-pdf core with idiomatic, handle-owning 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. It ships as a SwiftPM package with a static xcframework, so it links into macOS and iOS apps with nothing to bundle alongside.
Document authors a new PDF; EditableDoc loads and manipulates an existing one. Both are reference types that own a native handle, released automatically in deinit. Stateless calls (version, licensing, extraction, signing) live on the Pdf enum. Every fallible call throws a PdfError.Download
The binding ships as a SwiftPM package with a prebuilt, static RustPdfFFI.xcframework (macOS universal + iOS device + iOS simulator slices). Two artifacts are published per release: the self-contained package (sources + xcframework, consumed via a local path) and the standalone xcframework (consumed by URL). Basic PDF generation works immediately as a free trial; the corporate features unlock with a license token.
Prefer the URL route? The standalone RustPdfFFI-0.4.8.xcframework.zip (.sha256) is referenced directly from a .binaryTarget(url:checksum:) (see Installation). Building from source? make swift-dist assembles the same artifacts.
Installation
The native library is linked statically inside the xcframework, so it works inside an iOS app bundle with no sidecar library and nothing to dlopen. Add the binding to your Package.swift one of two ways.
A. Local package. Unzip rustpdf-swift-0.4.8.zip and depend on it by path:
// Package.swift
dependencies: [
.package(path: "path/to/RustPdf")
],
targets: [
.executableTarget(name: "MyApp", dependencies: [
.product(name: "RustPdf", package: "RustPdf")
])
]B. URL-hosted xcframework. Reference the published xcframework directly; SwiftPM downloads and verifies it:
// In your Package.swift targets:
.binaryTarget(
name: "CRustPdf",
url: "https://rustpdf.dev/downloads/RustPdfFFI-0.4.8.xcframework.zip",
checksum: "c9d9b174bec3170fdbcb45535de6c806c2dfa358f845bd7b759d0b42a439454f"
),
.target(
name: "RustPdf",
dependencies: ["CRustPdf"],
// the static Rust library links libiconv (its only non-system dependency)
linkerSettings: [.linkedLibrary("iconv")]
)swift-v0.4.8 tag.Import it and verify the version:
import RustPdf
print(Pdf.version) // native library version, e.g. "0.4.8"Quick start
A one-page document with a filled rectangle, saved to disk:
import RustPdf
let doc = try Document() // A4 by default
try doc.addPage()
try doc.setFillRGB(0.86, 0.20, 0.18)
try doc.rect(x: 72, y: 640, width: 200, height: 120) // points
try doc.fill()
try doc.save(to: "out.pdf")Most mutators return self, so calls chain:
let doc = try Document()
let font = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
.setFillRGB(0.1, 0.1, 0.12).rect(x: 0, y: 800, width: 595, height: 42).fill()
.showText(font: font, size: 24, x: 72, y: 740, "Olá, açúcar — café")
let data: [UInt8] = try doc.toBytes() // in-memory bytes instead of a file[UInt8]; the binding copies native out-buffers and frees them for you. Strings cross as UTF-8 automatically.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 PdfError with status .license 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:
try Pdf.activateLicense(token) // throws PdfError(.license) 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 task its own
Document/EditableDoc; independent documents share no state and run concurrently. - 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; serialize access if you must. - Errors are per-thread. The native last-error is thread-local, so a failure on one thread never clobbers another's, and
PdfErroris always thrown on the calling thread. - License is process-global.
Pdf.activateLicense(or the env var) applies to every thread; activate once at startup.
DispatchQueue.concurrentPerform(iterations: 8) { i in
do {
let doc = try Document() // one document per task
try doc.addPage()
.setFillRGB(0.1, 0.1, 0.12).rect(x: 72, y: 700, width: 200, height: 80).fill()
try doc.save(to: "out-\(i).pdf")
} catch { print("render \(i) failed:", error) }
}Authoring: create & save
try Document() freeCreates an empty document (A4 default page size). The native handle is released automatically when the Document is deallocated.
| Method | Description |
|---|---|
addPage() | Append a page using the default size. |
addPage(width:height:) | Append a page with an explicit size in points. |
setDefaultSize(width:height:) | Default size for subsequently added pages. |
setVersion(_:) | PDF header version (.v14 / .v15 / .v17 / .v20). |
pageCount | Number of pages so far. |
toBytes() | Render the document to [UInt8]. |
save(to:) | 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 |
|---|---|
setFillRGB(_:_:_:) | Fill color. |
setStrokeRGB(_:_:_:) | Stroke color. |
setLineWidth(_:) | Stroke width in points. |
rect(x:y:width:height:) | Add a rectangle subpath. |
fill() | Fill the current path with the fill color. |
stroke() | Stroke the current path with the stroke color. |
try doc.addPage()
try doc.setStrokeRGB(0.10, 0.45, 0.90).setLineWidth(3)
try doc.rect(x: 72, y: 600, width: 300, height: 160).stroke()
try doc.setFillRGB(0.95, 0.77, 0.06)
try doc.rect(x: 120, y: 640, width: 120, height: 80).fill()
try doc.save(to: "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.
addFont(path:) -> Int32 addFont(data:) -> Int32showText(font:size:x:y:_:headingLevel:)headingLevel (1–6) tags the run as H1–H6 in an accessible document (see Accessibility); leave 0 for ordinary text.
let regular = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: regular, size: 28, x: 72, y: 760, "Invoice #1024")
try doc.showText(font: regular, size: 12, x: 72, y: 720, "日本語 · Ελληνικά · العربية")
try doc.save(to: "text.pdf")\0) 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:)let intro = "A long paragraph that wraps to the given width and is justified " +
"automatically; extra space is distributed between words."
let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.paragraph(font: f, size: 12, x: 72, y: 700, width: 451, text: intro, align: .justify)
try doc.save(to: "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 |
|---|---|
addImage(path:) -> Int32 | Load JPEG/PNG from a file; returns the image id. |
addImagePNG(data:) -> Int32 | Register a PNG from memory. |
addImageJPEG(data:) -> Int32 | Register a JPEG from memory. |
drawImage(_:x:y:width:height:) | Draw at (x, y) scaled to w × h points. |
figure(_:x:y:width:height:alt:) | Draw as a tagged /Figure with alt text (accessibility). |
let logo = try doc.addImage(path: "logo.png")
try doc.addPage()
try doc.drawImage(logo, x: 72, y: 680, width: 160, height: 90)
try doc.save(to: "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() pdfa(_ level: PdfaLevel)try doc.pdfa(.a2b).setInfo(DocumentInfo(title: "Q3 Report", author: "Acme Inc."))
let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: f, size: 20, x: 72, y: 760, "Archival report")
try doc.save(to: "report_pdfa.pdf") // throws without a license granting PDF/Atitle in DocumentInfo.Accessibility (Tagged PDF / PDF/UA) licensed
tagged() builds a logical structure tree (PDF/UA-1). Combine with pdfa(.a2a) for archival and accessible output. Use headingLevel on showText for H1–H6, and figure(…, alt:) for described images.
tagged()try doc.pdfa(.a2a).tagged().setInfo(DocumentInfo(title: "Accessible report"))
let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: f, size: 26, x: 72, y: 760, "Annual report", headingLevel: 1) // H1
try doc.showText(font: f, size: 14, x: 72, y: 720, "Overview", headingLevel: 2) // H2
try doc.showText(font: f, size: 11, x: 72, y: 690, "Body paragraph of the section…")
let chart = try doc.addImage(path: "chart.png")
try doc.figure(chart, x: 72, y: 520, width: 300, height: 150, alt: "Revenue grew 18% year over year")
try doc.save(to: "accessible.pdf")figure() only produces an accessible, alt-texted figure inside a tagged/accessible document; on a plain document the alt text has no effect.tagged() alone is not PDF/UA-1 conformant. By itself it builds the structure tree but does not emit the XMP /Metadata stream that PDF/UA-1 requires, so veraPDF fails clause 7.1-8 ("Catalog … shall contain the Metadata key"). For output that validates as PDF/UA-1, pair it with pdfa(.a2a): pdfa(.a2a).tagged() emits the XMP and passes both verapdf -f 2a and verapdf -f ua1.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:relationship:description:)let xml: [UInt8] = Array(invoiceXmlData)
try doc.pdfa(.a3b).setInfo(DocumentInfo(title: "E-invoice 1024"))
let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: f, size: 18, x: 72, y: 760, "Invoice 1024")
try doc.attachFile(name: "invoice.xml", mime: "text/xml", data: xml,
relationship: .source, description: "Structured invoice data")
try doc.save(to: "einvoice.pdf")attachFile before serializing.attachFile itself. The licensed badge above reflects the PDF/A-3 workflow shown here. Calling attachFile 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: [UInt8], profile: FacturxProfile = .en16931)let xml: [UInt8] = Array(crossIndustryInvoiceData) // your Factur-X XML
try doc.setInfo(DocumentInfo(title: "Invoice INV-2026-001"))
let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: f, size: 18, x: 72, y: 760, "Invoice INV-2026-001")
try doc.facturx(xml, profile: .en16931)
try doc.save(to: "einvoice.pdf") // PDF/A-3 + Factur-X; needs a PDF/A licenseSee the FacturxProfile enum for the conformance levels (.minimum through .extended).
AcroForm fields
Build interactive forms with generated appearance streams (no NeedAppearances). Rectangles are tuples (x0, y0, x1, y1); page is a 0-based page index. Dotted names ("a.b.c") create hierarchical fields.
| Method | Description |
|---|---|
textField(name:page:rect:value:size:) | Text input (size: 0 → auto font size). |
checkbox(name:page:rect:checked:) | Checkbox. |
dropdown(name:page:rect:options:selected:size:) | Combo box from an array of strings. |
radioGroup(name:page:buttons:selected:) | buttons: [RadioButton], each with a rect and an export value. |
try doc.addPage()
try doc.textField(name: "applicant.name", page: 0, rect: (72, 700, 320, 720))
try doc.checkbox(name: "agree", page: 0, rect: (72, 660, 88, 676), checked: false)
try doc.dropdown(name: "plan", page: 0, rect: (72, 620, 240, 640),
options: ["Starter", "Pro", "Enterprise"], selected: 1)
try doc.radioGroup(name: "billing", page: 0, buttons: [
RadioButton(rect: (72, 580, 88, 596), export: "monthly"),
RadioButton(rect: (140, 580, 156, 596), export: "annual"),
], selected: 1)
try doc.save(to: "form.pdf")Fill and flatten fields later with EditableDoc.
page does not exist, or whose rectangle is degenerate (x1 < x0 or zero area), is rejected when the document is serialized: toBytes()/save() throw 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.Hyperlinks free
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).
linkURI(rect:uri:) linkToPage(rect:pageIndex:top:)let f = try doc.addFont(path: "Roboto-Regular.ttf")
try doc.addPage()
try doc.showText(font: f, size: 14, x: 72, y: 760, "Visit rustpdf.dev (see page 2)")
try doc.linkURI(rect: (72, 756, 320, 776), uri: "https://rustpdf.dev/") // web link
try doc.linkToPage(rect: (330, 756, 430, 776), pageIndex: 1, top: 800) // jump to page 2
try doc.addPage()
try doc.save(to: "links.pdf")Rectangles are tuples (x0, y0, x1, y1) in points; pageIndex 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:) .child(_ bookmark: Bookmark) addBookmark(_:)let f = try doc.addFont(path: "Roboto-Regular.ttf")
for _ in 0..<3 { try doc.addPage() }
try doc.addBookmark(
Bookmark(title: "Chapter 1", page: 0, top: 820)
.child(Bookmark(title: "Section 1.1", page: 1))
.child(Bookmark(title: "Section 1.2", page: 2)))
try doc.addBookmark(Bookmark(title: "Chapter 2", page: 2))
try doc.save(to: "outline.pdf")Metadata
setInfo(_ info: DocumentInfo)Sets the document information dictionary (and, for PDF/A, the matching XMP). Every field of DocumentInfo is optional.
try doc.setInfo(DocumentInfo(
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(loading: [UInt8]) EditableDoc(loading: [UInt8], password: String)let bytes = [UInt8](try Data(contentsOf: URL(fileURLWithPath: "in.pdf")))
let ed = try EditableDoc(loading: bytes)
print(ed.pageCount)
// encrypted input:
let secured = try EditableDoc(loading: encryptedBytes, password: "user-or-owner-pw")
try secured.save(to: "plain.pdf")load does not throw in that case. Serializing a zero-page document throws PdfError ("document has no pages") instead of writing an invalid file, but check pageCount > 0 after loading untrusted input before relying on it.Pages: merge, split, reorder, rotate
| Method | Description |
|---|---|
merge(_:) | Append all pages of another EditableDoc (objects renumbered & remapped). |
rotatePage(_:degrees:) | Rotate one page (90 / 180 / 270). |
deletePage(_:) | Remove a page. |
reorderPages(_:) | Reorder with a full permutation array of indices. |
extractPages(_:) -> EditableDoc | New document containing just those pages. |
pageCount | Current page count. |
let a = try EditableDoc(loading: try read("a.pdf"))
let b = try EditableDoc(loading: try read("b.pdf"))
try a.merge(b) // a now has a's pages followed by b's
try a.rotatePage(0, degrees: 90)
try a.reorderPages([2, 1, 0])
try a.save(to: "merged.pdf")reorderPages 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
| Method | Description |
|---|---|
setInfo(key:value:) | Set one info entry (e.g. "Title"). |
getInfo(key:) -> String | Read an info entry. |
setXMP(_:) | Replace the XMP metadata stream. |
overlayPage(_:content:) | Overlay a content-stream fragment (passed as raw [UInt8] bytes, e.g. Array("q … Q".utf8)) onto a page (stamps/watermarks). |
fillTextField(name:value:) -> Bool | Fill an AcroForm text field; returns whether it was found. |
let ed = try EditableDoc(loading: try read("form.pdf"))
try ed.setInfo(key: "Title", value: "Filled form")
let found = try ed.fillTextField(name: "applicant.name", value: "Jane Doe")
print("filled:", found, "| title:", try ed.getInfo(key: "Title"))
try ed.save(to: "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.
| Method | Description |
|---|---|
fieldNames() -> [String] | Fully-qualified names of every terminal field. |
fillTextField(name:value:) -> Bool | Set a text (or text-style choice) field; returns whether it matched. |
setCheckbox(name:checked:) -> Bool | Check/uncheck a checkbox. |
setRadio(name:exportValue:) -> Bool | Select a radio button by its export value. |
setChoice(name:value:) -> Bool | Set a dropdown / list-box value. |
flattenForms() | Bake all fields into static content and drop the /AcroForm. |
let ed = try EditableDoc(loading: try read("form.pdf"))
print(try ed.fieldNames()) // ["applicant.name", "agree", "plan", ...]
_ = try ed.fillTextField(name: "applicant.name", value: "Jane Doe")
_ = try ed.setCheckbox(name: "agree", checked: true)
_ = try ed.setRadio(name: "billing", exportValue: "annual")
_ = try ed.setChoice(name: "plan", value: "Pro")
try ed.flattenForms() // optional: make it non-editable
try ed.save(to: "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".
watermarkText(_:size:color:opacity:rotationDeg:opaqueBackground:)watermarkImageFile(path:width:height:opacity:rotationDeg:)Set opaqueBackground: true to fill a solid rectangle behind the text (a white-out stamp effect) instead of a transparent overlay. watermarkImageFile takes a rotationDeg to rotate the stamped image.
let ed = try EditableDoc(loading: try read("report.pdf"))
try ed.watermarkText("CONFIDENTIAL", opacity: 0.25, rotationDeg: 45, opaqueBackground: false)
try ed.watermarkImageFile(path: "stamp.png", width: 180, height: 180,
opacity: 0.30, rotationDeg: 30)
try ed.save(to: "stamped.pdf")Positioned drawing free
Stamp a single page in place: fill a rectangle and/or drop a line of text at exact coordinates. 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; rotationDeg rotates the text counter-clockwise about its anchor. Text uses the standard Helvetica font (keep it WinAnsi / Latin-1). Both return whether the page existed.
func fillRect(_ pageIndex: Int, _ x: Double, _ y: Double, _ width: Double, _ height: Double, color: (Double, Double, Double) = (1, 1, 1), opacity: Double = 1.0) -> Boolfunc placeText(_ pageIndex: Int, _ x: Double, _ y: Double, _ text: String, size: Double = 12, color: (Double, Double, Double) = (0, 0, 0), rotationDeg: Double = 0.0, align: Align = .left, fontId: Int = -1, anchor: VerticalAnchor = .baseline) -> Boolfunc maskedText(_ pageIndex: Int, _ x: Double, _ y: Double, _ width: Double, _ height: Double, _ text: String, size: Double = 12, textColor: (Double, Double, Double) = (0, 0, 0), bgColor: (Double, Double, Double) = (1, 1, 1), align: Align = .left, fontId: Int = -1, valign: VerticalAlign = .middle, padding: Double? = nil) -> Boolfunc drawImage(_ pageIndex: Int, image: [UInt8], x: Double, y: Double, width: Double, height: Double, rotationDeg: Double = 0.0, anchor: ImageAnchor = .corner) -> BoolA typical use is masking a spot with an opaque white box, then writing a value over it. placeText takes an align (.left, .right or .center) that shifts the start point along the baseline so the anchor (x, y) is the text's left, right or center edge. maskedText does the mask-and-write in one call: it fills the box [x, y, x+width, y+height] in bgColor (default white), then writes the text horizontally aligned per align and vertically centered within the box, so you never hand-compute the baseline. drawImage takes raw JPEG/PNG image bytes and places the image with its lower-left corner at (x, y) scaled to width × height points. The trailing fontId/anchor/valign/padding parameters are covered in Stamping fonts & anchors below.
let data = try [UInt8](Data(contentsOf: URL(fileURLWithPath: "invoice.pdf")))
let g = try Pdf.measurePage(data, 0) // read the page geometry first
let ed = try EditableDoc(data)
_ = ed.fillRect(0, 400, g.height - 60, 120, 18) // mask: default opaque white
_ = ed.placeText(0, 520, g.height - 56, "PAID", size: 12, color: (0, 0.5, 0), align: .right)
// mask + stamp in one call, centered in the box:
_ = ed.maskedText(0, 400, g.height - 60, 120, 18, "PAID", size: 12, align: .center)
let logo = try [UInt8](Data(contentsOf: URL(fileURLWithPath: "logo.png")))
_ = ed.drawImage(0, image: logo, x: 404, y: g.height - 120, width: 120, height: 48)
try ed.save(to: "stamped.pdf")Stamping fonts & anchors free
The stamping primitives above accept an embedded font, explicit vertical anchoring, and a paragraph mode with automatic word wrapping. See the interactive positioning guide for a visual tour of the anchor and coordinate-space semantics.
Embedded stamping fonts
func addFontFile(_ path: String) throws -> Intfunc addFont(_ data: [UInt8]) throws -> IntRegister a TrueType/OpenType font and pass the returned id as fontId to placeText, maskedText or placeParagraph. The font is embedded as a subset, so stamped text renders with the real font's glyphs and metrics (full Unicode, not just WinAnsi). Leave fontId at -1 to keep the built-in Helvetica.
let ed = try EditableDoc(loading: data)
let times = try ed.addFontFile("/Library/Fonts/Times New Roman.ttf")
_ = ed.placeText(0, 72, 700, "Assinado por João", size: 12, fontId: times)Vertical anchors (placeText)
anchor says what y means: .baseline (default, the historical behavior), .top hangs the text from y (the baseline lands ascent × size below it, legacy fixed-position layout semantics), .bottom rests the descender line on y. The .lineTop/.lineBottom variants use the layout line box (OS/2 win metrics plus a fixed half-leading) instead of the raw ascent/descent, matching legacy layout engines line placement exactly. Ascent/descent come from the selected font (embedded metrics, or the Helvetica AFM).
// y = 700 is the TOP of the text, not the baseline:
_ = ed.placeText(0, 72, 700, "hung from the top", size: 12, anchor: .top)
// reproduce a legacy fixed-position layout line exactly:
_ = ed.placeText(0, 72, 640, "layout line box", size: 12, anchor: .lineBottom)maskedText: vertical alignment & padding
valign places the line inside the box: .middle (default, historical cap-height centering), .top hangs the line from the top edge (top line-alignment in rectangle-based text APIs semantics), .bottom rests the descender line on the bottom edge. padding is the horizontal edge inset (points) for .left/.right alignment: nil keeps the historical min(0.15 × size, width / 4); pass 0 to start flush with the box edge like rectangle-based DrawString APIs.
_ = ed.maskedText(0, 400, 700, 120, 36, "R$ 1.234,56", size: 12,
valign: .top, padding: 0)Paragraphs with word wrapping
func placeParagraph(_ pageIndex: Int, _ x: Double, _ y: Double, _ width: Double, _ text: String, size: Double = 12, color: (Double, Double, Double) = (0, 0, 0), align: Align = .left, fontId: Int = -1, maxHeight: Double? = nil, lineHeight: Double = 1.0, anchor: VerticalAnchor = .top, rotationDeg: Double = 0.0) -> Boolfunc placeParagraphMeasured(...) -> (lines: Int, height: Double)placeParagraph breaks text into lines that fit width points (greedy, by word; \n forces a break) and draws them from (x, y) downward. With the default anchor: .top the first baseline lands ascent × size below y (legacy fixed-position layout); .baseline makes y the first line's baseline; .bottom/.lineBottom bottom-pin the block: its bottom rests on y and it grows upward by its real content height. maxHeight is a ceiling that truncates overflowing lines (with a bottom anchor the cut comes from the top and the last lines stay pinned); it never inflates the position. lineHeight scales the default 1.2 × size leading, and fontId wraps and draws with an embedded font, whose real metrics drive the break points. placeParagraphMeasured takes the same arguments and also reports the number of lines drawn and the consumed block height in points, so you can stack blocks without re-measuring.
// wrap a long remark into a 250 pt column, top at y = 500:
_ = ed.placeParagraph(0, 72, 500, 250,
"Observação: este documento foi assinado digitalmente e " +
"dispensa carimbo físico.", size: 10)
// bottom-pinned block with a 80 pt ceiling, measured:
let (lines, height) = ed.placeParagraphMeasured(
0, 72, 120, 250, longDisclaimer, size: 8,
maxHeight: 80, anchor: .bottom)
print("drew \(lines) lines, \(height) pt tall")Coordinate space: visible vs media
func setStampSpace(_ space: StampSpace) throws -> EditableDocBy default the stamping primitives work in the page's visible space (.visible): coordinates are what a viewer sees, compensating the page's /Rotate, so a rotationDeg: 0 stamp always reads upright on screen. .media switches subsequent calls to the raw PDF user space (the raw-coordinate semantics of legacy layout engines): no composition with /Rotate or the crop offset, and rotationDeg is the baseline angle in media space. Use it to reproduce coordinates computed for legacy PDF libraries on rotated (scanned) pages. Watermarks and redaction are unaffected.
try ed.setStampSpace(.media) // legacy layout engines-compatible coordinates
_ = ed.placeText(0, 72, 260, "same numbers as the legacy layout engines code", size: 12)
try ed.setStampSpace(.visible) // back to the defaultRotated image anchoring
drawImage's anchor controls how a rotated image is anchored at (x, y): .corner (default) rotates the image about its own lower-left corner, so the image sweeps around the point; .boundingBox lands the rotated image's bounding box with its lower-left at (x, y) (bounding-box layout semantics: the drawn pixels always sit at/above/right of the anchor, e.g. a 90° image occupies [x, x+height] × [y, y+width]).
_ = ed.drawImage(0, image: stamp, x: 72, y: 200, width: 120, height: 48,
rotationDeg: 90, anchor: .boundingBox)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(_ index: Int, rects: [(Double, Double, Double, Double)]) -> Boollet ed = try EditableDoc(loading: try read("statement.pdf"))
// rects = array of (x0, y0, x1, y1) tuples on that page
let ok = try ed.redact(0, rects: [(60, 590, 400, 620), (60, 540, 400, 570)])
try ed.save(to: "redacted.pdf") // throws without a license granting redactionPdf.extractText 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.
convertToPdfa(_ level: PdfaLevel = .a2b)let ed = try EditableDoc(loading: try read("in.pdf"))
try ed.convertToPdfa(.a2b) // throws if fonts aren't embedded
try ed.save(to: "archival.pdf") // veraPDF: PDF/A-2b compliantOptimize & compact
| Method | Description |
|---|---|
optimize() | Drop unreferenced objects, Flate-compress uncompressed streams, dedupe identical objects. |
compact(_:) | Pack objects into object streams + emit a cross-reference stream. |
let ed = try EditableDoc(loading: try read("big.pdf"))
try ed.optimize().compact(true)
try ed.save(to: "small.pdf")Encryption licensed
Apply standard-handler encryption at output. AES-256 (V5/R6) uses OS-CSPRNG keys/IVs.
encrypt(method:user:owner:readOnly:)let ed = try EditableDoc(loading: try read("in.pdf"))
try ed.encrypt(method: .aes256, user: "", owner: "owner-secret", readOnly: true)
try ed.save(to: "secured.pdf") // throws without an Encryption licenseSee the Encryption enum for RC4 / AES-128 / AES-256.
Normalize & downgrade free
Downgrade the output PDF version or strip PDF/A conformance so a file becomes a plain PDF. setVersion sets the header version (and clears any catalog /Version override); stripPdfa removes the /OutputIntents, the XMP pdfaid identifier and the /Version; normalize does both in one call (strip PDF/A and set the version, defaulting to 1.7).
| Method | Description |
|---|---|
setVersion(_:) | Set the output PDF version (see PdfVersion). |
stripPdfa() | Remove PDF/A conformance, leaving a plain PDF. |
normalize(_:) | Strip PDF/A and set the version (default .v17). |
let ed = try EditableDoc(loading: try read("archival.pdf"))
try ed.normalize(.v17) // strip PDF/A + downgrade to 1.7
// or step by step:
// try ed.stripPdfa()
// try ed.setVersion(.v15)
try ed.save(to: "plain.pdf")Output & incremental update
| Method | Description |
|---|---|
toBytes() -> [UInt8] | Serialize the manipulated document. |
save(to:) | Serialize to a file. |
toBytesIncremental(over:) -> [UInt8] | Append only changes to the original bytes (signature-safe, non-destructive). |
let original = try read("in.pdf")
let ed = try EditableDoc(loading: original)
try ed.setInfo(key: "Subject", value: "reviewed")
let incremental = try ed.toBytesIncremental(over: original) // original bytes preserved verbatimDigital 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 [UInt8]. pades: true switches to PAdES-B-B.
Pdf.sign(pdf:keyDER:certDER:options:) -> [UInt8]let pdf = try read("contract.pdf")
let key = try read("signer.pk8") // PKCS#8 private key (DER)
let cert = try read("signer.der") // X.509 certificate (DER)
let signed = try Pdf.sign(pdf: pdf, keyDER: key, certDER: cert,
options: SignOptions(reason: "Approved", location: "New York",
name: "Jane Doe", pades: true))
// Verify in a shell: pdfsig contract.signed.pdf → "Signature is Valid."Deferred / HSM signing licensed
Sign without the private key ever entering the library. The key stays in a hardware security module, cloud KMS, smartcard or PKI token; RustPdf builds the PDF signature structures and the CMS, and only the raw signature crosses back. This works with any PKI (for example eIDAS-qualified providers or AATL members). Two models are offered: a synchronous callback (Model A) and a two-phase split (Model B) for asynchronous or networked signers.
Model A: external signer callback
signWith prepares the signature, then calls your closure with the bytes to sign and embeds the raw RSA PKCS#1 v1.5 signature it returns (computed over SHA-256 of those bytes). The certificate is the signer certificate (DER); chain are intermediate certificates (DER), supplied independently of the key.
Pdf.signWith(_ pdf: [UInt8], certificate: [UInt8], chain: [[UInt8]] = [], options: SigningOptions? = nil, sign: ([UInt8]) throws -> [UInt8]) -> [UInt8]let pdf = try read("contract.pdf")
let cert = try read("signer.der") // signer certificate (DER)
let signed = try Pdf.signWith(
pdf, certificate: cert, chain: [intermediateCert],
options: SigningOptions(reason: "Approved", pades: true)
) { toSign in
// Send `toSign` to your HSM / cloud KMS / smartcard and return the
// raw RSA PKCS#1 v1.5 signature. The key never leaves the device.
try hsm.signRSA(sha256: toSign)
}
try Data(signed).write(to: URL(fileURLWithPath: "contract.signed.pdf"))Model B: two-phase signing
For an asynchronous or remote signer, split signing across two calls. beginSigning returns a SigningSession holding the prepared document (with a zero-filled /Contents placeholder), the exact bytes the signature covers, and their SHA-256 hash. Hand the hash to your HSM, assemble a DER CMS / PKCS#7 container, then call complete (or Pdf.completeSignature).
Pdf.beginSigning(_ pdf: [UInt8], options: SigningOptions? = nil) -> SigningSessionSigningSession.complete(_ container: [UInt8]) -> [UInt8]Pdf.completeSignature(_ document: [UInt8], container: [UInt8]) -> [UInt8]let pdf = try read("contract.pdf")
// Phase 1: prepare the document and get the bytes to sign.
let session = try Pdf.beginSigning(pdf, options: SigningOptions(certify: .forms))
let digest = session.hash // SHA-256 of session.bytes
// Phase 2 (possibly minutes later, on another host): your signer returns a
// finished CMS/PKCS#7 container built around `digest`.
let container = try remoteSigner.buildCMS(forHash: digest)
let signed = try session.complete(container)
// `session.document` survives serialization, so phase 2 can run elsewhere:
// let signed = try Pdf.completeSignature(session.document, container: container)Inspect signature fields first
Before signing, list the signature fields already present (signed or empty), so you can pick an empty field or confirm a document is unsigned.
Pdf.listSignatures(_ pdf: [UInt8]) -> [SignatureField]for field in try Pdf.listSignatures(pdf) {
print(field.name, field.signed ? "(signed)" : "(empty)")
}SigningOptions
Shared by both models. Empty strings and nil are treated as absent.
| Property | Type | Meaning |
|---|---|---|
reason | String? | The /Reason recorded in the signature dictionary. |
location | String? | The /Location recorded in the signature dictionary. |
name | String? | The signer /Name recorded in the signature dictionary. |
pades | Bool | Produce a PAdES-B-B signature (ETSI.CAdES.detached). |
certify | Certify | Certify the document (DocMDP). Use only on the first signature. |
containerSize | Int | Reserved /Contents bytes; 0 uses the library default (8192). Raise it for large cloud-HSM CMS containers. |
policy | SignaturePolicy? | Signature-policy identifier (PAdES-EPES); nil for none. |
visible | Bool | Draw a visible signature appearance using the fields below. |
visiblePage | Int | 0-based page index for the visible appearance. |
visibleRect | (Double, Double, Double, Double) | Appearance rectangle (x0, y0, x1, y1) in page points. |
visibleText | String? | Appearance text lines (separated by \n); nil for none. |
visibleImage | [UInt8] | PNG/JPEG bytes drawn aspect-fit behind any visibleText; empty for none. |
The visible appearance can carry an embedded image: set visibleImage to the PNG or JPEG bytes of a handwritten-signature graphic. It is drawn aspect-fit inside visibleRect, behind any visibleText.
let sigImage: [UInt8] = Array(try Data(contentsOf: URL(fileURLWithPath: "signature.png")))
let opts = SigningOptions(reason: "Approved", pades: true,
visible: true, visiblePage: 0,
visibleRect: (360, 60, 540, 130),
visibleText: "Jane Doe\n2026-06-30",
visibleImage: sigImage)Certify
| Value | Meaning |
|---|---|
.none | Not a certifying signature (an ordinary approval signature). |
.locked | /P 1: no changes permitted after signing. |
.forms | /P 2: form-filling and signing permitted. |
.formsAndAnnotations | /P 3: form-filling, signing and annotations permitted. |
SignaturePolicy
A PAdES-EPES policy identifier embedded as a signed attribute.
| Property | Type | Meaning |
|---|---|---|
oid | String | The policy OID (dotted-decimal). |
hash | [UInt8] | The policy document hash (under hashAlgorithmOid). |
hashAlgorithmOid | String? | Hash algorithm OID; nil means SHA-256. |
uri | String? | Optional SPURI qualifier: where the policy can be retrieved. |
SignatureField
| Property | Type | Meaning |
|---|---|---|
name | String | The signature field name. |
signed | Bool | Whether the field already holds a signature. |
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:) -> [UInt8]Pdf.timestamp(pdf:tsaKeyDER:tsaCertDER:date:) -> [UInt8]// B-LT: embed validation material (caller supplies DER certs/CRLs)
let lt = try Pdf.addDss(pdf: signed, certs: [cert], crls: [crl])
// B-LTA: add a document timestamp signed by a TSA key/cert
let lta = try Pdf.timestamp(pdf: lt, tsaKeyDER: tsaKey, tsaCertDER: tsaCert, date: Date())date. The core is deterministic, so there is no implicit “now”. If you omit date (or pass nil), the /DocTimeStamp is stamped with the Unix epoch (Dec 31 1969), which is meaningless for long-term validation. Supply the current Date() (or the intended timestamp time) so the B-LTA timestamp carries a real time.Network timestamp (AD-RT)
For a trusted document timestamp from a network RFC 3161 TSA, the library exposes transport-agnostic helpers and leaves the HTTP POST to you. The flow is: beginTimestamp prepares the document and returns the bytes to timestamp; SHA-256 those bytes; build a request with timestampRequest; POST it to the TSA; extract the token with timestampToken(fromResponse:); then embed it with completeSignature. Free public TSAs work, for example FreeTSA (https://freetsa.org/tsr) or DigiCert (http://timestamp.digicert.com).
Pdf.beginTimestamp(_ pdf: [UInt8]) -> (document: [UInt8], bytes: [UInt8])Pdf.timestampRequest(imprint: [UInt8], nonce: [UInt8]? = nil, certReq: Bool = true) -> [UInt8]Pdf.timestampToken(fromResponse: [UInt8]) -> [UInt8]import CryptoKit
let pdf = try read("signed.pdf")
// Phase 1: prepare and hash the bytes to timestamp.
let (document, bytes) = try Pdf.beginTimestamp(pdf)
let imprint = Array(SHA256.hash(data: Data(bytes)))
// Build the RFC 3161 request and POST it to the TSA (you do the HTTP).
let request = try Pdf.timestampRequest(imprint: imprint)
var req = URLRequest(url: URL(string: "https://freetsa.org/tsr")!)
req.httpMethod = "POST"
req.setValue("application/timestamp-query", forHTTPHeaderField: "Content-Type")
req.httpBody = Data(request)
let (respData, _) = try await URLSession.shared.data(for: req)
// Phase 2: extract the token from the response and embed it.
let token = try Pdf.timestampToken(fromResponse: [UInt8](respData))
let stamped = try Pdf.completeSignature(document, container: token)
try Data(stamped).write(to: URL(fileURLWithPath: "ltv.pdf"))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.
Pdf.verifySignatures(_ data: [UInt8]) -> [SignatureReport]Each SignatureReport has fieldName, subFilter, signer, coversWholeDocument, digestValid, signatureValid, isValid and byteRange. It also exposes the signer-certificate and CMS details: issuer (RFC 4514 DN), serialNumber (uppercase hex), validFrom / validTo (ISO-8601 certificate validity), algorithm (the friendly name, e.g. SHA256withRSA), signingTime (ISO-8601, from the signed attributes), certCount (certificates embedded in the CMS) and hasTimestamp (whether an embedded or document timestamp is present). The certificate-derived fields are optional (String?) and nil when absent, as are fieldName and signer. An empty array means the document is unsigned. Signature validation is a licensed feature: this call requires an active license (signatures) even when the document is unsigned, so it is not available on the free tier.
let data = try read("contract.signed.pdf")
for sig in try Pdf.verifySignatures(data) {
print(sig.signer ?? "?", "valid:", sig.isValid,
"covers whole doc:", sig.coversWholeDocument)
print(" issuer:", sig.issuer ?? "?", "| serial:", sig.serialNumber ?? "?")
print(" algorithm:", sig.algorithm ?? "?", "| signed at:", sig.signingTime ?? "?")
print(" valid:", sig.validFrom ?? "?", "→", sig.validTo ?? "?")
print(" certs:", sig.certCount, "| timestamped:", sig.hasTimestamp)
}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.
Pdf.extractText(_ pdf: [UInt8]) -> String freePdf.extractImagesToDir(_ data: [UInt8], _ dir: String) -> Int freelet data = try read("report.pdf")
print(try Pdf.extractText(data))
let n = try Pdf.extractImagesToDir(data, "out_images/") // returns how many were written
print("wrote \(n) image(s)")Positional text search free
Find every occurrence of a query string and get back its position on the page. Each match is a TextHit carrying the 0-based page, the matched text, and a bounding box (x, y, width, height) in PDF points with the origin at the page's lower-left corner. Pass caseSensitive: true for an exact-case match. An empty array means no match. See the searching text in a PDF guide for the concept.
Pdf.findText(_ pdf: [UInt8], query: String, caseSensitive: Bool = false) -> [TextHit]let data = try read("report.pdf")
for hit in try Pdf.findText(data, query: "Total") {
print("page \(hit.page): \"\(hit.text)\" at " +
"(\(hit.x), \(hit.y)) \(hit.width)×\(hit.height) pt")
}Coordinates match the authoring coordinate system, so a hit's box can be reused directly to draw a highlight or place a link.
Page geometry free
Read each page's size, rotation and boxes without modifying the file. width/height ignore /Rotate; rotatedWidth/rotatedHeight are the dimensions a viewer actually shows. Sizes are in PDF points (72 per inch).
static func measurePages(_ pdf: [UInt8]) throws -> [PageGeometry]static func measurePage(_ pdf: [UInt8], _ pageIndex: Int) throws -> PageGeometryEach PageGeometry has page (0-based), width, height, rotation (0/90/180/270), rotatedWidth, rotatedHeight, and the mediaBox/cropBox as a PdfRect (x0, y0, x1, y1 with width/height helpers).
let data = try [UInt8](Data(contentsOf: URL(fileURLWithPath: "report.pdf")))
let g = try Pdf.measurePage(data, 0)
print("page \(g.page): \(g.width)×\(g.height) pt, rotate \(g.rotation)")
print("visible: \(g.rotatedWidth)×\(g.rotatedHeight) pt")Document inspection free
Get a non-mutating overview of a PDF, its version, PDF/A level (if any) and encryption posture, without decrypting or rewriting it. Handy for triage before deciding how to process a file.
static func inspect(_ pdf: [UInt8]) throws -> PdfOverviewPdfOverview carries version (e.g. "1.7"), pdfaLevel (e.g. "2b", or nil if not PDF/A), encrypted, encryption ("None", "RC4", "AES-128" or "AES-256"), requiresPassword, and pageCount.
let data = try [UInt8](Data(contentsOf: URL(fileURLWithPath: "report.pdf")))
let o = try Pdf.inspect(data)
print("PDF \(o.version), \(o.pageCount) page(s)")
print("PDF/A: \(o.pdfaLevel ?? "no"), encryption: \(o.encryption)")
if o.requiresPassword { print("needs a password to open") }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.
Pdf.renderPageToPng(_ pdf: [UInt8], page: Int = 0, dpi: Double = 150.0) throws → [UInt8] licensedPdf.pageCount(_ pdf: [UInt8]) throws → Int freelet data = [UInt8](try Data(contentsOf: URL(fileURLWithPath: "report.pdf")))
print("\(try Pdf.pageCount(data)) page(s)")
let png = try Pdf.renderPageToPng(data, page: 0, dpi: 150.0)
try Data(png).write(to: URL(fileURLWithPath: "page1.png"))Enums
PdfaLevel
| Value | Level |
|---|---|
.a1b | PDF/A-1b (basic, PDF 1.4) |
.a2b | PDF/A-2b (basic): default of pdfa() |
.a2a | PDF/A-2a (accessible: pair with tagged()) |
.a3b | PDF/A-3b (basic, allows attachments) |
.a3a | PDF/A-3a (accessible + attachments) |
.a4 | PDF/A-4 (ISO 19005-4, based on PDF 2.0) |
.a4e | PDF/A-4e (engineering) |
.a4f | PDF/A-4f (requires at least one embedded file) |
PdfVersion
| Value | Header |
|---|---|
.v14 | PDF 1.4 |
.v15 | PDF 1.5 |
.v17 | PDF 1.7 (default for normalize) |
.v20 | PDF 2.0 |
Align
| Value | Meaning |
|---|---|
.left | Left-aligned (default) |
.right | Right-aligned |
.center | Centered |
.justify | Justified (space distributed between words) |
AFRelationship
| Value | Meaning |
|---|---|
.source | Source data for the document (e.g. the invoice XML) |
.data | Data used to derive the visual content |
.alternative | Alternative representation |
.supplement | Supplementary material |
.unspecified | Unspecified relationship |
Encryption
| Value | Cipher |
|---|---|
.rc4 | RC4-128 (legacy) |
.aes128 | AES-128 |
.aes256 | AES-256 (V5/R6): recommended |
FacturxProfile
| Value | Conformance level |
|---|---|
.minimum | Minimal header data only |
.basicWL | Basic, without line items |
.basic | Basic, with line items |
.en16931 | EN 16931 (Comfort): the interoperable core, default |
.extended | EN 16931 plus extensions |
Error handling
Every failing native call throws PdfError, carrying the status (a PdfStatus) 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.
do {
try doc.pdfa().setInfo(DocumentInfo(title: "x"))
try doc.addPage()
try doc.save(to: "out.pdf")
} catch let e as PdfError {
print("failed:", e.message, "(status \(e.status))") // e.g. .license
}Utilities
| Member | Description |
|---|---|
Pdf.version | Native library version string. |
Pdf.activateLicense(_:) | Activate a license token (throws on invalid/expired). |