From 287b65f788de73996610609ec9a8391edaf6fd7c Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:15:45 -0600 Subject: [PATCH] Object: reject malformed stroke types --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 85 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..98616051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d581760..bf3716d3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -259,9 +259,28 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", + "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", has_doc, |s, p| { + let kind: Option = p + .get("type") + .map(|v| { + if let Some(fields) = v.as_object() { + let field = match v.get("kind").and_then(Value::as_str) { + Some("dashed") => Some("pattern"), + Some("stripes") => Some("bands"), + Some("style") => Some("name"), + _ => None, + }; + for key in fields.keys() { + if key != "kind" && Some(key.as_str()) != field { + return Err(bad("object.stroke", format!("type: unknown field `{key}`"))); + } + } + } + serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}"))) + }) + .transpose()?; for key in ["swatch", "gapSwatch"] { if let Some(name) = p.get(key).and_then(Value::as_str) { known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; @@ -314,11 +333,13 @@ pub fn specs() -> Vec { if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) { st.gap_overprint = v; } - for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { + if let Some(kind) = &kind { + st.kind = kind.clone(); + } + for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { if let Some(v) = p2.get(k).cloned() { let _ = match apply { 0 => serde_json::from_value(v).map(|x| st.align = x), - 1 => serde_json::from_value(v).map(|x| st.kind = x), 2 => serde_json::from_value(v).map(|x| st.cap = x), 3 => serde_json::from_value(v).map(|x| st.join = x), 4 => serde_json::from_value(v).map(|x| st.start = x), @@ -4799,6 +4820,64 @@ mod group_tests { } } +#[cfg(test)] +mod stroke_type_tests { + use serde_json::json; + + #[test] + fn malformed_stroke_types_return_bad_params_without_editing() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone(); + for (kind, hint) in [ + (json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "zigzag"}), "zigzag"), + (json!({"kind": "solid", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"), + (json!({"kind": "dashed"}), "pattern"), + (json!({"kind": "dashed", "pattern": "bad"}), "type"), + (json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"), + (json!({"kind": "style"}), "name"), + (json!({}), "kind"), + (json!("solid"), "type"), + (json!(null), "type"), + ] { + let before = s.doc().unwrap().doc.clone(); + let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err(); + assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}"); + assert!(error.to_string().contains(hint), "{kind}: {error}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed"); + } + } + + #[test] + fn stroke_types_apply_all_supported_variants() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + for kind in [ + json!({"kind": "solid"}), + json!({"kind": "dashed", "pattern": [8, 2.5]}), + json!({"kind": "dotted"}), + json!({"kind": "thickThin"}), + json!({"kind": "thinThick"}), + json!({"kind": "thinThin"}), + json!({"kind": "thickThick"}), + json!({"kind": "thinThickThin"}), + json!({"kind": "thickThinThick"}), + json!({"kind": "wavy"}), + json!({"kind": "hashed"}), + json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}), + json!({"kind": "style", "name": "Rails"}), + ] { + let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap(); + assert_eq!(result["changed"], 1); + let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(item.stroke.kind, serde_json::from_value::(kind).unwrap()); + } + } +} + #[cfg(test)] mod swatch_name_tests { use serde_json::json;