From 4973d11569d99ea0b2ad9e6ca7944895218ff4cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:15:03 -0800 Subject: [PATCH] Object styles: unknown swatches and settings are refused - style.object.create and edit refuse a fill, stroke or gap swatch the document lacks, and a fill or stroke key that is not a setting of it (a misspelled "wieght" no longer succeeds without a change). - fromSelection must be true or false. --- crates/engine/src/cmd/style.rs | 4 +-- crates/engine/src/cmd/style/object.rs | 46 ++++++++++++++++++++++----- crates/engine/src/tests.rs | 15 +++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5b1ac047d..7e55e0529 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -182,7 +182,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …} (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", has_doc, create_object ), @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", has_doc, edit_object ), diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 50f38e835..2a8ec7dd3 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -131,7 +131,9 @@ fn merge_json(base: &mut Value, top: Value, depth: usize) { pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); - let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + const CREATE: &str = "style.object.create"; + check_swatches(CREATE, s, p)?; + let from_sel = bool_param(CREATE, p, "fromSelection")?.unwrap_or(true); let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; let mut os = ObjectStyle::default(); if let Some(it) = &src { @@ -141,7 +143,6 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { os.text_frame = it.text_frame().map(|t| t.options.clone()); os.effects = Some(it.effects.clone()); } - const CREATE: &str = "style.object.create"; if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? { os.fill = f; } @@ -171,6 +172,23 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// The swatches this call gives (`fill` as a name or `fill.swatch`, `stroke.swatch` and +/// `stroke.gapSwatch`) must be swatches of the document. +fn check_swatches(cmd: &str, s: &Session, p: &Value) -> Result<()> { + let doc = &s.doc()?.doc; + let given = [ + ("fill", p.get("fill").and_then(|f| f.as_str().or_else(|| f.get("swatch").and_then(Value::as_str)))), + ("stroke", p.get("stroke").and_then(|f| f.get("swatch")).and_then(Value::as_str)), + ("stroke gap", p.get("stroke").and_then(|f| f.get("gapSwatch")).and_then(Value::as_str)), + ]; + for (key, name) in given { + if let Some(name) = name { + crate::cmd::object::known_swatch(doc, name, cmd, key)?; + } + } + Ok(()) +} + /// `paragraphStyle`: a paragraph style's name, or null (the object style sets none). fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result>> { match p.get("paragraphStyle") { @@ -200,7 +218,7 @@ fn text_frame_param( o.insert("inset".into(), json!([n, n, n, n])); } } - let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; + let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()), &[])?; if let Some(Some(o)) = &tf { // Only the values given in this call are checked. let given = |k: &str| p.get("textFrame").is_some_and(|t| t.get(k).is_some()); @@ -228,7 +246,8 @@ fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { /// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 /// or the tint isn't 0–1. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { - let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()))?; + let optional = ["gapOverprint", "gradientAngle", "gradientVector"]; + let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()), &optional)?; if let Some(Some(st)) = &st { if !(st.weight.is_finite() && st.weight >= 0.0) { return Err(bad(cmd, "stroke weight must be a number ≥ 0")); @@ -241,13 +260,23 @@ fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> } /// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not -/// given, `Some(None)` for null (the style no longer sets it). -fn group_param(cmd: &str, p: &Value, key: &str, base: &T) -> Result>> { +/// given, `Some(None)` for null (the style no longer sets it). A field `base` doesn't write and +/// `optional` doesn't name is refused. +fn group_param( + cmd: &str, + p: &Value, + key: &str, + base: &T, + optional: &[&str], +) -> Result>> { match p.get(key) { None => Ok(None), Some(Value::Null) => Ok(Some(None)), - Some(v @ Value::Object(_)) => { + Some(v @ Value::Object(given)) => { let mut cur = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + if let Some(k) = given.keys().find(|k| cur.get(k.as_str()).is_none() && !optional.contains(&k.as_str())) { + return Err(bad(cmd, format!("{key}.{k} is not a {key} setting"))); + } merge_json(&mut cur, v.clone(), 0); serde_json::from_value(cur).map(|t| Some(Some(t))).map_err(|e| bad(cmd, format!("{key}: {e}"))) } @@ -260,7 +289,7 @@ fn fill_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Fill>) -> Res if let Some(sw) = p.get("fill").and_then(Value::as_str) { return Ok(Some(Some(designcraft_doc::Fill::swatch(sw)))); } - let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()))?; + let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()), &["gradientAngle", "gradientVector"])?; if let Some(Some(f)) = &fill && !(f.tint.is_finite() && (0.0..=1.0).contains(&f.tint)) { @@ -307,6 +336,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, format!("`{name}` can't be edited"))); } let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; + check_swatches(CMD, s, p)?; let mut new = old.clone(); if p.get("effects").is_some_and(Value::is_null) { if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d559fd7d2..526f421a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -792,6 +792,21 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // An unknown swatch, a misspelled setting and a word for a switch are refused. + for bad in [ + json!({"fill": "Nope"}), + json!({"fill": {"swatch": "Nope"}}), + json!({"stroke": {"swatch": "Nope"}}), + json!({"stroke": {"wieght": 5}}), + json!({"fill": {"tnt": 0.5}}), + ] { + let mut p = bad.clone(); + p["name"] = json!("Framed"); + assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); + p["name"] = json!("Other"); + assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); + } + assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": "yes"})).is_err()); // A size outside 0–10000 pt is refused, not changed. for size in [-1.0, 10_001.0] { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": size}})).is_err(), "{size}");