Files
pdfcraft/crates/cos
wstockandBrandon Thomas 72dac33f8e Issue #2367: an object stream whose entry cannot place it is found by its header (#2398)
* 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>
2026-10-18 03:48:23 -04:00
..

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-crypt and pdfcraft-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 denies unwrap/expect/panic!/todo!. Builds for wasm32-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 obj headers. 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; ByteSource streaming is core.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, /EFF embedded-file encryption, and /EncryptMetadata. security.rs knows what the spec exempts — the /Encrypt dictionary, 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 reserved arlington crate.
  • Image codecs. pdfcraft-filters carries the non-image filters; DCT, JPX, JBIG2 and CCITT are still decoded through the bootstrap renderer (core.image-filters, M2.6).