Files
craft-libs/crates/doc
.CooCoo fa1a657476 Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps (#404)
* Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps

A new crate, craft-doc, reads the native files of three apps from bytes:
PhotoCraft .pcraft (a ZIP bundle), VectorCraft .vectorcraft (JSON, plain
or gzip, with the version 4 data after the JSON) and EffectCraft .ecproj
(JSON).

- sniff tells which app wrote a file from its first bytes.
- info gives the format version, the writer the file names, and whether
  the version is one the crate reads.
- renditions returns VectorCraft's preview and PDF and PhotoCraft's
  thumbnail and composite, when the file has them.
- outline returns PhotoCraft's canvas and layer tree, VectorCraft's
  artboards and top-level layers, and EffectCraft's compositions with
  their layers.

A file whose version is newer than the crate reads is refused with
Error::TooNew. Limits bound the JSON, each entry, the total bytes
decompressed and the JSON nesting, and each is checked before the bytes
it covers are allocated. ZIP entries are read by a small STORE and
DEFLATE reader in the crate. The crate depends on serde, serde_json,
thiserror and flate2 and builds for wasm32-unknown-unknown.

The tests build every document they read: JSON written by hand, PNG,
ZIP and gzip bytes assembled in the test. doc.yml runs fmt, clippy, the
tests and a wasm32 check.

* ci: do not keep the checkout token in the doc workflow

The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.

* craft-doc: read each format's envelope through its format crate

craft-doc no longer keeps its own copies of the envelope types and
constants. It reads them with craft-pcraft-format (`MANIFEST`,
`FORMAT_VERSION`, `THUMB`, `COMPOSITE`, `check_manifest`),
craft-vectorcraft-format (`Head`, `Image`, `sniff`, `check`, `VERSION`,
`BLOB_MAGIC`, base64) and craft-ecproj-format (`Head::read`,
`SCHEMA_VERSION`). Its own base64 module is removed. A `.pcraft`
manifest is still read in one pass without building a
`serde_json::Value`, with the checks `read_manifest` applies. The
outlines are still read with craft-doc's read-only views of each
document, and the public API (`sniff`, `info`, `renditions`, `outline`,
`Limits`) does not change.

These inputs are now read as the format crates read them:

- A `.pcraft` manifest without `generator`, or with a `thumbnail` other
  than `thumb.png` or a `composite` other than `composite/preview.png`,
  is malformed for `renditions` and `outline`; `info` still reports its
  version. A manifest with more than 4,194,304 JSON items is refused
  with `LimitExceeded`.
- A `.vectorcraft` preview or PDF member without `mime` gives no
  rendition.
- `sniff` takes `format` only from a top-level member, so
  `{"meta":{"format":"vectorcraft"}}` is not a VectorCraft file.

tests/envelopes.rs writes a file of each format with the crates' types
and reads it through every call, and checks the inputs above.
doc.yml also runs when one of the three crates changes.

* craft-doc: sniff reads only top-level members of an EffectCraft project

`sniff` took any JSON object whose first member is `savedBy` for an
EffectCraft project when `"schema"` appeared anywhere in its first
bytes, so `{"savedBy":"1.0","metadata":{"schema":1}}` was claimed. It
now checks the top-level member names only, one at a time, without
keeping them.

* craft-doc: bound list elements with max_items; no rendition without a MIME type

Limits gains max_items (default 1,048,576): EffectCraft project items and
composition layers, VectorCraft artboards and layers, and PhotoCraft layers
at every level are counted before each one is read, and a file that lists
more gives Error::LimitExceeded. A list of empty objects within max_json no
longer expands into gigabytes of layers.

A VectorCraft preview or pdf member with an empty mime gives no rendition, as
one without a mime does; a preview may be PNG or JPEG. An EffectCraft
composition without a duration or a frame rate gets no timing instead of a
duration of 0.
2026-11-02 16:29:51 -05:00
..

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 (the Manifest members and checks, and check_manifest), craft-vectorcraft-format (Head, Image, sniff, the version and the marker) and craft-ecproj-format (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).

craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }

Use

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<Kind> 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.
  • info(bytes, &Limits) -> Result<Info, Error>: 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<Vec<Rendition>, Error>: each Rendition has a kind (Pdf, Thumbnail, Composite), a mime and the bytes.
  • outline(bytes, &Limits) -> Result<Outline, Error>: canvas, pages and layers.
  • Error is Unrecognized, Malformed, TooNew, Unsupported or LimitExceeded. A file whose version is newer than the crate reads gives TooNew from renditions and outline.

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 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.

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): every byte one call decompresses from gzip, reads out of ZIP entries or returns as a rendition, together.
  • 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.

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 decompress only as far as the end of the JSON. renditions checks the size of each member after the JSON against max_entry before it decompresses any of that data, then decompresses only as far as the members end. The struct is #[non_exhaustive]: start from the defaults and set the fields you change.

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.
  • 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 preview that names another entry, a format member inside another member) are refused. A preview or PDF member without mime gives no rendition.

.github/workflows/doc.yml runs fmt, clippy and the tests on Linux and Windows, and a wasm32-unknown-unknown check.