Acrobat reports an encrypted PDF as "damaged and could not be repaired", before asking for the password, when the document catalog sits in an object stream. Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such files, and qpdf keeps the catalog standalone too. Full saves of encrypted documents now write the catalog as its own object; unencrypted output is unchanged. Verified with Acrobat DC on macOS: the same file fails with the catalog packed and opens with it standalone, in AES-256 and RC4-128. Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
pdfcraft-cos
The PDF object layer (ISO 32000-2 §7): the COS object graph, parsed lazily and tolerantly, edited through a copy-on-write overlay, and written back incrementally or as a full rewrite.
- Layer: L1, standalone. The layering table lets it use only
pdfcraft-filters,pdfcraft-cryptandpdfcraft-geom(xtask/src/layers.rs::STANDALONE_DEPS); it currently uses the first two. That keeps the parser and the crypto independently auditable and reusable. #![forbid(unsafe_code)], and the crate deniesunwrap/expect/panic!/todo!. Builds forwasm32-unknown-unknown.
The object graph is the document model. Nothing is lifted into a tidier representation and
written back out, because that is how data gets silently dropped. Typed views live above, in
pdfcraft-model.
API
let doc = Document::open(bytes)?; // Arc<Vec<u8>>
let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files
let root = doc.root(); // Option<ObjRef> — the catalog
let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow one indirect reference
let d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
doc.set(r, Object::Dict(d)); // edit: lands in the overlay
let r = doc.add(obj); // append a new object
doc.free(r);
let bytes = write_incremental(&doc, &SaveOptions::default())?; // append a revision
let bytes = write_full(&doc, &SaveOptions::default())?; // garbage-collected rewrite
Both writers return the bytes; the caller decides where they go, which is what lets the engine produce a working file in memory after every edit and keep saving atomic.
Useful alongside: doc.repair_log() (what was reconstructed), doc.revisions() and
doc.revision_ends() (the incremental history), doc.is_edited(num) and doc.modified_objects(),
doc.permissions() and doc.security(), doc.set_encryption() / doc.remove_encryption(), and
doc.require_full_save() for edits that cannot be expressed incrementally.
Document is cheap to clone: the original bytes, the xref index and the parse caches are
shared, so a clone is a snapshot. Undo/redo and background jobs hold clones while the UI edits
another. Edits never touch the original bytes.
Reading
- Every cross-reference form: classic tables with their quirky subsections, cross-reference
streams, hybrid-reference files (
/XRefStm), and object streams. - Repair. A file whose xref is wrong or missing is reconstructed by scanning for
objheaders. Repairs are recorded, never silent, so the UI can say the file was damaged. - Tolerant, never fatal. The lexer works on a byte slice and a position; it allocates nothing
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
CosError, never a panic — this crate is the first thing a hostile file meets. - Lazy. Objects are parsed on first use and cached. (Whole-file buffering is still the case;
ByteSourcestreaming iscore.lazy-loading, not yet done.) - Encryption is handled on load and save through
pdfcraft-crypt: every standard-security revision R2–R6 (RC4, AES-128/256), crypt filters,/EFFembedded-file encryption, and/EncryptMetadata.security.rsknows what the spec exempts — the/Encryptdictionary, xref streams, the trailer/ID, and objects inside an object stream (the stream is encrypted whole).
Writing
write_incremental appends only the edited objects, a cross-reference section in the same
style as the file's last one, and a trailer with /Prev. The original bytes are never modified,
which is what keeps a signature over an earlier revision valid.
write_full writes only what the trailer can reach, renumbered densely — the Save As and
garbage-collecting path. By default (SaveOptions::object_streams) it packs non-stream objects
into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a
classic table any reader accepts.
Dictionaries keep their key order and streams keep their encoded bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized.
Errors
CosError: NotPdf, Syntax { offset, detail }, MissingObject, NotADictionary, Filter,
NeedsPassword, WrongPassword, Security, Poisoned.
Not done here
- Streaming very large files within a memory budget (
ByteSource,core.lazy-loading); files are read whole today. - Linearized output, Fast Web View (
core.linearization, M11). - Structural validation against the Arlington model (
core.arlington-validation), which will live in the reservedarlingtoncrate. - Image codecs.
pdfcraft-filterscarries the non-image filters; DCT, JPX, JBIG2 and CCITT are still decoded through the bootstrap renderer (core.image-filters, M2.6).