M3.40: stroke on type — the Stroke panel edits the characters' stroke

With type selected, stroke.set (without an item) gives every run the
weight, cap, join, miter limit and dashes instead of adding an object
stroke; while the Type tool edits text, the Stroke panel styles the
selected characters through text.setRangeStyle `strokeOptions`, and the
panel reads the first run (or the selection's style).

CharStyle gains stroke_cap, stroke_join, stroke_miter_limit and
stroke_dash (defaulted, skipped when default). Character strokes and
object strokes on type now draw through the shared stroke geometry on
the canvas, in PDF and in SVG (live text writes cap/join/miter/dash and
tspans reset what the first run sets; object strokes are drawn on the
glyph outlines), so every stroke option, opacity and blend applies.
Outlines, the Eyedropper and graphic styles keep the options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 01:09:55 +07:00
co-authored by Claude Opus 5.5
parent 3a79e6d922
commit 323b178ea7
23 changed files with 777 additions and 161 deletions
+5 -3
View File
@@ -637,7 +637,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let (appearance, src_box) = match &src.kind {
NodeKind::Text(t) if !t.runs.is_empty() => {
let r = &t.runs[0];
(Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds()))
(r.style.basic_appearance(), Some(t.local_bounds()))
}
_ => (src.appearance.clone(), src.geometric_bounds()),
};
@@ -680,10 +680,12 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
}
if let NodeKind::Text(t) = &mut n.kind {
let ap = placed(Some(t.local_bounds()));
// Characters without a painted stroke get no weight.
let stroke =
ap.stroke().map_or_else(|| StrokeLayer::new(Paint::None, 0.0), |s| StrokeLayer { width: ap.stroke_width(), ..s.clone() });
for r in &mut t.runs {
r.style.fill = ap.fill_paint();
r.style.stroke = ap.stroke_paint();
r.style.stroke_width = ap.stroke_width();
r.style.set_stroke_layer(&stroke);
}
continue;
}
+1 -1
View File
@@ -208,7 +208,7 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
/// other containers inside them), a compound path for its parts.
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
let expand = match n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
NodeKind::Compound { .. } => false,
+129 -44
View File
@@ -1,11 +1,14 @@
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
//! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes).
use serde_json::Value;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, WidthProfile};
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, NodeKind, StrokeLayer, Unit, WidthProfile};
use super::appearance::{edit_items, item_target};
use super::paint::painted;
use super::*;
use crate::inspect::{ALIGNS, CAPS, JOINS, node_stroke};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -14,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range)",
has_doc,
stroke_set
),
@@ -30,10 +33,100 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The stroke attributes object and character strokes share, parsed from `stroke.set` params
/// (also the `strokeOptions` of `text.setRangeStyle`).
#[derive(Clone, Debug, Default)]
pub(crate) struct StrokeChange {
pub(crate) weight: Option<f64>,
cap: Option<LineCap>,
join: Option<LineJoin>,
miter_limit: Option<f64>,
/// `Some(None)`: solid.
dash: Option<Option<Vec<f64>>>,
dash_offset: Option<f64>,
align_dashes: Option<bool>,
}
/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent.
fn named<T: Copy>(table: &[(T, &str)], p: &Value, key: &str, cmd: &str) -> Result<Option<T>> {
let Some(s) = str_param(p, key) else { return Ok(None) };
let s = if key == "cap" && s == "projecting" { "square" } else { s };
match table.iter().find(|(_, n)| *n == s) {
Some((v, _)) => Ok(Some(*v)),
None => Err(bad(cmd, format!("{key} must be {}, got {s}", table.iter().map(|(_, n)| *n).collect::<Vec<_>>().join("|")))),
}
}
impl StrokeChange {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let dash = match p.get("dash") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).filter(|d| !d.is_empty())),
Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))),
};
Ok(Self {
weight: num("weight").map(|w| w.clamp(0.0, 1000.0)),
cap: named(&CAPS, p, "cap", cmd)?,
join: named(&JOINS, p, "join", cmd)?,
miter_limit: num("miterLimit").map(|m| m.clamp(1.0, 500.0)),
dash,
dash_offset: num("dashOffset"),
align_dashes: p.get("alignDashes").and_then(Value::as_bool),
})
}
pub(crate) fn is_empty(&self) -> bool {
self.weight.is_none()
&& self.cap.is_none()
&& self.join.is_none()
&& self.miter_limit.is_none()
&& self.dash.is_none()
&& self.dash_offset.is_none()
&& self.align_dashes.is_none()
}
pub(crate) fn apply(&self, st: &mut StrokeLayer) {
if let Some(w) = self.weight {
st.width = w;
}
if let Some(c) = self.cap {
st.cap = c;
}
if let Some(j) = self.join {
st.join = j;
}
if let Some(m) = self.miter_limit {
st.miter_limit = m;
}
if let Some(pattern) = &self.dash {
// A new pattern keeps the offset and alignment of the one it replaces.
st.dash = pattern.clone().map(|pattern| Dash { pattern, ..st.dash.take().unwrap_or_default() });
}
if let Some(d) = st.dash.as_mut() {
if let Some(o) = self.dash_offset {
d.offset = o;
}
if let Some(a) = self.align_dashes {
d.align_corners = a;
}
}
}
/// [`Self::apply`] to a character stroke.
pub(crate) fn apply_char(&self, style: &mut CharStyle) {
let mut st = style.stroke_layer();
self.apply(&mut st);
style.set_stroke_layer(&st);
}
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.set";
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
let change = StrokeChange::parse(p, C)?;
if let Some(w) = change.weight {
s.paint.stroke_width = w;
}
let item = item_target(s, p, C)?.of_kind(s, false);
let ids = item.targets(s, p)?;
@@ -46,6 +139,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
let align = named(&ALIGNS, p, "align", C)?;
let arrow_align = match str_param(p, "arrowAlign") {
None => None,
Some("extend") => Some(ArrowAlign::Extend),
@@ -58,50 +152,19 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)),
};
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style));
return Ok(());
}
if index.is_none() && n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
// A new pattern keeps the offset and alignment of the one it replaces.
st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() });
}
_ => {}
}
if let Some(d) = st.dash.as_mut() {
if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) {
d.offset = o;
}
if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) {
d.align_corners = a;
}
change.apply(st);
if let Some(a) = align {
st.align = a;
}
if let Some(a) = sa {
st.start_arrow = a;
@@ -120,6 +183,28 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
impl Session {
/// Units > Stroke (Preferences): the unit stroke weights and dash lengths show in.
pub fn stroke_unit(&self) -> Unit {
super::edit::parse_unit(&self.prefs.units_stroke).unwrap_or_default()
}
/// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the
/// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else
/// the first selected object's (a group's first painted object's) top stroke, type's first
/// run's character stroke. None without a selection or a stroke.
pub fn shown_stroke(&self) -> Option<StrokeLayer> {
let st = self.active()?;
let first = st.doc.node(*st.selection.objects.first()?)?;
if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) {
return first.appearance.stroke_at(Some(i)).cloned();
}
let mut nodes = vec![];
painted(first, true, &mut nodes);
node_stroke(nodes.first()?)
}
}
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile {
let mut pts: Vec<(f64, f64, f64)> =
+1 -1
View File
@@ -192,7 +192,7 @@ fn captured(n: &Node) -> Appearance {
match &n.kind {
NodeKind::Text(t) => {
if let Some(r) = t.runs.first() {
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
ap.items = r.style.basic_appearance().items;
// Type paints its characters in text space.
ap = unitized(ap, Some(t.local_bounds()));
}
+16 -6
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, T
use vectorcraft_geom::Affine;
use vectorcraft_text::edit;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::*;
@@ -26,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -133,7 +134,8 @@ pub(crate) struct CharChange {
rotation: Option<f64>,
fill: Option<Paint>,
stroke: Option<Paint>,
stroke_width: Option<f64>,
/// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke.
stroke_opts: StrokeChange,
underline: Option<bool>,
strikethrough: Option<bool>,
all_caps: Option<bool>,
@@ -170,6 +172,14 @@ impl CharChange {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let mut stroke_opts = match p.get("strokeOptions") {
None | Some(Value::Null) => StrokeChange::default(),
Some(o) if o.is_object() => StrokeChange::parse(o, cmd)?,
Some(_) => return Err(bad(cmd, "strokeOptions must be an object of stroke.set options")),
};
if let Some(w) = num("strokeWidth") {
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
}
let c = Self {
font: str_param(p, "font").map(str::to_string),
style: str_param(p, "style").map(str::to_string),
@@ -183,7 +193,7 @@ impl CharChange {
rotation: num("rotation"),
fill: paint_param(p, "fill", cmd)?,
stroke: paint_param(p, "stroke", cmd)?,
stroke_width: num("strokeWidth"),
stroke_opts,
underline: flag("underline"),
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
@@ -208,7 +218,7 @@ impl CharChange {
&& self.rotation.is_none()
&& self.fill.is_none()
&& self.stroke.is_none()
&& self.stroke_width.is_none()
&& self.stroke_opts.is_empty()
&& self.underline.is_none()
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
@@ -259,8 +269,8 @@ impl CharChange {
if let Some(v) = &self.stroke {
st.stroke = v.clone();
}
if let Some(v) = self.stroke_width {
st.stroke_width = v.clamp(0.0, 1000.0);
if !self.stroke_opts.is_empty() {
self.stroke_opts.apply_char(st);
}
if let Some(v) = self.underline {
st.underline = v;
+19 -1
View File
@@ -1,7 +1,7 @@
//! Agent-friendly document summaries and view-models for panels.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer};
use crate::Session;
@@ -30,6 +30,7 @@ pub fn node_summary(n: &Node) -> Value {
v["stroke"] = json!(n.appearance.stroke_paint().label());
v["strokeWidth"] = json!(n.appearance.stroke_width());
}
match &n.kind {
NodeKind::Path { path, .. } => {
v["anchors"] = json!(path.anchor_count());
@@ -68,3 +69,20 @@ pub fn document(s: &Session) -> Value {
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()},
})
}
/// Cap names as `stroke.set` takes them.
pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")];
/// Join names as `stroke.set` takes them.
pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin::Round, "round"), (LineJoin::Bevel, "bevel")];
/// Stroke alignment names as `stroke.set` takes them.
pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")];
/// The stroke an object's Stroke panel options describe: type's first run's character stroke,
/// else the topmost stroke. None for containers and objects without a stroke.
pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
match &n.kind {
NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())),
_ if n.is_container() => None,
_ => n.appearance.stroke().cloned(),
}
}
+2
View File
@@ -747,6 +747,8 @@ mod tests_brushsym;
#[cfg(test)]
mod tests_build;
#[cfg(test)]
mod tests_charstroke;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_cmdsplit;
+135
View File
@@ -0,0 +1,135 @@
//! Stroke on type: the Stroke panel's options on the characters' stroke (every run, or a range
//! through `text.setRangeStyle`), and the character stroke kept by the Eyedropper, outlines and
//! the native format.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// Selected point type "Stroke" with a 1 pt red character stroke.
fn stroked_text(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "text.create", json!({"x": 20, "y": 100, "text": "Stroke", "size": 48}))["id"].as_u64().unwrap());
run(s, "select.set", json!({ "ids": [id.0] }));
run(s, "paint.setStroke", json!({"color": "#ff0000"}));
id
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not type"),
}
}
#[test]
fn stroke_options_on_type_style_every_run_and_add_no_object_stroke() {
let mut s = session();
let id = stroked_text(&mut s);
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60}));
assert_eq!(text(&s, id).runs.len(), 2);
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "stroke.set", json!({"weight": 3, "join": "round", "cap": "round", "miterLimit": 4, "dash": [6, 2]}));
let t = text(&s, id);
for r in &t.runs {
let st = &r.style;
assert_eq!((st.stroke_width, st.stroke_join, st.stroke_cap, st.stroke_miter_limit), (3.0, LineJoin::Round, LineCap::Round, 4.0));
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![6.0, 2.0]));
assert_eq!(st.stroke, Paint::solid(Color::rgb8(255, 0, 0)), "the stroke colour stays");
}
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The shown stroke (Stroke panel, Control bar) is the first run's.
let shown = s.shown_stroke().unwrap();
assert_eq!((shown.width, shown.join), (3.0, LineJoin::Round));
// Dash options keep working on the character dash; null turns it solid.
run(&mut s, "stroke.set", json!({"alignDashes": true, "dashOffset": 1}));
assert_eq!(text(&s, id).runs[0].style.stroke_dash, Some(Dash { pattern: vec![6.0, 2.0], offset: 1.0, align_corners: true }));
run(&mut s, "stroke.set", json!({"dash": null}));
assert!(text(&s, id).runs.iter().all(|r| r.style.stroke_dash.is_none()));
assert!(s.execute("stroke.set", &json!({"join": "pointy"})).is_err(), "unknown joins are rejected");
}
#[test]
fn an_object_stroke_on_type_is_still_reached_through_its_item() {
let mut s = session();
let id = stroked_text(&mut s);
run(&mut s, "appearance.addStroke", json!({}));
let item = s.doc().unwrap().doc.node(id).unwrap().appearance.items.len() - 1;
run(&mut s, "stroke.set", json!({"item": item, "weight": 5, "dash": [3, 3]}));
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let st = n.appearance.stroke_at(Some(item)).unwrap();
assert_eq!((st.width, st.dash.as_ref().map(|d| d.pattern.clone())), (5.0, Some(vec![3.0, 3.0])));
assert_eq!(text(&s, id).runs[0].style.stroke_width, 1.0, "the characters keep their stroke");
}
#[test]
fn range_stroke_options_split_runs() {
let mut s = session();
let id = stroked_text(&mut s);
run(
&mut s,
"text.setRangeStyle",
json!({"id": id.0, "start": 0, "end": 3, "strokeWidth": 2, "strokeOptions": {"join": "bevel", "dash": [1, 1]}}),
);
let t = text(&s, id);
assert_eq!(t.runs.len(), 2);
assert_eq!((t.runs[0].style.stroke_width, t.runs[0].style.stroke_join), (2.0, LineJoin::Bevel));
assert!(t.runs[0].style.stroke_dash.is_some());
assert_eq!((t.runs[1].style.stroke_width, t.runs[1].style.stroke_join), (1.0, LineJoin::Miter));
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err());
}
#[test]
fn eyedropper_and_outlines_carry_the_character_stroke() {
let mut s = session();
let id = stroked_text(&mut s);
run(&mut s, "stroke.set", json!({"weight": 2, "join": "round", "dash": [4, 2]}));
// Type → path: the path takes the characters' stroke options.
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]}));
let r = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().clone();
assert_eq!((r.width, r.join, r.dash.map(|d| d.pattern)), (2.0, LineJoin::Round, Some(vec![4.0, 2.0])));
// Path → type.
run(&mut s, "stroke.set", json!({"ids": [rect], "cap": "square", "dash": null}));
run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]}));
let st = text(&s, id).runs[0].style.clone();
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_dash), (LineCap::Square, LineJoin::Round, None));
// Create Outlines keeps them on every glyph.
run(&mut s, "select.set", json!({ "ids": [id.0] }));
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let glyphs = d.node(NodeId(g)).unwrap().children().unwrap().to_vec();
assert!(!glyphs.is_empty());
assert!(glyphs.iter().all(|c| c.appearance.stroke().is_some_and(|st| st.cap == LineCap::Square && st.join == LineJoin::Round)));
}
#[test]
fn character_stroke_options_round_trip_natively_and_old_files_load() {
let mut s = session();
let id = stroked_text(&mut s);
run(&mut s, "stroke.set", json!({"cap": "round", "join": "bevel", "miterLimit": 3, "dash": [5, 1]}));
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!("not type") };
assert_eq!(t.runs, text(&s, id).runs);
// A character style written before stroke options existed takes the defaults.
let mut old = serde_json::to_value(CharStyle::default()).unwrap();
for k in ["stroke_cap", "stroke_join", "stroke_miter_limit", "stroke_dash"] {
old.as_object_mut().unwrap().remove(k);
}
let st: CharStyle = serde_json::from_value(old).unwrap();
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_miter_limit, st.stroke_dash), (LineCap::Butt, LineJoin::Miter, 10.0, None));
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_miter_limit").is_none(), "defaults aren't written");
}