Main (#5569) decodes every image through cos's ImageBudget, which counts the JBIG2 decoder's own work and the globals it reads, through main's own restore_shared_jbig2 and image::without_globals for the images that share a redacted image's /JBIG2Globals too. This branch replaces both with globals::unshare, so the merge keeps unshare and drops them. without_dictionary now decodes through ImageBudget::decode instead of Document::decoded_image_within, which main made private. That closes the gap the PR noted: the decoder's own work (up to hayro-jbig2's 2^30 steps an image) was charged one unit of the run budget. apply passes its run's allowance, what clearing left of it, as main's restore did. Past it apply is refused with ImagesPastAllowance, as main's restore was. remove_hidden gives unshare an allowance of its own (sanitize::unshare_unmarked), and past it is refused with SharedJbig2Budget: "redact fewer pages at a time" means nothing there, and Optimize's discard runs it too. SharedJbig2Budget's text now says finding and storing again. Tests: main's allowance test runs on unshare, with ImageBudget::with_work(0) refused as ImagesPastAllowance, through unshare_unmarked as SharedJbig2Budget, and u64::MAX storing one image. Test helpers decode through ImageBudget, since Document::decoded_image is no longer public. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
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
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too
let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults
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 and CCITT fax and JBIG2 decoding (Document::decoded_image, a JBIG2 image's/JBIG2GlobalswithinMAX_JBIG2_GLOBALS, 32 MiB, their filters undone once a document for the images that share them, up to 64 MiB kept, while their segments are decoded for every image, andImageBudgetfor an operation over many images, which share one allowance of decoding, filters, decoders and pixels alike, and are refused past it:IMAGE_WORK, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at 300 dpi, plusIMAGE_WORK_PER_BYTE, 2^13 steps for each byte of the file); DCT and JPX are still decoded through the bootstrap renderer (core.image-filters, M2.6).