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 the2/3 * (control - endpoint)trick),ArcTo(flattened to cubic per SVG 1.1 Appendix F.6.5),Close(h). - Fills:
Paint::Solid(DeviceRGBsc),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::transformemits onecmoperator. - Groups:
q ... Qsave/restore brackets around children. Group opacity becomes anExtGStateresource referenced via/GSx gs. - Clip paths: emitted before the children's content stream as
W n(orW* nfor even-odd fill rule). - Fill rules:
NonZero(f/B) vs.EvenOdd(f*/B*). - Embedded raster:
ImageRefwhose underlyingVideoFrameis RGBA8 lands as a FlateDecodeImageXObject and is painted withDo.
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(AESV2vsV2). - 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/Permsblock 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 = read?;
// Default API tries the empty user password.
match read_pdf_to_scene
# Ok::
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 viaCFM(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 ;
let cert_der = read?;
let pkcs8_der = read?;
let credential = from_der?;
let scene = read_pdf_to_scene_with_certificate?;
# Ok::
Round 11 lands the symmetric encoder side: the writer emits public-key-encrypted PDFs that round-trip through the reader.
use ;
// One recipient — IssuerAndSerial form.
let recipient = from_issuer_and_serial;
let cfg = pkcs7_s5_v5_aes256;
let pdf = write_pdf_from_scene_pubsec_encrypted?;
# Ok::
PubSecRecipient also exposes from_subject_key_identifier(ski, key)
for the CMS v2 form. Round 12 adds per-crypt-filter recipient
lists — write_pdf_from_scene_pubsec_multi_cf + PubSecMultiCfConfig
PubSecCfGroupemit a doc with multiple permission sets (each its own envelope), andopen_with_certificate_with_permissionssurfaces 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 thedhSinglePass-stdDH-sha256kdf-schemeKEA 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 — passPubSecCredential::from_parsed_ec(cert, KariCurve::P384, scalar)(orP256/X25519) and the KARI envelope opens through the sameread_pdf_to_scene_with_certificateentry 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 — eachKariRecipient { 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 24 closes the RFC 8418 curve set with X448 (RFC 7748 §5 / RFC 8410 §3 —id-X4481.3.101.111, 56-byte raw u-coordinate, 224-bit security level): passKariCurve::X448and the same writer + reader entry points handle it. Default KDF is X9.63-SHA-512 (security-strength match); HKDF SHA-256/384/512 are also valid via theKariRecipient::x448_hkdf_*constructors. Cross-checked against the RFC 7748 §6.2 Alice/Bob shared-secret vector byte-for-byte. Round 17 closes the long-term-cert originator gap: when a KARI envelope'sOriginatorIdentifierOrKeyisIssuerAndSerialorSubjectKeyIdentifierrather than the in-bandOriginatorPublicKey, the recipient resolves the originator cert through aTrustStore— pass it viaread_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'sOriginatorInfo(RFC 5652 §10.2.1 —certs[]/crls[]) is now exposed viaEnvelopedData::originator_info(), and theRecipientKeyIdentifier's OPTIONALdate(GeneralizedTime) +other(OtherKeyAttribute) fields are captured by the parser. NewTrustStore::find_with_temporal_validity(ski, instant)uses the RKIDdateto 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. TheCertificateparser now also extracts thevaliditywindow (notBefore / notAfter), normalisingUTCTimetoGeneralizedTimeper RFC 5280 §4.1.2.5.1's 1950..2049 pivot for direct byte-comparison. Round 19 ships two orthogonal additions. Document-level XMP/Metadatastream end-to-end (ISO 32000-1 §14.3.2 + Adobe XMP Spec 2012): writer entrywrite_pdf_from_scene_with_xmp(scene, xmp_bytes)attaches the raw XMP RDF/XML packet to the catalog as a/Type /Metadata /Subtype /XMLstream (no/Filter); reader accessorDocumentReader::xmp_metadata()returnsSome(bytes)for documents that carry one. CMSSignedDataparser scaffolding (RFC 5652 §5 — PKCS#7):pubsec::signed_data::parse_signed_datadecodesid-signedDatablobs into typedSignedData { digest_algorithms, encap_content, certs, crls, signer_infos }+SignerInfo(sid, digest / signature OIDs, signed / unsigned attribute lists with raw-DER values, rawsignatureoctets).
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.
Round 21 closes the reader half of the round-20 follow-up list:
PDF /Sig annotation reader (ISO 32000-1 §12.7.4.5 + §12.8.1).
DocumentReader::signatures() walks the catalog → /AcroForm /Fields
tree (honouring /FT inheritance through non-terminal /Kids
parents per §12.7.3.1) and surfaces one [PdfSignature] per /V
signature dictionary it can parse. Each value carries the
[a, b, c, d] /ByteRange, the hex-decoded /Contents blob, the
/SubFilter (adbe.pkcs7.detached / ETSI.CAdES.detached etc.),
the optional metadata fields (/Name, /Reason, /Location,
/ContactInfo, /M), and — for the CMS-detached SubFilters — the
parsed [pubsec::signed_data::SignedData]. PdfSignature::signed_message(pdf)
concatenates the two /ByteRange-named slices into the byte string
the signing tool hashed; pass it as AttachedContent::External(...)
to the existing [pubsec::verify::verify_signature] for a full
end-to-end verify.
use DocumentReader;
use ;
use parse_certificate;
let mut r = open?;
for sig in r.signatures?
# Ok::
The reader is tolerant of unsigned slots (a Sig form field whose /V
is absent — common for "approval line still pending" templates), of
non-terminal parent fields without their own /V, and of malformed
/Contents blobs (the dict surfaces but signed_data is None).
Round 30 closes the symmetric writer half: the new
oxideav_pdf::sig module emits signed PDFs with valid /ByteRange
- PKCS#7 / CMS
SignedData/Contentsblobs (ISO 32000-1 §12.7.4.5 + §12.8.1 + §7.5.6 + RFC 5652 §5 + §5.4 + §11.2). The classic "ByteRange-placeholder fill-in" pattern is implemented end-to-end — build PDF with a fixed-width/ByteRange[?? ?? ?? ??]+ a/Contents <0…0>placeholder (8192 hex chars = 4096 raw bytes, enough for any RSA-2048 / ECDSA-P256 SHA-256 SignedData with a single signer + cert), patch/ByteRangewith the computed offsets, hash the bytes spanned by/ByteRange, wrap into a CAdES-BES-style CMSSignedDatawithsignedAttrs = { contentType, messageDigest }per RFC 5652 §11.1+§11.2, hex-encode, overwrite the placeholder. A [Signer] trait decouples the crypto: bring your ownring/rsa/p256/ HSM impl, or use the reference [RsaPkcs1v15Sha256Signer] / [EcdsaP256Sha256Signer] that wrap the in-crate deps.
use ;
let private_key = new?;
let signer = new;
let identity = from_signer_cert_der?;
let signed_pdf = sign_pdf_from_scene?;
# Ok::
Round-30 ships RSA-PKCS#1 v1.5 + SHA-256 and ECDSA-P256 + SHA-256.
RSA-PSS, ECDSA on P-384 / P-521, and Ed25519 plug in through the same
[Signer] trait without touching the writer surface. The output is
accepted by qpdf --check and verifies end-to-end against the
round-27 PKCS#7 verify dispatch.
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 EncryptionConfig;
let cfg = aes_256_r6;
let pdf = write_pdf_from_scene_encrypted?;
# Ok::
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.
Stream filters (round 104 adds the /Predictor post-filter)
decode_stream recovers a stream's raw payload by applying its
/Filter (single Name or Array chain, §7.4.1). The generic
decompression filters are all handled in array order, so chains like
[/ASCII85Decode /LZWDecode] (§7.4.4 Example 2) round-trip:
/FlateDecode(§7.4.4) — zlib DEFLATE; the writer's default./LZWDecode(§7.4.4.2) — variable-width (9..=12-bit) MSB-first LZW, the TIFF flavour. Round 98 wires this throughdecode_streamplus the round-23 image-XObject and round-35 inline-image filter peels. The/EarlyChangeparameter (§7.4.4.3 Table 8) is honoured from/DecodeParms, defaulting to1(TIFF/PDF default); the KwKwK self-reference and clear-table (256) / EOD (257) codes are handled, and a truncated stream returns its partial decode./ASCII85Decode(§7.4.3),/ASCIIHexDecode(§7.4.2),/RunLengthDecode(§7.4.5) — also accepted in single + chain position, including the inline-image abbreviations (/Fl,/LZW,/A85,/AHx,/RL).
Round 104 wires the /DecodeParms /Predictor post-filter
(§7.4.4.4) into decode_stream, so a /FlateDecode or /LZWDecode
stream whose /DecodeParms carries /Predictor > 1 is un-differenced
after inflating — the same path the xref-stream walker already used,
now reaching every generic stream:
- PNG predictors (
/Predictor 10..=15, Table 10) — each row's leading algorithm tag (Table 9: None / Sub / Up / Average / Paeth) is authoritative, with the "left"/"upper-left" neighbours takenbpp = ceil(Colors * BitsPerComponent / 8)bytes back. - TIFF Predictor 2 (
/Predictor 2) — per-component left differencing, with sub-byte/BitsPerComponent(1 / 2 / 4) unpacked, summed modulo2^bpc, and repacked; 8- and 16-bit components run byte/word-wise.
/Colors, /BitsPerComponent, and /Columns are read from the same
parameter dict (Table 8 defaults 1 / 8 / 1). /Predictor 1 (or no
/DecodeParms) is a no-op passthrough.
Terminal image-codec filters (/DCTDecode, /JPXDecode,
/JBIG2Decode, /CCITTFaxDecode) are not decoded here — they keep
routing to the dedicated image walkers that hand the opaque payload to
a codec crate.
Validated against ISO 32000-1:2008 §7.4.4.2 Example 2's packed vector
(80 0B 60 50 22 0C 0C 85 01 → 45 45 45 45 45 65 45 45 45 66), plus
PNG (Sub / Up / Average / Paeth) and TIFF-2 (8-bit, RGB-interleaved,
4-bit) predictor round-trips.
Indirect stream /Length (round 91)
The reader resolves stream-object /Length entries that are
indirect references rather than direct integers, per ISO 32000-1
§7.3.10 Example 3:
7 0 obj
<< /Length 8 0 R >>
stream
BT /F1 12 Tf 72 712 Td ( ... ) Tj ET
endstream
endobj
8 0 obj
77
endobj
This shape is what every one-pass PDF writer produces — the encoder
doesn't know the compressed body length until after deflating it, so
the dict carries a forward reference to an integer object written
after the stream. Real-world spec PDFs (e.g.
docs/video/mpeg1/ISO_IEC_11172-2-MPEG1-Video-1993.pdf) use this on
every content stream. Before round 91 the reader rejected them
outright; now it consults the xref table, fetches the
length-carrying integer, and patches the resolved direct value into
the stream dictionary so downstream consumers (decode_stream,
encryption length tracking) never see the stale Reference.
The resolver is exposed at the parser level as
Parser::parse_indirect_with_length_resolver(&mut dyn LengthResolver)
— callers that already have an xref table provide a closure,
callers that don't (the xref-stream parser itself, before any xref
has been built) pass NoLengthResolver and indirect /Length is
rejected per §7.5.8's effective direct-integer requirement.
Compressed-target lookups (length integer stored inside an ObjStm)
surface a clear error rather than mis-resolving; not yet seen in the
wild.
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 = write_pdf_from_scene?;
// ... time passes; user adds two pages ...
let updated = write_pdf_incremental_update?;
// `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.
Text extraction (round 22)
[DocumentReader::text_extraction] walks every page's content
stream and emits one [TextRun] per Tj / TJ / ' / " operator,
with the text-matrix origin and Tf font + size resolved per ISO
32000-1 §9.4.4. Encoded glyphs are mapped back to Unicode through
the font's /ToUnicode CMap when present (parsing the bfchar /
bfrange blocks defined in §9.10.3 + Adobe Tech Note #5014); for
Identity-H Type 0 fonts without /ToUnicode the walker falls back
to interpreting each 2-byte CID as a BMP code point. Simple fonts
honour /Encoding /WinAnsiEncoding and /Encoding /MacRomanEncoding
(Annex D.2), with a Latin-1 fallback for everything else.
use DocumentReader;
let pdf = read?;
let mut reader = open?;
let extraction = reader.text_extraction?;
for run in &extraction.runs
println!;
# Ok::
Runs come out in stream order — the rendering order the page would have laid down. Reading-order reconstruction (column / paragraph segmentation) is a future-round followup; round 22 gives the raw runs plus matrix positions so a downstream layout pass can do its own segmentation.
JPEG passthrough on Image XObjects (round 23)
[DocumentReader::image_xobjects] walks every page's
/Resources /XObject subdict and surfaces every Image XObject whose
final filter is /DCTDecode (ISO 32000-1 §7.4.8). The returned
[PdfImageXObject] carries the unmodified JPEG bytes — the exact
JPEG-1 / JFIF stream a JPEG decoder needs — plus the dictionary's
/Width, /Height, /ColorSpace (mapped to the [ColorSpace] tag:
DeviceRGB / DeviceCMYK / DeviceGray / Indexed / Other), and
/BitsPerComponent. Wrapping /ASCII85Decode / /ASCIIHexDecode /
/FlateDecode filters preceding /DCTDecode are unwrapped before
the JPEG payload is returned, so callers always get a self-contained
JPEG stream (the standard pdfimages -all shape).
use DocumentReader;
let pdf = read?;
let mut reader = open?;
for in reader.image_xobjects?
# Ok::
The same XObject referenced from multiple pages is returned once
(deduplicated by ObjectId). Image XObjects with non-DCTDecode
filters (FlateDecode-only raster XObjects, JBIG2Decode, JPXDecode,
CCITTFaxDecode) are silently skipped — the round-23 walker is
JPEG-only. Cross-checked against pdfimages -all (poppler-utils):
the bytes are byte-identical.
Inline-image extraction (round 35)
[DocumentReader::inline_images] walks every page's content stream and
surfaces every BI … ID … EI triplet (ISO 32000-1 §8.9.7) as a
[PdfInlineImage] — the content-stream-level counterpart of the
round-23 Image XObject walker. Both abbreviated (Table 93 — /W,
/H, /CS /RGB, /F /DCT) and long-form (/Width, /ColorSpace /DeviceRGB, /Filter /DCTDecode) keys are accepted on input.
Filter coverage mirrors the round-23 XObject walker: wrapping /A85,
/AHx, /Fl, /RL are peeled before the payload reaches the
caller; terminal codec filters (/DCT, /JPX, /JBIG2, /CCF) are
left in place and surface as an [InlineImageFilter] tag so a
downstream JPEG / JPEG2000 / JBIG2 / CCITT-Fax decoder can take
over.
The /IM true image-mask flag is preserved (1-bit stencil that takes
its colour from the current path-paint state); source_page_index
and source_page_obj are filled in so callers can locate where in
the document the inline image was painted.
use ;
let pdf = read?;
let mut reader = open?;
for img in reader.inline_images?
# Ok::
§8.9.7 framing detail: the EI terminator must be preceded by a
whitespace byte and followed by whitespace or EOF — embedded EI
sequences inside the payload (with no surrounding whitespace) are
preserved as data, matching pdfimages -all's extraction behaviour.
Optional Content / OCG layers (round 95)
[DocumentReader::optional_content] walks the catalog's
/OCProperties entry and surfaces every Optional Content Group +
configuration (ISO 32000-1 §8.11 + §7.7.2 Table 28). PDFs with
toggleable "layers" — CAD drawings, multi-language alternates,
watermark / content separations — store one [OptionalContentGroup]
per /Type /OCG indirect object, with /Name UI label, optional
/Intent (View / Design), and optional /Usage filters
(language / zoom / print / view / export / page-element).
The configuration dictionary's /BaseState (ON / OFF /
Unchanged) + /ON + /OFF arrays apply per §8.11.4.5 algorithm
steps (a)+(b)+(c), giving each group a resolved boolean state.
OptionalContent::is_visible(group_id) is the lookup;
states_for_config(&alt) re-resolves under any of the /Configs
alternate configurations.
use DocumentReader;
let mut r = open?;
if let Some = r.optional_content?
# Ok::
Optional Content Membership Dictionaries (OCMDs, Table 99) are also
covered — parse_membership(reader, dict) decodes the /OCGs
reference list, the /P policy (AllOn / AnyOn / AnyOff /
AllOff), and the /VE visibility expression (PDF 1.6 — [/And …]
/ [/Or …] / [/Not e], recursively nested). OptionalContent::evaluate_membership(&mem)
plugs an OCMD into the current state map and returns the boolean
visibility per §8.11.2.2's NOTE 2 (when /VE is present, the
expression wins over /P). The configuration's /Order array
parses into a tree of [OcOrderItem::Group] leaves and
[OcOrderItem::Subtree { label, items }] nodes — both the labelled-
collection form ([(Frog Anatomy) g1 g2]) and the sublayer-nesting
form ([g1 [g2 g3]]).
Action enumeration (round 36)
[DocumentReader::actions] walks every place an action can hide in
a PDF and surfaces each as a [PdfAction] — the audit-grade
counterpart to the round-25 link reader (links only) and the round-26
annotation reader (annotations only). Sources walked (ISO 32000-1
§12.6):
- Catalog
/OpenAction(§7.7.2 Table 28) — fires on document open. Action-dict form lands; destination-array form is purely navigation and is skipped. - Catalog
/AAadditional actions (§12.6.3 Table 197) —WC,WS,DS,WP,DP. - Page
/AA(§12.6.3 Table 196) —O(page open),C(page close). - Annotation
/A+/AA(§12.5.3 Table 165) —E/X/D/U/Fo/Bl/PO/PC/PV/PIplus the primary/A. - Form-field
/A+/AA(§12.7.4 Table 220 + Table 196 eventsK/F/V/C) walked through the/AcroForm /Fieldstree, with/Kidsrecursion bounded at depth 32. - Catalog
/Names /JavaScriptname tree (§7.7.4 Table 31 + §7.9.6) — every JavaScript function the document defines.
Each action's /Next chain (§12.6.3) is followed recursively up to
depth 32, with indirect-reference dedup to break malformed cycles.
The carrier action and every chained-/Next action surface as their
own [PdfAction] with progressively-higher chain_depth.
Per-type payload decodes the high-signal entries Table 198 calls out:
/URI(§12.6.4.7 Table 206) — URI text +/IsMap./JavaScript(§12.6.4.16 Table 217) —/JSis decoded from literal-string / hex-string / stream form, recognising UTF-8 BOM (EF BB BF), UTF-16BE BOM (FE FF), UTF-16LE BOM (FF FE), or PDFDocEncoding fallback./Launch(§12.6.4.5 Table 202) —/Ffilename +/NewWindow./GoToR(§12.6.4.3 Table 200) //GoToE(§12.6.4.4 Table 201) —/Ffilespec + raw/Ddestination./SubmitForm(§12.7.5.2 Tables 236+237) —/FURL +/Flagsbitfield (Include/Exclude / IncludeNoValueFields / ExportFormat / GetMethod / SubmitCoordinates / XFDF …)./ResetForm(§12.7.5.3 Table 239),/ImportData(§12.7.5.4 Table 240),/Hide(§12.6.4.10 Table 209),/Named(§12.6.4.11 Table 211),/SetOCGState(§12.6.4.12 Table 212 — On/Off/Toggle counts),/GoTo(§12.6.4.2 — page index resolved when/Dis an explicit array).- The remaining Table 198 types (
/Thread,/Sound,/Movie,/Rendition,/Trans,/GoTo3DView) surface as their unit variants; unknown/Svalues fall through toActionKind::Other { kind }with the raw name preserved.
use ;
let mut r = open?;
for action in r.actions?
# Ok::
The walker is tolerant of malformed action dicts (skipped silently),
of /Next chains that loop back on themselves (the indirect-ref
visited-set cuts the loop), and of action types this round doesn't
decode (ActionKind::Other preserves the raw /S name so callers
walking a forensic / unknown PDF still get a complete enumeration).
Annotations beyond Link + XMP packet fields (round 26)
[DocumentReader::annotations] walks every page's /Annots array and
surfaces every entry as a [PdfAnnotation] (ISO 32000-1 §12.5.6
Tables 169..209). Per-subtype payload covers /Text (sticky notes —
/Open, /Name icon, /State, /StateModel), /FreeText (/DA,
/Q quadding, /RC, /IT intent), /Stamp (icon name), the four
text-markup variants /Highlight / /Underline / /Squiggly /
/StrikeOut (/QuadPoints), /Square + /Circle (/IC, /RD),
/Link (re-uses the round-25 go-to / URI decoder), and /Widget
(/FT, /T, /V). Unknown subtypes (Movie, Sound, 3D, RichMedia,
…) surface as AnnotationKind::Other { subtype }. Common Table 164
fields (/Rect, /Contents, /NM, /M, /F, /C, /Border) are
decoded for every subtype.
use ;
let mut r = open?;
for a in r.annotations?
# Ok::
[DocumentReader::xmp_packet] parses the document-level XMP packet
round-19 surfaces into a structured [XmpPacket] (ISO 32000-1
§14.3.2 + Adobe XMP Spec 2012 / ISO 16684-1 / ISO 19005-1..3 §6.x).
Covers the most-used Dublin Core (dc:title through rdf:Alt,
dc:creator through rdf:Seq, dc:subject rdf:Bag, dc:rights,
dc:format), XMP Basic (xmp:CreateDate / xmp:ModifyDate /
xmp:MetadataDate / xmp:CreatorTool), PDF schema (pdf:Producer /
pdf:Keywords / pdf:PDFVersion / pdf:Trapped), and PDF/A
identification (pdfaid:part / pdfaid:conformance) fields. Element
and attribute forms both recognised; XML entities (& / < /
> / " / ') plus numeric character references
decode. XmpPacket::is_pdf_a() + pdf_a_conformance() collapse the
pair into a 1B-style PDF/A conformance designator.
let mut r = open?;
if let Some = r.xmp_packet?
# Ok::
Simple-font /Encoding /Differences resolver (round 28)
Simple Type 1 / TrueType / Type 3 fonts may carry their /Encoding as
a dictionary that overlays a /Differences array on top of a named
/BaseEncoding (ISO 32000-1 §9.6.6.1). The reader resolves this
properly: the array's flat [N name1 name2 … M nameK …] form is
parsed (numeric tokens reset the running code; names land at
consecutive slots), and each glyph name maps to its Unicode scalar
through the Adobe Glyph List (subset staged under
docs/document/pdf/agl/subset.txt, ~320 glyph names). The resolver
plugs into the [DocumentReader::text_extraction] path so a
/Differences-using font decodes correctly to Unicode.
use ;
// Imagine an inline encoding dict resolved from a PDF font:
// /Encoding << /BaseEncoding /WinAnsiEncoding
// /Differences [24 /breve /caron /circumflex] >>
let diffs = parse_encoding_differences?;
let base = from_base;
let map = apply_encoding_differences;
assert_eq!; // breve
# Ok::
Unknown glyph names emit U+FFFD as a marker (matching what
pdftotext --raw does for un-resolvable glyphs). Multi-character
glyph expansions (/fi → "fi", /fl → "fl") are accommodated. Six
base encodings are recognised: WinAnsi / MacRoman / MacExpert /
Standard / Symbol / ZapfDingbats. Full AGL coverage (CJK,
Cyrillic, Devanagari) is round-29+.
Reading-order layout pass (round 29)
[DocumentReader::read_in_logical_order] walks the catalog's
/StructTreeRoot /K tree and emits text runs in author-intended
reading order rather than the painter's raster order (ISO 32000-1
§14.6 + §14.7 + §14.8 — Tagged PDF). For a 2-column document, naive
raster extraction interleaves column 1's first row, column 2's first
row, column 1's second row, …; the round-29 pass walks [Sect_col1, Sect_col2] and emits all of column 1 before any of column 2. The
walker handles every leaf shape ISO 32000-1 §14.7.4.4 defines:
bare-integer MCID kids (resolve against the ancestor's inheritable
/Pg), <</Type /MCR /Pg p /MCID m>> marked-content references with
their own /Pg overrides (cross-page tables), <</Type /OBJR …>>
object references (skipped — they reference annotations, not text),
and nested /StructElem kids which recurse with a 64-deep cycle
guard.
use ;
let mut r = open?;
let result = r.read_in_logical_order?;
match result.mode
for run in &result.runs
# Ok::
Documents without a /StructTreeRoot (or with a malformed / empty
tree) fall back to the existing raster-order extraction with
LayoutMode::Raster set on the return so callers can branch. The
pass also exposes extract_text_marked(reader) which emits every
text run alongside the marked-content /MCID it was painted under
(for callers that want to assemble a custom logical order outside the
StructTreeRoot — e.g. PDF/UA accessibility audits).
AcroForm interactive-widget writer (round 31)
[write_pdf_with_form] is the writer-side counterpart of the
round-26 AnnotationKind::Widget reader. Given a Scene in pages
mode plus a slice of FormField specs it emits a PDF whose Catalog
carries /AcroForm and whose page /Annots arrays carry the matching
/Subtype /Widget annotations (ISO 32000-1 §12.7).
All four canonical field types per §12.7.4 land:
- Text (
/FT /Tx) —FormFieldTextwith optional default value,/MaxLen,/Qjustification (left/centre/right per Table 222), and/Ffbit 12 (multi-line) per Table 228. - Checkbox (
/FT /Btn) —FormFieldCheckboxkeyed by/Yesand/Offappearance states per Table 228./V,/DV, and/ASstay consistent. - Radio group (
/FT /Btnwith Radio + NoToggleToOff flags) —FormFieldRadioGroupbecomes one aggregate field with/Kidsreferring to one widget per option; the selected option's/AScarries its export-value Name, others carry/Off. - Choice (
/FT /Ch) —FormFieldChoicewith/Optarray and optional/V./Ffbit 18 selects combo-box vs. list-box. - Signature (
/FT /Sig) —FormFieldSignaturewraps aBox<dyn Signer>+SignerIdentityand re-uses the round-30/Contentsplaceholder pattern. Only one signature field per call.
use ;
let fields = vec!;
let pdf = write_pdf_with_form?;
# Ok::
The AcroForm dict gets /DA "(/Helv 12 Tf 0 g)" per §12.7.3.3 (the
caller can override per field), /NeedAppearances true so viewers
regenerate /AP at open time, and /SigFlags 3 when a signature
field is present. qpdf --check accepts the output; the round-26
reader round-trips field_type / field_name / value for every
widget.
General annotations writer (round 32)
[write_pdf_with_annotations] is the symmetric writer side of the
round-26 generic annotation reader. Where round 25 emitted only
/Subtype /Link and round 31 emitted /Subtype /Widget, round 32
covers the rest of the §12.5.6 subtype taxonomy that authoring tools
produce in the wild: Text, Link, FreeText, Highlight, Underline,
Squiggly, StrikeOut, Stamp, Square, Circle, and Ink.
Five most-common interactive PDF subtypes (Text/Link/FreeText/
Highlight/Stamp) plus three markup ones (Square/Circle/Ink) are
all wired into a single Annotation struct + WriterAnnotationKind
enum, with cross-subtype Table 164 fields (/T author, /M
modified-date, /F flags, /C colour, /Border) hanging off the
struct itself:
use ;
let annots = vec!;
let pdf = write_pdf_with_annotations?;
# Ok::
Highlight/Underline/Squiggly/StrikeOut take a
Vec<[f32; 8]> of quads (lowered to the spec's 8N-real
/QuadPoints array); Ink takes a Vec<Vec<f32>> of strokes
(each [x0, y0, x1, y1, …]). qpdf --check accepts the output;
the round-26 reader round-trips every subtype.
Embedded file attachments (round 33)
write_pdf_with_attachments(scene, &[Attachment]) embeds arbitrary
files inside the PDF as /Type /EmbeddedFile streams, materialises
one /Type /Filespec dictionary per attachment (ISO 32000-1 §7.11.3
Table 44 + §7.11.4 Table 45 + §3.10), registers each filespec in the
catalog's /Names → /EmbeddedFiles name tree (§7.7.4 Table 31 +
§7.9.6 Name trees), and optionally drops a /FileAttachment
annotation marker (§12.5.6.15 Table 187) on a chosen page. The
embedded-file stream body is FlateDecode-compressed when that
shrinks; otherwise stored cleartext.
use ;
let pdf = write_pdf_with_attachments?;
# Ok::
Each attachment's /F entry is the PDFDocEncoded name (literal
string for ASCII; UTF-16BE-with-BOM hex string otherwise), the /UF
entry is always UTF-16BE for full Unicode coverage (PDF 1.7+), and
/EF /F and /EF /UF both point at the same embedded-file stream.
Name-tree keys are emitted in byte-wise lexicographic order per
§7.9.6.2.
The reader-side counterpart [read_pdf_attachments] walks the same
name tree back into Vec<PdfAttachment { name, mime_type, bytes, modified }>. qpdf --check and qpdf --json both accept the
output; qpdf --json lists each embedded file by name.
Document time-stamp signatures (round 34)
add_document_timestamp(pdf, tsa) appends an RFC 3161
Document Time-Stamp revision (ISO 32000-1 §12.8.5) to an existing
(signed-or-unsigned) PDF. The new revision adds a /FT /Sig field
whose /V is a sig dictionary with /Type /DocTimeStamp +
/SubFilter /ETSI.RFC3161, and whose /Contents <…hex…> holds a
full RFC 3161 TimeStampToken (a CMS SignedData ContentInfo over
a TSTInfo SEQUENCE). The byte-range placeholder pattern of round
30 is reused, so a doc-timestamp can coexist with one or more
regular signatures in the same document — each is its own
incremental update per ISO 32000-1 §7.5.6.
use ;
let tsa = new?;
let stamped = add_document_timestamp?;
The [TsaSigner] trait is the integration seam for production TSAs
(RFC 3161 §3 HTTP transport, RFC 5816 ESSCertIDv2 — both out of
scope for round 34). The in-tree [MockTsaSigner] short-circuits the
network round-trip with a self-signed RSA-2048 / SHA-256 token —
handy for tests and for self-contained roundtrips. The reader side
surfaces timestamps separately via DocumentReader::doc_timestamps()
(or the free fn [read_pdf_doc_timestamps]). qpdf --check accepts
the output; when openssl ts -verify is on PATH, it accepts the
embedded TST.
Content-stream DeviceCMYK colour (round 115)
The content-stream parser now honours the k (fill) and K (stroke)
DeviceCMYK colour operators (ISO 32000-1 §8.6.4.4). Because the
vector IR carries only DeviceRGB, each CMYK colour is converted via
§10.3.5 ("Conversion from DeviceCMYK to DeviceRGB") — red = 1 − min(1, cyan + black) and the magenta/yellow counterparts, no black
generation or undercolour removal. Pure cyan/magenta/yellow inks
reconstruct as (0,255,255) / (255,0,255) / (255,255,0) and
0 0 0 1 k as black, where the parser previously collapsed every
CMYK colour to opaque black. Out-of-range operands are clamped to
0.0..=1.0 first (§10.3.4 NOTE 4).
Deferred
- Text emission — writer-side
BT … Tj … ETforNode::Textusing Type 0 fonts with a CIDFont built viaoxideav-ttf/oxideav-otf. The reader-side extraction surface landed in round 22 (see above). - Writer-side JPEG passthrough on
ImageRef(DCTDecode XObject) — needs core IR support for "raw codec bytes" alongside the decoded VideoFrame so the writer can emit/Filter /DCTDecodeinstead of re-encoding every JPEG to FlateDecoded raw RGBA. The reader-side surface landed in round 23 (see above). - 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.
- Ed25519 / Ed448 signature dispatch in
pubsec::verify— round 20 covers RSA-PKCS#1 v1.5 / RSA-PSS / ECDSA on P-256 / P-384 / P-521; EdDSA needs aned25519-dalek(ored448-goldilocks) dep. - Transparency groups beyond a per-
Group/ca+/CAopacity.
Usage
[]
= "0.1"
= "0.0"
use ;
use TimeBase;
let mut p = new;
p.move_to
.line_to
.line_to
.line_to
.close;
let frame = VectorFrame ;
let pdf = write_pdf.expect;
write.unwrap;
# Ok::
License
MIT — see LICENSE.