* Issue #2367: an object stream whose entry cannot place it is found by its header qpdf's xref-compressed-in-compressed.pdf marks object stream 69 as itself compressed inside stream 81, which the format forbids and which does not exist. objstm() loaded the container through its cross-reference entry only, so every object inside (the outlines, the page labels) read as null and a full save wrote `/Outlines null /PageLabels null`, while an incremental save (which copies the original bytes) kept them, and MuPDF recovers them. The container is now found by its own `N G obj` header when its entry cannot load it (scan_for, the same last resort a plain object with a wrong offset has). Regression test: document::tests::an_object_stream_marked_as_compressed_is_found_by_its_header. Verified on the fixture: objects 70 and 82 load (Count 6; 12 page-label ranges) and a full save keeps both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh The fallback is for file entries only: once the stream is freed or replaced in the overlay, its objects must not come back from the file's copy (sign's object_stream_changes_cannot_reuse_old_candidate_membership caught this after the rebase); a cos test covers both cases. * cos: a container the saved table marks free is not found by its header either Freeing an object stream, saving incrementally and reopening left the overlay empty and the table saying the stream is free; the header fallback then rescanned the file and the stream's objects came back on the next full save. The fallback now also stops at an on-disk free entry. * M1: put the #2367 test's description on the test, not its fixture --------- Co-authored-by: Brandon Thomas <echelon@gmail.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. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. - 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).