oxideav-pdf 0.1.1

Pure-Rust PDF writer for the oxideav framework — vector-stays-vector path
Documentation

oxideav-pdf

Pure-Rust PDF writer + reader for the oxideav framework. The writer emits PDF 1.4 vector documents from VectorFrame / Scene inputs (paths stay paths, fills stay fills); the reader walks bytes back into a Scene, with optional decryption for password-protected files. Zero C dependencies.

Part of the oxideav framework — a pure-Rust media stack. Codec, container, and filter crates are implemented from the spec (no C codec libraries linked or wrapped, no *-sys crates).

What round 1 supports

  • Paths: MoveTo (m), LineTo (l), CubicCurveTo (c), QuadCurveTo (lifted to cubic via the 2/3 * (control - endpoint) trick), ArcTo (flattened to cubic per SVG 1.1 Appendix F.6.5), Close (h).
  • Fills: Paint::Solid (DeviceRGB sc), Paint::LinearGradient (axial pattern shading, Pattern Type 2 + Function Type 2), Paint::RadialGradient (radial shading, Function Type 3).
  • Strokes: width (w), cap (J), join (j), miter limit (M), dash pattern (d).
  • Transforms: every Group::transform emits one cm operator.
  • Groups: q ... Q save/restore brackets around children. Group opacity becomes an ExtGState resource referenced via /GSx gs.
  • Clip paths: emitted before the children's content stream as W n (or W* n for even-odd fill rule).
  • Fill rules: NonZero (f / B) vs. EvenOdd (f* / B*).
  • Embedded raster: ImageRef whose underlying VideoFrame is RGBA8 lands as a FlateDecode Image XObject and is painted with Do.

Encryption decode (full Standard handler)

The reader handles password-protected PDFs under the standard security handler across the full revision range ISO 32000 defines:

  • R=2 — RC4-40 (V=1, Length=40).
  • R=3 — RC4-128 (V=2, Length=128).
  • R=4 — AES-128 CBC or RC4-128, picked from the crypt-filter CFM (AESV2 vs V2).
  • R=5 — AES-256 CBC, V=5, CFM=AESV3. Adobe extension level 3 (PDF 1.7); plain SHA-256 password derivation with validation + key salts.
  • R=6 — AES-256 CBC, V=5, CFM=AESV3. ISO 32000-2:2020 (PDF 2.0); iterated SHA-256/384/512 hash chain (Algorithm 2.B) plus /Perms block validation (Algorithm 13).

Both user and owner passwords authenticate (Algorithms 6 + 7 for R≤4; Algorithms 11 + 12 for R≥5); the default empty user password is tried first so PDFs encrypted "just for permission flags" open with no caller intervention. Strings and stream payloads are decrypted via per-object keys (Algorithm 1) for R≤4 and via the file key directly (no per-object derivation) for R≥5.

let pdf = std::fs::read("locked.pdf")?;
// Default API tries the empty user password.
match oxideav_pdf::read_pdf_to_scene(&pdf) {
    Ok(scene) => println!("opened: {} pages", scene.pages.unwrap().len()),
    Err(_)    => {
        // Password-protected — supply one.
        let scene = oxideav_pdf::read_pdf_to_scene_with_password(&pdf, b"hunter2")?;
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())

Per-stream crypt-filter overrides land in a follow-up round.

Public-key encryption (decode + encode)

The reader and writer both handle public-key-encrypted PDFs under the adbe.pkcs7.s3 / s4 / s5 SubFilters of the public-key security handler (ISO 32000-1 §7.6.4 + ISO 32000-2 §7.6.5):

  • adbe.pkcs7.s3 — RC4-40, V=1, SHA-1 file-key derivation.
  • adbe.pkcs7.s4 — RC4-128, V=2, SHA-1.
  • adbe.pkcs7.s5, V=4 — RC4-128 or AES-128 CBC via CFM (V2 / AESV2).
  • adbe.pkcs7.s5, V=5 — AES-256 CBC, CFM=AESV3, SHA-256.

The trailer's /Recipients array (or /CF /<StmF> /Recipients for s5) carries one CMS EnvelopedData (RFC 5652 §6.1) per access- permission set; each envelope's KeyTransRecipientInfo SET wraps the content-encryption key with RSAES-PKCS1-v1_5 to a recipient's RSA public key. The reader matches by either IssuerAndSerialNumber (CMS v0) or SubjectKeyIdentifier (CMS v2 — RFC 5280 §4.2.1.2 method 1 SHA-1 of the SPKI BIT STRING contents), RSA-decrypts the wrapped CEK, decrypts the envelope contents (RC4 / AES-128 / AES-256 CBC), then derives the file encryption key per §7.6.4.3 / §7.6.5.3.

use oxideav_pdf::{read_pdf_to_scene_with_certificate, PubSecCredential};

let cert_der    = std::fs::read("user.cert.der")?;
let pkcs8_der   = std::fs::read("user.key.pkcs8.der")?;
let credential  = PubSecCredential::from_der(&cert_der, &pkcs8_der)?;
let scene = read_pdf_to_scene_with_certificate(&pdf_bytes, &credential)?;
# Ok::<(), Box<dyn std::error::Error>>(())

Round 11 lands the symmetric encoder side: the writer emits public-key-encrypted PDFs that round-trip through the reader.

use oxideav_pdf::{
    write_pdf_from_scene_pubsec_encrypted, PubSecEncoderConfig, PubSecRecipient,
};

// One recipient — IssuerAndSerial form.
let recipient = PubSecRecipient::from_issuer_and_serial(
    issuer_der,           // recipient cert's `issuer` SEQUENCE bytes
    serial_bytes,         // recipient cert's serial INTEGER body
    rsa_public_key,
);
let cfg = PubSecEncoderConfig::pkcs7_s5_v5_aes256(vec![recipient]);
let pdf = write_pdf_from_scene_pubsec_encrypted(&scene, &cfg)?;
# Ok::<(), oxideav_pdf::PdfError>(())

PubSecRecipient also exposes from_subject_key_identifier(ski, key) for the CMS v2 form. Round 12 adds per-crypt-filter recipient listswrite_pdf_from_scene_pubsec_multi_cf + PubSecMultiCfConfig

  • PubSecCfGroup emit a doc with multiple permission sets (each its own envelope), and open_with_certificate_with_permissions surfaces the matched recipient's permission mask. Round 12 lands the CMS KARI decoder (RFC 5652 §6.2.2) — KeyAgree (ECDH/DH) recipients parse structurally. Round 14 closes the unwrap: P-256 ECDH + RFC 5753 §7.1.2 X9.63-SHA-256 KDF + RFC 3394 AES Key Wrap (128/192/256-bit) for the dhSinglePass-stdDH-sha256kdf-scheme KEA OID. Round 15 extends the curve set: P-384 (dhSinglePass-stdDH-sha384kdf-scheme, X9.63-SHA-384) and X25519 (RFC 8418 §2.1, secg-scheme …sha256kdf + id-X25519) join P-256 — pass PubSecCredential::from_parsed_ec(cert, KariCurve::P384, scalar) (or P256 / X25519) and the KARI envelope opens through the same read_pdf_to_scene_with_certificate entry point as KTRI. Round 15 also lands the writer-side KARI encode: write_pdf_from_scene_pubsec_kari(scene, &PubSecKariConfig) mirrors the round-11 KTRI writer — each KariRecipient { curve, … } becomes one CMS KARI envelope with AES-256-WRAP. Round 16 lands P-521 (dhSinglePass-stdDH-sha512kdf-scheme, X9.63-SHA-512) + RFC 8418 §2.2 HKDF binding for X25519 (dhSinglePass-stdDH-hkdf-sha256/384/512-scheme, smime-alg 19/20/21). Round 17 closes the long-term-cert originator gap: when a KARI envelope's OriginatorIdentifierOrKey is IssuerAndSerial or SubjectKeyIdentifier rather than the in-band OriginatorPublicKey, the recipient resolves the originator cert through a TrustStore — pass it via read_pdf_to_scene_with_certificate_and_trust_store(pdf, &cred, &store). Round 17 also adds read-only decode for legacy RC2-CBC (RFC 2268 + RFC 3217) and DES-EDE3-CBC (3DES, RFC 3370 §5.2) envelope content algorithms so PDF 2.0-deprecated archives still open; no encode-side support — the writer always uses AES. Round 18 surfaces previously-discarded CMS metadata: the envelope's OriginatorInfo (RFC 5652 §10.2.1 — certs[] / crls[]) is now exposed via EnvelopedData::originator_info(), and the RecipientKeyIdentifier's OPTIONAL date (GeneralizedTime) + other (OtherKeyAttribute) fields are captured by the parser. New TrustStore::find_with_temporal_validity(ski, instant) uses the RKID date to pick the cert generation that was active when the envelope was authored — useful for long-lived archives where multiple cert generations exist for the same SKI. The Certificate parser now also extracts the validity window (notBefore / notAfter), normalising UTCTime to GeneralizedTime per RFC 5280 §4.1.2.5.1's 1950..2049 pivot for direct byte-comparison. Round 19 ships two orthogonal additions. Document-level XMP /Metadata stream end-to-end (ISO 32000-1 §14.3.2 + Adobe XMP Spec 2012): writer entry write_pdf_from_scene_with_xmp(scene, xmp_bytes) attaches the raw XMP RDF/XML packet to the catalog as a /Type /Metadata /Subtype /XML stream (no /Filter); reader accessor DocumentReader::xmp_metadata() returns Some(bytes) for documents that carry one. CMS SignedData parser scaffolding (RFC 5652 §5 — PKCS#7): pubsec::signed_data::parse_signed_data decodes id-signedData blobs into typed SignedData { digest_algorithms, encap_content, certs, crls, signer_infos } + SignerInfo (sid, digest / signature OIDs, signed / unsigned attribute lists with raw-DER values, raw signature octets).

Round 20 closes the round-19 verification deferral. New pubsec::verify::verify_signature(signer, certs, content) resolves the signer's certificate from a pool by IssuerAndSerial or SubjectKeyIdentifier, hashes the canonical (universal-SET-tag) re-encoding of signedAttrs per digestAlgorithm, and verifies the hash against signature per signatureAlgorithm (RFC 5652 §5.4 + §11.2). Hash side: SHA-1 / SHA-256 / SHA-384 / SHA-512. Signature side: RSA-PKCS#1 v1.5 (the rsaEncryption + four sha*WithRSA OIDs all map here), RSA-PSS (id-RSASSA-PSS), and ECDSA on P-256 / P-384 / P-521 (curve dispatch by the cert SPKI's named-curve OID per RFC 5480 §2.1.1.1). When signedAttrs is present, the verifier also cross-checks the messageDigest attribute against the eContent hash (RFC 5652 §11.2) — so a tampered eContent fails even when the outer signature still verifies. Detached signatures (PAdES — eContent absent) feed the document bytes through AttachedContent::External(&[u8]). Round-20 also extends x509::Certificate to capture spki_algorithm_oid + spki_algorithm_params so the verifier can route ECDSA on the named-curve OID without re-parsing the certificate.

Encryption encode (writer side)

The writer emits password-protected PDFs across the same revision range the reader handles. [oxideav_pdf::write_pdf_from_scene_encrypted] takes a [Scene] and an [encrypt::EncryptionConfig] and produces bytes that round-trip through read_pdf_to_scene_with_password:

use oxideav_pdf::encrypt::EncryptionConfig;

let cfg = EncryptionConfig::aes_256_r6(b"hunter2", b"FILE-ID-16-BYTES");
let pdf = oxideav_pdf::write_pdf_from_scene_encrypted(&scene, &cfg)?;
# Ok::<(), oxideav_pdf::PdfError>(())

Writer-side coverage matches the reader: R=2 (RC4-40), R=3 (RC4-128), R=4 (AES-128 / RC4 via CFM), R=5 (Adobe ext L3), R=6 (ISO 2.0). /O, /U, /OE, /UE, and /Perms come from the canonical algorithms (3, 4, 5 for V≤4; 8, 9, 10 for V=5); per-object key derivation is Algorithm 1 (V≤4) or the file key directly (V=5).

Cross-reference streams

Both reader and writer support the binary cross-reference stream form introduced in PDF 1.5 (ISO 32000-1 §7.5.8): a /Type /XRef stream object whose body packs each entry into /W [w1 w2 w3] big-endian fields, Flate-compressed with /Predictor 12 (PNG-Up). The classical xref-keyword form (PDF 1.0..1.4) is also accepted on input and remains the writer's default; opt into the stream form via [oxideav_pdf::write_pdf_from_scene_xref_stream].

Object streams

Both reader and writer support PDF 1.5+ object streams (/Type /ObjStm, ISO 32000-1 §7.5.7). The reader resolves Compressed xref entries by fetching the containing object stream, parsing its (obj_num offset) header, and returning the body bytes from the matching slot. The writer packs every compressible indirect object (every dict that isn't a stream and isn't the Catalog) into one ObjStm container — opt in via [oxideav_pdf::write_pdf_from_scene_object_stream]. Stream objects (content streams, image XObjects, the xref stream itself) cannot be compressed per §7.5.7 and remain at their own byte offsets.

Incremental updates

[oxideav_pdf::write_pdf_incremental_update] appends new revisions to a previously-written PDF per ISO 32000-1 §7.5.6 — the new revision's body is appended verbatim, followed by a new xref subsection that lists only the changed slots, plus a trailer carrying /Prev <prev_xref_off> pointing back at the original revision. The reader follows the /Prev chain and merges entries: the newest revision wins on overlap.

let original = oxideav_pdf::write_pdf_from_scene(&scene_v1)?;
// ... time passes; user adds two pages ...
let updated = oxideav_pdf::write_pdf_incremental_update(&original, &new_pages)?;
// `updated` starts with `original` byte-for-byte, then appends.

Per-stream /Crypt /Identity opt-out

ISO 32000-1 §7.6.5 lets a single stream opt out of per-object encryption by listing /Crypt as its first /Filter with /DecodeParms /Name /Identity (or no /Name — the default per §7.4.10 Table 24). The writer leaves such streams untouched while encrypting the rest of the file; the reader applies the same rule on input. The classic consumer is XMP metadata streams that need to remain searchable in encrypted PDFs.

Linearization (Fast Web View)

Round 9 emits Linearized PDF per ISO 32000-1 §7.5.6 + Annex F. [write_pdf_from_scene_linearized] produces a PDF whose first 1024 bytes carry a complete linearization parameter dictionary (/Linearized 1 + /L + /H + /O + /E + /N + /T); the on-wire layout follows F.3.1 (header → lin-dict → first-page xref → catalog → hint stream → first-page section → remaining pages → main xref). startxref at EOF points at the first-page xref; the first-page trailer's /Prev points at the main xref. The output is also a valid plain PDF — readers ignoring /Linearized walk the same Catalog + Pages tree + page content.

The hint stream emits the page offset table (F.4.1) with full per-page entries (round 13: items 1, 2, 6, 7 — object count, page length, content stream offset relative to page start, content stream length) at fixed 32-bit width, plus minimal shared-object (F.4.2), thumbnail (F.4.3), and outline (F.4.4) header sections. Entry counts for the latter three are zero so no per-shared-object / per-thumbnail / per-outline bytes are generated. The hint dict carries /S, /T, /O offsets into the decoded hint stream so a reader walking the optional tables sees a fully-formed (if empty) layout. Extended generic (F.4.5) and embedded-file-stream (F.4.6) tables are still deferred — we generate no interactive forms / structure trees / embedded files.

Deferred

  • Text (waiting on Node::Text; will use Type 0 fonts with a CIDFont built via oxideav-ttf/oxideav-otf).
  • JPEG passthrough on ImageRef (DCTDecode XObject) — needs core IR support for "raw codec bytes" alongside the decoded VideoFrame.
  • Extended generic hint tables (F.4.5) and embedded-file-stream hint tables (F.4.6) for linearized output — we generate no interactive forms / structure trees / embedded files, so the per-table content would be empty anyway.
  • CMS KARI for P-521 (dhSinglePass-stdDH-sha512kdf-scheme) — round 14 covers P-256, round 15 extends to P-384 + X25519; P-521 is unblocked by the same mechanism (add sha2::Sha512 to the KariCurve enum + a p521 dep).
  • CMS KARI HKDF binding (RFC 8418 §2.2 — smime-alg 19/20/21 OIDs for X25519/X448 with HKDF) — round 15 ships the X9.63 binding only.
  • CMS KARI X448 (RFC 8418 §2 — needs an X448 ECDH crate; the dalek ecosystem doesn't ship one, so this likely waits on a third-party crate or hand-rolled curve arithmetic).
  • CMS KARI long-term originator certificates — OriginatorId::IssuerAndSerial / SubjectKeyIdentifier resolution against a recipient-supplied trust store; current code requires the originator's public key in-band (the only form Adobe ever emits).
  • Transparency groups beyond a per-Group /ca+/CA opacity.

Usage

[dependencies]
oxideav-core = "0.1"
oxideav-pdf  = "0.0"
use oxideav_core::{
    FillRule, Group, Node, Paint, Path, PathNode, Point, Rgba, VectorFrame,
};
use oxideav_core::TimeBase;

let mut p = Path::new();
p.move_to(Point::new(10.0, 10.0))
    .line_to(Point::new(110.0, 10.0))
    .line_to(Point::new(110.0, 60.0))
    .line_to(Point::new(10.0, 60.0))
    .close();

let frame = VectorFrame {
    width: 200.0,
    height: 100.0,
    view_box: None,
    root: Group {
        children: vec![Node::Path(PathNode {
            path: p,
            fill: Some(Paint::Solid(Rgba::opaque(0xFF, 0x80, 0x00))),
            stroke: None,
            fill_rule: FillRule::NonZero,
        })],
        ..Group::default()
    },
    pts: None,
    time_base: TimeBase::new(1, 1),
};

let pdf = oxideav_pdf::write_pdf(&frame).expect("vector → PDF");
std::fs::write("out.pdf", pdf).unwrap();
# Ok::<(), Box<dyn std::error::Error>>(())

License

MIT — see LICENSE.