diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..d04aa2f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2188,6 +2188,15 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-stencilcraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-stream-codecs" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 629bab64c..cef7b2a21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } +craft-stencilcraft-format = { path = "crates/stencilcraft-format" } craft-svg-paths = { path = "crates/svg-paths" } craft-stream-codecs = { path = "crates/stream-codecs" } craft-text = { path = "crates/text" } diff --git a/README.md b/README.md index a45fd5870..c79fa2aff 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit - [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies. - [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft. - [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused. +- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. The app retains its document model and domain validation. - [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft. - [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds diff --git a/crates/stencilcraft-format/Cargo.toml b/crates/stencilcraft-format/Cargo.toml new file mode 100644 index 000000000..fec3ad3f9 --- /dev/null +++ b/crates/stencilcraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-stencilcraft-format" +description = "Reader and writer for the versioned StencilCraft JSON document format" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/stencilcraft-format/README.md b/crates/stencilcraft-format/README.md new file mode 100644 index 000000000..2391d3c56 --- /dev/null +++ b/crates/stencilcraft-format/README.md @@ -0,0 +1,23 @@ +# craft-stencilcraft-format + +Reader and writer for the versioned `.stencilcraft` JSON file. The JSON header is +`{"stencilcraft":{"name":"stencilcraft","version":1}}`; the document fields share the +top-level object with the header. `DocumentFields` owns field names, flattening, required fields, +version 1 defaults, and omitted optional members. Its values remain JSON so the consuming app +owns the domain model, geometry validation, and field-to-model mapping. + +The crate does not open files or set a file-size policy. Callers perform bounded file I/O before +calling `File::read`. + +```toml +craft-stencilcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "" } +``` + +`File::read` parses JSON, validates the name and exact supported version, reads `DocumentFields`, +and ignores unknown members for version 1 compatibility. `File::new` and `to_json_pretty` write +the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent `images` +defaults to an empty list, matching early version 1 files. + +`cargo test -p craft-stencilcraft-format` covers round-trip wire shape, version 1 defaults, +unknown members, wrong and missing headers, unsupported versions, malformed JSON, and missing +required fields. diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs new file mode 100644 index 000000000..9f1e2a2c4 --- /dev/null +++ b/crates/stencilcraft-format/src/lib.rs @@ -0,0 +1,229 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +//! Reader and writer for the versioned JSON document format. +//! +//! The file stores a format header under `stencilcraft` and the document's wire fields at the +//! top level: +//! +//! ```json +//! { +//! "stencilcraft": { "name": "stencilcraft", "version": 1 }, +//! "board": { "width_mm": 100.0, "height_mm": 150.0 }, +//! "layers": [] +//! } +//! ``` +//! +//! [`DocumentFields`] owns the JSON field names, required and defaulted fields, and the +//! flattening rule. Its values are JSON because the consuming app owns the document model, +//! domain validation, and conversion between that model and the wire fields. The crate does not +//! open files or set a file-size policy; callers handle bounded file I/O. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The extension, without the leading dot. +pub const EXTENSION: &str = "stencilcraft"; +/// The format name stored in the header. +pub const FORMAT_NAME: &str = "stencilcraft"; +/// The newest document format version this crate reads and writes. +pub const FORMAT_VERSION: u64 = 1; +/// The top-level member containing the format header. +pub const HEADER_KEY: &str = "stencilcraft"; + +/// Wire fields in the flattened native document object. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DocumentFields { + /// Required millimetre artboard dimensions. + pub board: Value, + /// Required kerf settings. + pub kerf: Value, + /// Required drawing layers. + pub layers: Value, + /// Next document object id; absent in early version 1 files, where it defaults to 1. + #[serde(rename = "next-shape-id", default = "default_next_shape_id")] + pub next_shape_id: u64, + /// Embedded raster images; absent in early version 1 files, where it defaults to empty. + #[serde(default)] + pub images: Vec, + /// Optional canvas page setup, omitted when absent. + #[serde(rename = "board-setup", default, skip_serializing_if = "Option::is_none")] + pub board_setup: Option, + /// Optional pages, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pages: Option, + /// Optional guides, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub guides: Option, + /// Optional grid settings, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grid: Option, + /// Optional snap settings, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snap: Option, + /// Optional groups, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groups: Option, + /// Optional user metadata, omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +fn default_next_shape_id() -> u64 { + 1 +} + +/// A native document and its flattened wire fields. +#[derive(Debug, Clone, PartialEq)] +pub struct File { + /// The fields after the header have been parsed, with app-domain objects still encoded as + /// JSON values. + pub document: DocumentFields, +} + +impl File { + /// Builds a native document from its wire fields. + pub fn new(document: DocumentFields) -> Self { + Self { document } + } + + /// Parses JSON, validates the format header and version, then reads the wire fields. + pub fn read(text: &str) -> Result { + let value: Value = serde_json::from_str(text).map_err(Error::Json)?; + let header = value.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?; + if header.get("name").and_then(Value::as_str) != Some(FORMAT_NAME) { + return Err(Error::NotStencilCraft); + } + let Some(version) = header.get("version").and_then(Value::as_u64) else { + return Err(Error::NotStencilCraft); + }; + if version != FORMAT_VERSION { + return Err(Error::Version { found: version, supported: FORMAT_VERSION }); + } + let raw: RawFile = serde_json::from_value(value).map_err(|source| Error::Document { source })?; + Ok(Self { document: raw.document }) + } + + /// Writes the current format header and flattened wire fields as indented JSON. + pub fn to_json_pretty(&self) -> Result { + let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document }; + let value = serde_json::to_value(raw).map_err(Error::Json)?; + serde_json::to_string_pretty(&value).map_err(Error::Json) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Header { + name: String, + version: u64, +} + +#[derive(Debug, Deserialize)] +struct RawFile { + #[serde(rename = "stencilcraft")] + _header: Header, + #[serde(flatten)] + document: DocumentFields, +} + +#[derive(Serialize)] +struct HeaderRef<'a> { + name: &'a str, + version: u64, +} + +#[derive(Serialize)] +struct RawFileRef<'a> { + #[serde(rename = "stencilcraft")] + header: HeaderRef<'a>, + #[serde(flatten)] + document: &'a DocumentFields, +} + +/// A file was malformed, belongs to another format, or uses an unsupported version. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The input is not valid JSON, or the file could not be serialized. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// The input has no valid StencilCraft format header. + #[error("not a stencilcraft file")] + NotStencilCraft, + /// The input names a version other than [`FORMAT_VERSION`]. + #[error("format version {found}, this build reads {supported}")] + Version { found: u64, supported: u64 }, + /// The JSON envelope is valid, but a required or typed wire field is malformed. + #[error("{source}")] + Document { source: serde_json::Error }, +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{DocumentFields, Error, FORMAT_VERSION, File}; + + fn fields() -> DocumentFields { + DocumentFields { + board: json!({"width_mm": 100.0, "height_mm": 150.0}), + kerf: json!({"width_mm": 0.0, "apply_to_exports": false}), + layers: json!([]), + next_shape_id: 1, + images: Vec::new(), + board_setup: None, + pages: None, + guides: None, + grid: None, + snap: None, + groups: None, + meta: None, + } + } + + #[test] + fn writes_flattened_schema_with_the_current_header_and_reads_it() -> Result<(), Error> { + let file = File::new(fields()); + let text = file.to_json_pretty()?; + assert!(text.starts_with("{\n \"board\":")); + let value: Value = serde_json::from_str(&text)?; + assert_eq!(value["stencilcraft"]["name"], "stencilcraft"); + assert_eq!(value["stencilcraft"]["version"], FORMAT_VERSION); + assert_eq!(value["board"], file.document.board); + assert_eq!(value["layers"], file.document.layers); + assert!(value.get("board-setup").is_none()); + assert_eq!(File::read(&text)?, file); + Ok(()) + } + + #[test] + fn reads_version_one_defaults_and_ignores_unknown_fields() -> Result<(), Error> { + let text = r#"{"stencilcraft":{"name":"stencilcraft","version":1},"board":{},"kerf":{},"layers":[],"future-field":true}"#; + let file = File::read(text)?; + assert_eq!(file.document.next_shape_id, 1); + assert!(file.document.images.is_empty()); + Ok(()) + } + + #[test] + fn refuses_missing_or_mismatched_headers() { + for text in [ + r#"{"board":{},"kerf":{},"layers":[]}"#, + r#"{"stencilcraft":{"name":"other","version":1}}"#, + r#"{"stencilcraft":{"name":"stencilcraft","version":"1"}}"#, + ] { + assert!(matches!(File::read(text), Err(Error::NotStencilCraft))); + } + } + + #[test] + fn refuses_versions_other_than_the_current_version() { + assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":2}}"#), Err(Error::Version { found: 2, supported: 1 }))); + } + + #[test] + fn separates_json_errors_from_wire_schema_errors() { + assert!(matches!(File::read("{"), Err(Error::Json(_)))); + assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":1}}"#), Err(Error::Document { .. }))); + } +}