craft-pcraft-format
The envelope of PhotoCraft's .pcraft bundle: the manifest around the document, tile
references, the entry paths, the limits for reading a manifest, and the runner for manifest
migrations. The document model stays in PhotoCraft and is a type parameter here. The crate
does not read ZIP archives, open files or decode tiles. No unsafe, no panics on malformed
input, and it builds for wasm32-unknown-unknown. The dependencies are serde, serde_json
and thiserror.
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
Bundle layout
A bundle is a ZIP archive or a directory with these entries:
| Entry | Constant or function | Contents |
|---|---|---|
manifest.json |
MANIFEST |
the versioned manifest |
tiles/<blake3>.zst |
tile_path(hash) |
a 256×256 tile, zstd, little-endian samples |
blobs/<blake3>.zst |
blob_path(hash) |
binary data, zstd |
thumb.png |
THUMB |
optional thumbnail |
composite/preview.png |
COMPOSITE |
optional flattened preview |
composite/full.png |
FULL_COMPOSITE |
optional full-canvas flattened image |
save.lock |
SAVE_LOCK |
directory bundles only: held while a save runs (empty) |
<blake3> is the BLAKE3 hash of the uncompressed bytes as 64 lowercase hex digits.
is_valid_hash checks that form. tile_path and blob_path return None for any other
string, so a hash read from a file never becomes an entry name such as tiles/../x.zst.
TILES_DIR (tiles), BLOBS_DIR (blobs) and COMPOSITE_DIR (composite) name the
directories, and OBJECT_SUFFIX (.zst) ends every tile and blob entry. EXTENSION is
pcraft and FORMAT_VERSION is 1.
Use
use craft_pcraft_format::{FORMAT_VERSION, Limits, Manifest, Step};
const STEPS: &[Step] = &[];
let manifest: Manifest<MyDocument> = craft_pcraft_format::read_manifest(&manifest_json, &Limits::default(), STEPS, FORMAT_VERSION)?;
read_manifest(json, &Limits, steps, target)is the reader of a manifest. It runsparse_manifest, thenmigrate, which refuses a version newer thantarget, then reads the members, the document asD.read_manifest_withtakes a function that reads the document from itsserde_json::Valueinstead ofD'sDeserialize.Manifest<D>hasformat_version,generator,document: D,thumbnailandcomposite, and serializes them in that order. Absent previews serialize asnull. On read, missing previews areNone, members the type does not name are skipped, and athumbnailother thanthumb.pngor acompositeother thancomposite/preview.pnggivesCorrupt.Manifesthas noDeserializeand is#[non_exhaustive]:Manifest::new(generator, document)builds one atFORMAT_VERSIONwithout previews.Manifest::full_compositeoptionally namescomposite/full.png. A missing or null member reads asNone; any other path givesCorrupt. When absent, serialization omits this member, preserving the bytes of manifests without a full-canvas image. It does not change the format version.TileRefhastx,tyandhash, and is#[non_exhaustive]:TileRef::new(tx, ty, hash)builds one.TileRef::pathgives its entry, orNonewhen the hash is not valid.check_manifest(json, &Limits)checks the size, the nesting and the item count in one pass without recursion. PhotoCraft's own check of a manifest it saves tests the nesting and the item count only.parse_manifest(json, &Limits)runs that check and then parses into aserde_json::Valuewith serde_json's 128-level recursion limit turned off.check_manifest_with_cancel(json, &Limits, &cancelled)applies those same checks while polling a callback between bounded portions of JSON. A true result stops withError::Cancelled.migrate(&mut value, steps, target)upgrades the JSON in place, wheresteps[i]upgrades versioni + 1toi + 2, and returns the version the file had.Step<E>isfn(&mut Value) -> Result<(), E>; withE: From<Error>, the steps return the caller's own error type.ErrorisCorrupt,Unsupported,TooNew,LimitExceeded,JsonorCancelled. A manifest whoseformat_versionis newer thantargetgivesTooNew, and one with no step to the next version givesUnsupported. A missing or invalidformat_versiongivesCorrupt, and any other missing or mistyped member givesJson.TooNewholdsfound,supportedand the manifest'sgenerator(Nonewhen it is missing or not a string), and is matched with...
Limits
Limits (fields max_manifest_bytes, max_blob_bytes, max_total_bytes, max_depth,
max_items) holds the limits PhotoCraft loads with:
max_manifest_bytes(256 MiB): the size ofmanifest.json.max_blob_bytes(1 GiB): one decompressed blob.max_total_bytes(16 GiB): every decompressed tile and blob in one bundle.max_depth(367,manifest_depth(MAX_GROUP_DEPTH)): the nesting of JSON arrays and objects. PhotoCraft nests up toMAX_GROUP_DEPTH(100, a copy of PhotoCraft's own constant) layer groups, three levels each, and allows 64 more levels for the document and leaf layers. A value aboveMAX_DEPTH(400) is treated as 400, so parsing stays within the stack.max_items(MAX_MANIFEST_ITEMS, 4,194,304): arrays and objects opened, plus elements and members after a comma.
check_manifest, parse_manifest and read_manifest apply the manifest limits. The crate
does not read tiles or blobs, so it does not apply max_blob_bytes or max_total_bytes: the
caller that decompresses the entries enforces them. The struct is #[non_exhaustive]: start from the
defaults and set the fields you change.
let mut limits = craft_pcraft_format::Limits::default();
limits.max_manifest_bytes = 16 << 20;
Testing
cargo test -p craft-pcraft-format runs:
tests/manifest.rs: a manifest with and without previews serializes withserde_json::to_vec_prettyto JSON written by hand in the test, byte for byte, and reads back equal; missing previews and unknown members; entry names, hash checks, and no entry name for an invalid hash.tests/read.rs:read_manifestandread_manifest_withread the envelope and the document, refuse a newer version with the writer'sgenerator, refuse a manifest past the limits, run the steps first, and report missing or mistyped members as a struct with a derivedDeserializereports them, in the same order; a missing or invalidformat_versionisCorrupt, and so is a preview that names another entry, such as../xor an absolute path.- Doc tests:
ManifestandTileRefare not built by struct literal,Manifesthas noDeserialize, andError::TooNewis matched with... tests/limits.rs: the default limits, nesting at and pastmax_depth, items at and pastmax_items, brackets inside strings, the manifest size, JSON errors and the messages.tests/migrate.rs: steps in order, partial chains, the current version, newer, missing, zero and out-of-range versions, a missing step, and steps that return the caller's error type.
.github/workflows/pcraft-format.yml runs fmt, clippy and the tests on Linux and Windows, and
a wasm32-unknown-unknown check.