# craft-doc Reads PhotoCraft `.pcraft`, VectorCraft `.vectorcraft` and EffectCraft `.ecproj` documents without the apps: which app wrote a file, its format version, the previews and PDF it carries, and its pages and layers. It takes bytes and returns plain data. It does not open files, render or composite. No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. Each format's envelope is read through its crate in this repository: [`craft-pcraft-format`](../pcraft-format/README.md) (the `Manifest` members and checks, and `check_manifest`), [`craft-vectorcraft-format`](../vectorcraft-format/README.md) (`Image`, `sniff`, the version and the marker) and [`craft-ecproj-format`](../ecproj-format/README.md) (`Head`, the schema check). The outlines are read with craft-doc's own read-only views of the documents. The other dependencies are `serde`, `serde_json`, `thiserror` and `flate2` (its pure-Rust backend). ```toml craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "" } ``` ## Use ```rust use craft_doc::{Kind, Limits, RenditionKind}; let limits = Limits::default(); match craft_doc::sniff(&bytes) { Some(Kind::VectorCraft) => { let pdf = craft_doc::renditions(&bytes, &limits)?.into_iter().find(|r| r.kind == RenditionKind::Pdf); } Some(kind) => { /* ... */ } None => { /* not a Craft document */ } } let outline = craft_doc::outline(&bytes, &limits)?; ``` - `sniff(head) -> Option` reads the first bytes. The first 64 KiB are enough for every file the apps write. A re-zipped `.pcraft` whose `manifest.json` entry starts after the head is recognized only when `head` holds the whole file. When `head` holds a whole ZIP archive, the reader checks its central-directory names when the archive opens. Otherwise it checks its local headers. A VectorCraft file is recognized by its top-level `format` member, as `craft_vectorcraft_format::sniff` recognizes it, so a file written as a JSON array, which `File::read` also reads, is not. - `info(bytes, &Limits) -> Result`: the kind, the version the file declares, the writer it names, and `supported`, which is `false` when the version is newer than the crate reads. - `renditions(bytes, &Limits) -> Result, Error>`: each `Rendition` has a `kind` (`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`. `is_full_composite` is true only for a PhotoCraft composite selected from an explicit `full_composite` declaration; it is false for the legacy preview and every other rendition. It records the selected source, without decoding pixels or checking canvas dimensions. Derived equality and debug output include this provenance. - `rendition(bytes, RenditionKind, &Limits) -> Result, Error>`: the requested kind only, or `None` when absent. Container metadata and JSON limits are still checked. Other rendition payloads are not decoded or copied: a damaged preview does not prevent reading the PDF, and a missing thumbnail ZIP entry does not prevent reading the composite. A compressed member's offset still counts against `max_total`, including bytes traversed to reach it. Normal gzip reads remain buffered. If read-ahead reaches a damaged appended member, the metadata read retries with exact compressed input and output through the JSON marker; already-counted prefix bytes are replayed without retaining or charging another copy. JSON and selected-payload limits still apply, and requesting the damaged member still fails. - `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. - `pcraft_document::(bytes, &Limits) -> Result`: a PhotoCraft manifest's `document` deserialized directly into the caller's metadata type. It reads no previews, tiles or blobs and does not check that referenced payload entries exist. Envelope, version, JSON byte and nesting checks still apply. Before the caller's deserializer runs, `max_items` bounds the whole manifest's structural JSON items, including skipped fields, as the shared format's `check_manifest` counts them (opening array/object brackets and commas outside strings), capped at that format's own ceiling. The caller owns its type's allocations and behavior. No pixel feature is required. A cancellation variant can follow after the cooperative document-read APIs in [#571](https://github.com/storytold/craft-libs/pull/571) merge. - `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported`, `LimitExceeded` or `Cancelled`. A file whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. A ZIP archive whose `manifest.json` has no `format_version` gives `Unrecognized`. ### Cooperative cancellation `info_with_cancel`, `renditions_with_cancel` and `outline_with_cancel` take the same bytes and limits plus `&dyn Fn() -> bool`. The callback runs on the calling thread. Once it returns true, that call stops with `Error::Cancelled`, even if a later callback result would be false. ```rust use std::sync::atomic::{AtomicBool, Ordering}; let stopped = AtomicBool::new(false); let renditions = craft_doc::renditions_with_cancel(&bytes, &limits, &|| stopped.load(Ordering::Relaxed))?; ``` Checks occur during format detection, JSON scans and parsing, gzip input and output, rendition copies, ZIP directory and entry reads, CRC traversal, and outline conversion, including shared header, manifest and base64 helpers. The existing entry points keep their behavior and limits. PDF parsing/rendering and optional PhotoCraft layer pixel decoding are separate operations. ## What each format gives | | `.vectorcraft` | `.pcraft` | `.ecproj` | |---|---|---|---| | Container | JSON, plain or gzip; since version 4, large members after the JSON (`\n\0VCBLOBS\0`) | ZIP of STORE or DEFLATE entries | JSON | | Version read | `version` up to 4 (`"format": "vectorcraft"` or `"drawcraft"`) | `format_version` 1 | `schema` up to 2 | | Writer | `generator` | `generator` | `savedBy` | | Renditions | `preview` (Thumbnail) and `pdf` (Pdf), when the file was saved with them | the `thumbnail` and flattened composite the manifest names | none | | Pages | artboards, in points | none | compositions by item id, in pixels, with frame rate and duration | | Layers | top-level layers | the layer tree with groups | each composition's layers | Layers are listed bottom first in every format. A layer's `kind` is the name the file uses: `layer` in VectorCraft; `raster`, `group`, `adjustment`, `fill`, `text`, `shape` or `smart` in PhotoCraft; the source type in EffectCraft (`Footage`, `Comp`, `Solid`, `Text` and others). EffectCraft layers have no `opacity`, because opacity is an animated property there. A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are not read. The `.ecprojx` XML copies are not read. For PhotoCraft, `Composite` prefers `full_composite` (`composite/full.png`) when declared, otherwise `composite` (`composite/preview.png`). The unused preview payload is not read. A declared full-canvas image that is missing or exceeds the limits is an error. The reader does not silently substitute the smaller preview. Returned PNG bytes retain any embedded color profile; the reader does not decode pixels or convert colors. ## PhotoCraft layer pixels With the feature `pcraft-pixels`, which adds `ruzstd` and `blake3` (both pure Rust), `pcraft_layer_rgba(bytes, layer, &Limits)` decodes one layer of a `.pcraft` bundle over the canvas: ```toml craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", features = ["pcraft-pixels"] } ``` - `layer` is a path of indexes through `Outline::layers` and each group's `children`, bottom first: `&[1, 0]` is the bottom layer inside the second layer from the bottom. - A raster layer gives its surface; a text, shape or smart object layer gives the rendered copy PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video` member; it gives its surface, which holds the frame at the playhead, and the other frames are not read. A group, an adjustment or fill layer, a layer saved without a rendered copy, or a path that names no layer gives `Ok(None)`, and the mask of such a layer is not returned. - `LayerPixels::rgba` holds `width` × `height` RGBA pixels, row by row, in the layer's sample type: `Samples::U8`, `U16` or `F32`. Grayscale repeats the gray value in red, green and blue, and a surface without alpha is opaque. Pixels without a tile hold the surface's default pixel. Tiles outside the canvas, at negative columns and rows among them, are not read. - `LayerPixels::mask` holds the layer mask, one sample per pixel, with its `enabled`, `density` and `feather` as saved. Density and feather are not applied. A mask stored as RGB gives its first channel. - Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits; PhotoCraft stores the layers of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask stored in another color mode, such as CMYK or Lab, gives `Error::Unsupported`. A canvas of zero width or height gives `Error::Malformed`. - The decoded layer and the decoded mask each count as one entry against `max_entry`, and with each distinct tile read and decompressed against `max_total`, checked before they are allocated. A tile that several references name is read and decoded once per call. A tile is decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash that names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that declares a window larger than 8 MiB, or has bytes after its frame, is refused. Tile references in parsed surfaces, rendered caches and masks throughout the layer tree count against `max_items`, including other layers. Video frames and other unrecognized members are skipped. `tests/pixels.rs` builds bundles with tiles compressed by `ruzstd` and runs with `cargo test -p craft-doc --all-features`: 8, 16 and 32-bit tiles, the default pixel, negative tile coordinates, a mask, layers in groups and layers without pixels, text, shape and smart object copies, bitmap and indexed documents, a CMYK document, a zstd bomb, a tile shared by many references, and the limits. ## PhotoCraft ICC payload With `pcraft-pixels`, `pcraft_icc_profile(bytes, &limits)` returns the raw blob named by `document.icc_profile`. Missing or null gives `Ok(None)`; a declared missing or damaged blob is an error. The payload's BLAKE3 hash is checked, but its ICC semantics are not validated. An empty verified payload is returned as `Some`, and no pixels or colors are converted. Previews, tiles and unrelated blobs are not read. The manifest, the selected ZIP entry holding one zstd frame, and the decoded profile count against `max_total`. Each is limited by `max_entry`, and the manifest also by `max_json` and the normal PhotoCraft envelope/depth checks. Decoding admits only the remaining output budget, at most an 8 MiB window, and no bytes after the frame. Structural manifest items (objects and arrays opened, plus commas outside strings) count against `max_items` before metadata is read, including skipped fields, with PhotoCraft's structural ceiling of 4,194,304. This is different from the layer-pixel reader's counting of parsed layer and tile-reference lists. An absent profile does not bypass manifest limits. Limits apply per call; a consumer reading several layers and a profile must also bound its combined work and retained memory. This call takes no cancellation callback. It returns payload bytes rather than a parsed or validated color profile and does not promise that a renderer honors those bytes. ## Limits `Limits` (fields `max_json`, `max_entry`, `max_total`, `max_depth`) bounds each call: - `max_json` (256 MiB): the JSON read, which is the VectorCraft file's JSON, the PhotoCraft manifest or the EffectCraft project. The names and other strings returned are parts of it. - `max_entry` (256 MiB): one rendition or one ZIP entry. The size a file states is checked before any of it is copied or inflated. - `max_total` (1 GiB): the limit counts decompressed gzip bytes a call uses, bytes it reads out of ZIP entries, and bytes it returns as renditions. A rendition decompressed from gzip is counted once, and a second rendition of the same bytes is counted as another copy. The gzip reader may decode a 64 KiB chunk past the JSON or final rendition. Unused bytes in that chunk do not count. - `max_depth` (400, also the largest value it takes, `MAX_DEPTH`): the nesting of JSON arrays and objects, checked before parsing. PhotoCraft nests up to 100 groups of layers, three levels each. `renditions` and `outline` refuse a PhotoCraft manifest nested deeper than 367 levels (`craft_pcraft_format::manifest_depth(MAX_GROUP_DEPTH)`), as PhotoCraft does. A PhotoCraft manifest with more than 4,194,304 JSON items (`craft_pcraft_format::MAX_MANIFEST_ITEMS`) is refused with `Error::LimitExceeded`, as PhotoCraft reads it. On a compressed `.vectorcraft` file, `info` and `outline` read the JSON from the decompressed stream. The reader decodes chunks of up to 64 KiB, so the chunk holding the end of the JSON may also hold data after its marker. `renditions` checks each member's size against `max_entry` and the last member's ending offset plus duplicate-copy sizes against the remaining `max_total` budget before reading the tail. Beyond the buffered chunk, it decompresses only as far as the members end and keeps only their bytes. The struct is `#[non_exhaustive]`: start from the defaults and set the fields you change. ```rust let mut limits = craft_doc::Limits::default(); limits.max_entry = 32 << 20; ``` ## Testing `cargo test -p craft-doc` runs: - `tests/formats.rs`: documents of each format written by the tests (JSON by hand, PNG and ZIP bytes assembled in the test, gzip through `flate2`), read back through each call: plain and gzip VectorCraft files, members inline as base64 and after the JSON, `.pcraft` bundles with STORE and DEFLATE entries and the manifest last, `.ecproj` compositions, newer versions, and files `sniff` must not claim (PDF, PNG, other ZIP and JSON files). - `tests/hostile.rs`: every truncation of each sample, seeded mutations, proptest on arbitrary bytes, and inputs that pass each limit: a gzip bomb, a DEFLATE entry larger than its stated size, members longer than `max_entry`, JSON nested past `max_depth`, ZIP64, encryption, other compression methods and CRC mismatches. An array of a million elements in place of a `generator` or a preview is skipped. - `tests/envelopes.rs`: files whose envelope is written with the format crates' types read through each call, and envelopes those crates refuse (a manifest without `generator`, a manifest that is a JSON array, a preview that names another entry, a `format` member inside another member) are refused. A preview or PDF member without `mime` gives no rendition, and a VectorCraft `generator`, `preview` or `pdf` reads as `File::read` reads it: as `None` when it has the wrong shape, and as its last value when it is repeated. A value of the wrong shape is skipped as it is parsed, and none of it is kept. A repeated `version`, and in `renditions` a missing or repeated `document`, are refused, as `File::read` refuses them. `.github/workflows/doc.yml` runs fmt, clippy and the tests on Linux and Windows, and a `wasm32-unknown-unknown` check.