craft-doc: PhotoCraft layer pixels and masks behind the pcraft-pixels feature
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 (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.pcraftwhosemanifest.jsonentry starts after the head is recognized only whenheadholds the whole file. Whenheadholds a whole ZIP archive, the names in its central directory decide. A VectorCraft file is recognized by its top-levelformatmember, ascraft_vectorcraft_format::sniffrecognizes it, so a file written as a JSON array, whichFile::readalso reads, is not.info(bytes, &Limits) -> Result<Info, Error>: the kind, the version the file declares, the writer it names, andsupported, which isfalsewhen the version is newer than the crate reads.renditions(bytes, &Limits) -> Result<Vec<Rendition>, Error>: eachRenditionhas akind(Pdf,Thumbnail,Composite), amimeand thebytes.outline(bytes, &Limits) -> Result<Outline, Error>:canvas,pagesandlayers.ErrorisUnrecognized,Malformed,TooNew,UnsupportedorLimitExceeded. A file whose version is newer than the crate reads givesTooNewfromrenditionsandoutline. A ZIP archive whosemanifest.jsonhas noformat_versiongivesUnrecognized.
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.
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:
craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["pcraft-pixels"] }
layeris a path of indexes throughOutline::layersand each group'schildren, 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
videomember; 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 givesOk(None), and the mask of such a layer is not returned. LayerPixels::rgbaholdswidth×heightRGBA pixels, row by row, in the layer's sample type:Samples::U8,U16orF32. 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::maskholds the layer mask, one sample per pixel, with itsenabled,densityandfeatheras 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 givesError::Malformed. - The decoded layer and the decoded mask each count as one entry against
max_entry, and with each distinct tile read and decompressed againstmax_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 againstmax_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.
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. A rendition decompressed from gzip is counted once, and a second rendition of the same bytes is counted as another copy.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.renditionsandoutlinerefuse 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 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, and the offset where the last member ends against
max_total. It then 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.
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 throughflate2), read back through each call: plain and gzip VectorCraft files, members inline as base64 and after the JSON,.pcraftbundles with STORE and DEFLATE entries and the manifest last,.ecprojcompositions, newer versions, and filessniffmust 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 thanmax_entry, JSON nested pastmax_depth, ZIP64, encryption, other compression methods and CRC mismatches. An array of a million elements in place of ageneratoror 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 withoutgenerator, a manifest that is a JSON array, a preview that names another entry, aformatmember inside another member) are refused. A preview or PDF member withoutmimegives no rendition, and a VectorCraftgenerator,previeworpdfreads asFile::readreads it: asNonewhen 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 repeatedversion, and inrenditionsa missing or repeateddocument, are refused, asFile::readrefuses them.
.github/workflows/doc.yml runs fmt, clippy and the tests on Linux and Windows, and a
wasm32-unknown-unknown check.