From b5301ca74d0d926e6750ebc23e3bb287fedee872 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 18:24:56 -0800 Subject: [PATCH 1/3] Fix range text stroke weight defaults and scaling --- crates/engine/src/cmd/textedit.rs | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 8ce750a5e..df19ec002 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -351,6 +351,8 @@ pub(crate) struct CharChange { stroke: Option, /// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke. stroke_opts: StrokeChange, + /// Document points per text-space point for a stroke edit. + stroke_scale: Option, underline: Option, strikethrough: Option, all_caps: Option, @@ -641,6 +643,7 @@ impl CharChange { fill: paint_param(p, "fill", cmd)?, stroke: paint_param(p, "stroke", cmd)?, stroke_opts, + stroke_scale: None, underline: flag("underline"), strikethrough: flag("strikethrough"), all_caps: flag("allCaps"), @@ -723,6 +726,7 @@ impl CharChange { self.tcy_up_down = self.tcy_up_down.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; self.tcy_left_right = self.tcy_left_right.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?; + self.stroke_scale = Some(vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0)); Ok(()) } @@ -809,10 +813,13 @@ impl CharChange { st.fill = v.clone(); } if let Some(v) = &self.stroke { + if !v.is_none() { + super::gradient::run_stroke_weight(st); + } st.stroke = v.clone(); } if !self.stroke_opts.is_empty() { - self.stroke_opts.apply_char(st); + self.stroke_opts.apply_char_scaled(st, self.stroke_scale.unwrap_or(1.0)); } if let Some(v) = self.underline { st.underline = v; @@ -1609,3 +1616,27 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; Ok(json!({"removed": true})) } + +#[cfg(test)] +mod range_stroke_tests { + use super::*; + + #[test] + fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABCD"})).unwrap()["id"].as_u64().unwrap(); + let weights = |s: &Session| { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text missing") }; + t.runs.iter().map(|r| r.style.stroke_width).collect::>() + }; + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "stroke": "#ff0000"})).unwrap(); + assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); + + s.execute("object.scale", &json!({"ids": [id], "sx": 200, "sy": 200})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "strokeWidth": 2.0})).unwrap(); + assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})).unwrap(); + assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); + } +} From 79102f61043108a8ecfd82b21887402600adb3bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 20:52:01 -0800 Subject: [PATCH 2/3] Tests: range stroke weight test joins the character stroke tests The test moves from an inline module in cmd/textedit.rs to tests/charstroke.rs and uses that file's helpers. docs/mcp.md says that text.setRangeStyle gives a range's stroke a 1 pt weight as text.setStyle does, and that its strokeWidth and strokeOptions lengths are in document points. --- crates/engine/src/cmd/textedit.rs | 24 ------------------------ crates/engine/src/tests/charstroke.rs | 15 +++++++++++++++ docs/mcp.md | 2 ++ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index df19ec002..ac0af5867 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1616,27 +1616,3 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; Ok(json!({"removed": true})) } - -#[cfg(test)] -mod range_stroke_tests { - use super::*; - - #[test] - fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABCD"})).unwrap()["id"].as_u64().unwrap(); - let weights = |s: &Session| { - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text missing") }; - t.runs.iter().map(|r| r.style.stroke_width).collect::>() - }; - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "stroke": "#ff0000"})).unwrap(); - assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); - - s.execute("object.scale", &json!({"ids": [id], "sx": 200, "sy": 200})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "strokeWidth": 2.0})).unwrap(); - assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})).unwrap(); - assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); - } -} diff --git a/crates/engine/src/tests/charstroke.rs b/crates/engine/src/tests/charstroke.rs index ba948cb66..2c34e697e 100644 --- a/crates/engine/src/tests/charstroke.rs +++ b/crates/engine/src/tests/charstroke.rs @@ -91,6 +91,21 @@ fn range_stroke_options_split_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err()); } +#[test] +fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { + let mut s = session(); + let id = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "ABCD"}))["id"].as_u64().unwrap()); + let weights = |s: &Session| text(s, id).runs.iter().map(|r| r.style.stroke_width).collect::>(); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "stroke": "#ff0000"})); + assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); + + run(&mut s, "object.scale", json!({"ids": [id.0], "sx": 200, "sy": 200})); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeWidth": 2.0})); + assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})); + assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); +} + #[test] fn eyedropper_and_outlines_carry_the_character_stroke() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 8a2551ffc..864a1af92 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2404,6 +2404,8 @@ dash?, dashOffset?, alignDashes?}}`. `text.setStyle {ids?, stroke?, strokeWidth? and weight of the characters of each object given (without `start` and `end`: all of its text), with its other keys in one undo step; `strokeWidth` is in document points, as `stroke.set` gives the weight of scaled type. A `stroke` color without `strokeWidth` gives characters that have no stroke weight 1 pt, as `paint.setStroke` does. +`text.setRangeStyle` does the same for its range, and its `strokeWidth` and `strokeOptions` lengths are in document +points too. `align: "inside"|"outside"` keeps the characters' stroke on that side of the glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first From 0dba3afea9a371bc34c957654cca464a78f6402c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:38:46 -0800 Subject: [PATCH 3/3] Docs: the 1 pt default stroke weight on type is in the type's own units docs/mcp.md says that the default weight text.setStyle and text.setRangeStyle give an unweighted stroke is in the type's own units, so scaled type draws it wider, while strokeWidth and strokeOptions lengths are in document points. --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 864a1af92..5fab5134a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2405,7 +2405,7 @@ and weight of the characters of each object given (without `start` and `end`: al one undo step; `strokeWidth` is in document points, as `stroke.set` gives the weight of scaled type. A `stroke` color without `strokeWidth` gives characters that have no stroke weight 1 pt, as `paint.setStroke` does. `text.setRangeStyle` does the same for its range, and its `strokeWidth` and `strokeOptions` lengths are in document -points too. +points too. The 1 pt default of both commands is in the type's own units, so on type scaled to 200% it draws 2 pt wide. `align: "inside"|"outside"` keeps the characters' stroke on that side of the glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first