From c7fb7544c2cada91c7bbcfd6ddbca8ad9da77f7d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:23:04 +0000 Subject: [PATCH 1/8] craft-stencilcraft-format: add native JSON schema --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 1 + crates/stencilcraft-format/Cargo.toml | 16 ++ crates/stencilcraft-format/README.md | 23 +++ crates/stencilcraft-format/src/lib.rs | 229 ++++++++++++++++++++++++++ 6 files changed, 279 insertions(+) create mode 100644 crates/stencilcraft-format/Cargo.toml create mode 100644 crates/stencilcraft-format/README.md create mode 100644 crates/stencilcraft-format/src/lib.rs 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 { .. }))); + } +} From d31b04810b8b8626cec650ac6424d7cc37bace0f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:28:06 +0000 Subject: [PATCH 2/8] Fix current workspace clippy diagnostics --- crates/cms/src/synth.rs | 8 ++++---- crates/proof/src/tr/mod.rs | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index a59058f08..b1535f78c 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -111,9 +111,9 @@ impl CmykModel { // Overprints with black: multiplicative in reflectance above a first-surface floor. let floor = 0.006; let mut prim = [[0.0; 3]; 16]; - for m in 0..16 { + for (m, slot) in prim.iter_mut().enumerate() { let p = base[m & 7]; - prim[m] = if m & 8 == 0 { + *slot = if m & 8 == 0 { p } else { std::array::from_fn(|i| { @@ -133,7 +133,7 @@ impl CmykModel { (v + 4.0 * self.tvi[i] * v * (1.0 - v)).clamp(0.0, 1.0) }); let mut s = [0.0; 3]; - for m in 0..16 { + for (m, prim) in self.prim_n.iter().enumerate() { let mut w = 1.0; for (i, ai) in a.iter().enumerate() { w *= if m & (1 << i) != 0 { *ai } else { 1.0 - ai }; @@ -141,7 +141,7 @@ impl CmykModel { if w == 0.0 { continue; } - for (acc, p) in s.iter_mut().zip(&self.prim_n[m]) { + for (acc, p) in s.iter_mut().zip(prim) { *acc += w * p; } } diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index 5472fbe21..c728d5872 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -100,11 +100,7 @@ fn check(d: &TrDict, word: &str, opts: &crate::Options) -> bool { } // Compound words separated by hyphens (e.g., `afyon-karahisar`) - if w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) { - return true; - } - - false + w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) } #[cfg(test)] From 7a639e04a777dd96ce85683084c2cfcc08a6fecf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:35:01 +0000 Subject: [PATCH 3/8] Fix workspace clippy loop in LaTeX tests --- crates/latex/src/tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/latex/src/tests.rs b/crates/latex/src/tests.rs index 55bde2489..ccf349c80 100644 --- a/crates/latex/src/tests.rs +++ b/crates/latex/src/tests.rs @@ -353,9 +353,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc = Document::new(); let mut t_borderless = Table::new(2, 2, 400.0); t_borderless.props.style = None; - for r in 0..2 { - for c in 0..2 { - t_borderless.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_borderless.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc.body = vec![Arc::new(Block::Table(t_borderless))]; @@ -369,9 +369,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc_bordered = Document::new(); let mut t_bordered = Table::new(2, 2, 400.0); t_bordered.props.style = Some("TableGrid".to_string()); - for r in 0..2 { - for c in 0..2 { - t_bordered.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_bordered.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc_bordered.body = vec![Arc::new(Block::Table(t_bordered))]; From 1b53814a388ee7df0517a1522eb1143d5165c9a1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:37:09 +0000 Subject: [PATCH 4/8] Apply verified workspace format fixes --- crates/kra/src/lib.rs | 43 ++++++++++++++---- crates/ora/src/lib.rs | 61 ++++++++++++++++++++------ crates/plotter/tests/golden.rs | 4 +- crates/video/src/hevc/decode/filter.rs | 5 +-- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/crates/kra/src/lib.rs b/crates/kra/src/lib.rs index 7485b9dd5..12ae3468b 100644 --- a/crates/kra/src/lib.rs +++ b/crates/kra/src/lib.rs @@ -161,6 +161,16 @@ fn children<'a>(el: &'a El, container: &'static str, tag: &'static str) -> impl } fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { + let mut out = node_fields(el, is_mask)?; + if !is_mask { + out.layers = layers(el, count)?; + out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + } + Ok(out) +} + +/// A node's own attributes, without its children. +fn node_fields(el: &El, is_mask: bool) -> Result { let owned = |name: &str| attr(el, name).map(str::to_string); let node_type = attr(el, "nodetype").unwrap_or("").to_string(); // Only the kinds whose pixels are read need sound numbers; a node an application will leave @@ -168,7 +178,7 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { let read = matches!(node_type.as_str(), "paintlayer" | "grouplayer" | "transparencymask"); let lenient = |v: Result, default: f64| if read { v } else { Ok(v.unwrap_or(default)) }; let (x, y) = if is_mask && !read { (offset(el, "x").unwrap_or(0), offset(el, "y").unwrap_or(0)) } else { (offset(el, "x")?, offset(el, "y")?) }; - let mut out = Node { + let out = Node { node_type, name: owned("name"), visible: attr(el, "visible") != Some("0"), @@ -187,22 +197,39 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { layers: Vec::new(), masks: Vec::new(), }; - if !is_mask { - out.layers = layers(el, count)?; - out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; - } Ok(out) } -/// The `` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The `` of `el`, walked with an explicit stack: groups can nest up to [`MAX_XML_DEPTH`] +/// deep, more than a small thread stack can recurse. fn layers(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + node: Node, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = children(el, "layers", "layer").collect::>().into_iter(); + let mut stack: Vec = Vec::new(); let mut out = Vec::new(); - for child in children(el, "layers", "layer") { + loop { + let next = match stack.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = stack.pop() else { break }; + match stack.last_mut() { + Some(parent) => parent.node.layers.push(frame.node), + None => out.push(frame.node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(node(child, false, count)?); + let mut built = node_fields(child, false)?; + built.masks = children(child, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + stack.push(Frame { node: built, rest: children(child, "layers", "layer").collect::>().into_iter() }); } Ok(out) } diff --git a/crates/ora/src/lib.rs b/crates/ora/src/lib.rs index b2a91cc40..69d494d0d 100644 --- a/crates/ora/src/lib.rs +++ b/crates/ora/src/lib.rs @@ -189,28 +189,63 @@ fn props(el: &El) -> Result { }) } -/// The elements of `stack`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The elements of `stack`, walked with an explicit stack: a stack can nest up to +/// [`MAX_XML_DEPTH`] deep, more than a small thread stack can recurse. fn stack(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + props: Props, + isolation: Option, + children: Vec, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = el.elements().collect::>().into_iter(); + let mut frames: Vec = Vec::new(); let mut out = Vec::new(); - for child in el.elements() { + loop { + let next = match frames.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = frames.pop() else { break }; + let node = Node::Stack(Stack { props: frame.props, isolation: frame.isolation, children: frame.children }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(match child.name.as_str() { - "layer" => Node::Layer(Layer { - props: props(child)?, - x: int(child, "x")?, - y: int(child, "y")?, - src: child.attr("src").map(|s| s.trim().to_string()), - }), - "stack" => Node::Stack(Stack { + match child.name.as_str() { + "layer" => { + let node = Node::Layer(Layer { + props: props(child)?, + x: int(child, "x")?, + y: int(child, "y")?, + src: child.attr("src").map(|s| s.trim().to_string()), + }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + "stack" => frames.push(Frame { props: props(child)?, isolation: child.attr("isolation").map(|s| s.trim().to_string()), - children: stack(child, count)?, + children: Vec::new(), + rest: child.elements().collect::>().into_iter(), }), - other => Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }, - }); + other => { + let node = Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }; + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + } } Ok(out) } diff --git a/crates/plotter/tests/golden.rs b/crates/plotter/tests/golden.rs index 26aaae554..4d5029010 100644 --- a/crates/plotter/tests/golden.rs +++ b/crates/plotter/tests/golden.rs @@ -135,7 +135,7 @@ fn silhouette_registration_marks() { let p = silhouette::encode(&pro, &j).unwrap(); let (head, rest) = p.split_at_registration(); let text = |c: &[Command]| c.iter().map(Command::to_ascii).collect::>().join(" "); - assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB123,4600,3600,200,100")); + assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB123,4600,3600,200,100")); // The design is shifted so the first mark is the origin. assert_eq!(text(rest), "M100,100 D100,300 M100,0 SO0"); assert_eq!(p.registration_wait, Some(head.len())); @@ -148,7 +148,7 @@ fn silhouette_registration_marks() { reg.origin_right_mm = 0.0; assert!(ascii(®).ends_with(" TB124,4600,3600,0,0")); reg.search = false; - assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600"); + assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB23,4600,3600"); } #[test] diff --git a/crates/video/src/hevc/decode/filter.rs b/crates/video/src/hevc/decode/filter.rs index d5b94161d..69f936bc1 100644 --- a/crates/video/src/hevc/decode/filter.rs +++ b/crates/video/src/hevc/decode/filter.rs @@ -522,10 +522,7 @@ fn sao_edge_ctb( return false; } } - if !pic.pps.loop_filter_across_tiles && pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] != pic.layout.tile_id[cur_ts as usize] { - return false; - } - true + pic.pps.loop_filter_across_tiles || pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] == pic.layout.tile_id[cur_ts as usize] }; // avail[dy][dx]: the CTB at (rx + dx - 1, ry + dy - 1) may be used. let (rx, ry) = (rs % pic.wctb, rs / pic.wctb); From 5407ea8220a350b1b34f6fe41fa45cd4c71566a6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:04:29 -0500 Subject: [PATCH 5/8] Document required kerf field in native format example --- crates/stencilcraft-format/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 9f1e2a2c4..81881f6a9 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -10,6 +10,7 @@ //! { //! "stencilcraft": { "name": "stencilcraft", "version": 1 }, //! "board": { "width_mm": 100.0, "height_mm": 150.0 }, +//! "kerf": { "width_mm": 0.0, "apply_to_exports": false }, //! "layers": [] //! } //! ``` From 7c3a099ad8fee294ec2d5cba9d2241d099a29f11 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:21:44 +0000 Subject: [PATCH 6/8] Read native format JSON from streams --- crates/stencilcraft-format/src/lib.rs | 188 +++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 6 deletions(-) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 81881f6a9..3ff499124 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -18,10 +18,14 @@ //! [`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. +//! open files or set a file-size policy; callers handle bounded file I/O. [`File::read_from`] +//! lets callers parse from a reader without first buffering source bytes in a string. -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; +use std::fmt; +use std::io::Read; /// The extension, without the leading dot. pub const EXTENSION: &str = "stencilcraft"; @@ -90,8 +94,21 @@ impl File { /// 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)?; + let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?; + Self::from_wire(wire) + } + + /// Parses JSON from a reader, without first buffering the source bytes in a string. + /// + /// Parsed document fields are still materialized as JSON values. Callers that impose a + /// total file-size policy should enforce it on the reader before calling this method. + pub fn read_from(reader: impl Read) -> Result { + let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?; + Self::from_wire(wire) + } + + fn from_wire(wire: RawWire) -> Result { + let header = wire.fields.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); } @@ -101,7 +118,7 @@ impl File { 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 })?; + let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; Ok(Self { document: raw.document }) } @@ -127,6 +144,132 @@ struct RawFile { document: DocumentFields, } +/// A top-level streaming visitor that stores only fields owned by this wire schema. +/// +/// Values are kept as `Value` because consumers own their document models. Unknown fields are +/// skipped, duplicate fields retain serde_json's last-value-wins behavior, and header checks run +/// only after the full JSON input has been parsed. +struct RawWire { + fields: Map, +} + +impl<'de> Deserialize<'de> for RawWire { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RawWireVisitor; + + impl<'de> Visitor<'de> for RawWireVisitor { + type Value = RawWire; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value containing a StencilCraft document") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut fields = Map::new(); + while let Some(key) = access.next_key::()? { + if is_wire_field(&key) { + fields.insert(key, access.next_value()?); + } else { + let _ = access.next_value::()?; + } + } + Ok(RawWire { fields }) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + while access.next_element::()?.is_some() {} + Ok(RawWire { fields: Map::new() }) + } + + fn visit_bool(self, _value: bool) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_i64(self, _value: i64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_u64(self, _value: u64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_f64(self, _value: f64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_str(self, _value: &str) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_string(self, _value: String) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + } + + deserializer.deserialize_any(RawWireVisitor) + } +} + +fn is_wire_field(name: &str) -> bool { + matches!( + name, + HEADER_KEY + | "board" + | "kerf" + | "layers" + | "next-shape-id" + | "images" + | "board-setup" + | "pages" + | "guides" + | "grid" + | "snap" + | "groups" + | "meta" + ) +} + #[derive(Serialize)] struct HeaderRef<'a> { name: &'a str, @@ -161,6 +304,8 @@ pub enum Error { #[cfg(test)] mod tests { + use std::io::{self, Cursor, Read}; + use serde_json::{Value, json}; use super::{DocumentFields, Error, FORMAT_VERSION, File}; @@ -222,6 +367,37 @@ mod tests { assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":2}}"#), Err(Error::Version { found: 2, supported: 1 }))); } + #[test] + fn reader_preserves_duplicate_key_and_error_precedence_behavior() -> Result<(), Error> { + let duplicate = r#"{"stencilcraft":{"name":"wrong","version":1},"stencilcraft":{"name":"stencilcraft","version":1},"board":{"first":true},"board":{"width_mm":100,"height_mm":150},"kerf":{},"layers":[]}"#; + let file = File::read_from(Cursor::new(duplicate))?; + assert_eq!(file.document.board, json!({"width_mm":100,"height_mm":150})); + + let malformed_after_bad_header = Cursor::new(br#"{"stencilcraft":{"name":"other","version":1},"board": "#); + assert!(matches!(File::read_from(malformed_after_bad_header), Err(Error::Json(_)))); + assert!(matches!(File::read_from(Cursor::new(b"[]")), Err(Error::NotStencilCraft))); + Ok(()) + } + + #[test] + fn reader_accepts_large_input_in_short_reads() -> Result<(), Error> { + struct ShortReads(R); + impl Read for ShortReads { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let length = buffer.len().min(1024); + self.0.read(&mut buffer[..length]) + } + } + + let text = format!( + r#"{{"stencilcraft":{{"name":"stencilcraft","version":1}},"board":{{}},"kerf":{{}},"layers":[],"future-field":"{}"}}"#, + "x".repeat(8 * 1024 * 1024) + ); + let file = File::read_from(ShortReads(Cursor::new(text.as_bytes())))?; + assert_eq!(file.document.layers, json!([])); + Ok(()) + } + #[test] fn separates_json_errors_from_wire_schema_errors() { assert!(matches!(File::read("{"), Err(Error::Json(_)))); From eb379cf3d211cbe9f01d5e0688cf4f853bdf2c28 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:51:08 +0000 Subject: [PATCH 7/8] test(stencilcraft-format): cover scalar JSON roots --- crates/stencilcraft-format/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 3ff499124..1395e1b8a 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -362,6 +362,13 @@ mod tests { } } + #[test] + fn scalar_roots_are_not_documents() { + for text in ["false", "-1", "1", "1.5", "\"scalar\"", "null"] { + 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 }))); From 4b8dde4c1258848d15a0def44c7901d7e966a240 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:52:28 +0000 Subject: [PATCH 8/8] test(stencilcraft-format): exercise reader scalar root variants --- crates/stencilcraft-format/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 1395e1b8a..709bf7ac7 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -367,6 +367,7 @@ mod tests { for text in ["false", "-1", "1", "1.5", "\"scalar\"", "null"] { assert!(matches!(File::read(text), Err(Error::NotStencilCraft))); } + assert!(matches!(File::read_from(Cursor::new(br#""scalar""#)), Err(Error::NotStencilCraft))); } #[test]