diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 994dff13..077d00e4 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; +use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { #[default] @@ -64,8 +66,24 @@ pub struct CharStyle { pub overprint_fill: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub overprint_stroke: bool, + /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type + /// selected); defaults as for object strokes. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub stroke_miter_limit: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke_dash: Option, } +fn ten() -> f64 { + 10.0 +} +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} fn regular() -> String { "Regular".into() } @@ -96,6 +114,10 @@ impl Default for CharStyle { style_name: None, overprint_fill: false, overprint_stroke: false, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, + stroke_dash: None, } } } @@ -104,19 +126,50 @@ impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } - /// These characters' paint as an object appearance (their outlines'): the fill, plus the - /// stroke when it is painted, overprinting as the characters do. - pub fn appearance(&self) -> crate::Appearance { - let mut fill = crate::FillLayer::new(self.fill.clone()); - fill.overprint = self.overprint_fill; - let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], ..Default::default() }; - if !self.stroke.is_none() && self.stroke_width > 0.0 { - let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width); - st.overprint = self.overprint_stroke; - a.items.push(crate::AppearanceItem::Stroke(st)); + /// Do the characters draw a stroke (a paint and a positive weight)? + pub fn has_stroke(&self) -> bool { + !self.stroke.is_none() && self.stroke_width > 0.0 + } + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and + /// overprint), so type strokes share the object strokes' geometry, rendering and export. + pub fn stroke_layer(&self) -> StrokeLayer { + StrokeLayer { + overprint: self.overprint_stroke, + cap: self.stroke_cap, + join: self.stroke_join, + miter_limit: self.stroke_miter_limit, + dash: self.stroke_dash.clone(), + ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) + } + } + /// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke + /// (the options characters have no use for, such as alignment and arrowheads, are dropped). + pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { + self.stroke = st.paint.clone(); + self.stroke_width = st.width; + self.stroke_cap = st.cap; + self.stroke_join = st.join; + self.stroke_miter_limit = st.miter_limit; + self.stroke_dash = st.dash.clone(); + } + /// The character fill as a fill layer, overprinting as the characters do. + fn fill_layer(&self) -> FillLayer { + FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } + } + /// The characters' paint as an object appearance (their outlines'): the fill, plus the + /// stroke when it draws one, overprinting as the characters do. + pub fn appearance(&self) -> Appearance { + let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; + if self.has_stroke() { + a.items.push(AppearanceItem::Stroke(self.stroke_layer())); } a } + /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has + /// no paint): the appearance the Eyedropper and graphic styles take from type. + pub fn basic_appearance(&self) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } + } } /// Tab stop alignment (Tabs panel). diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 6de86c5a..12cad9b7 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -9,7 +9,7 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; use crate::{GeomContext, apply_one, is_geometry, merged_params}; @@ -54,13 +54,7 @@ pub fn outline_text(n: &Node) -> Option { .iter() .zip(&t.runs) .filter(|(bp, _)| !bp.elements().is_empty()) - .map(|(bp, run)| { - let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() }; - if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 { - ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width))); - } - Arc::new(Node::path(n.id, path(bp), ap)) - }) + .map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance()))) .collect(); if !n.appearance.items.is_empty() && !all.elements().is_empty() { let (below, above) = n.appearance.split_contents(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index b5ae5dcc..6d86eec3 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -637,7 +637,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { 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 { } 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; } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2ada9291..3763c20f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -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, diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 79296a27..c1ec48d1 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -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 { vec![ @@ -14,7 +17,7 @@ pub fn specs() -> Vec { "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 { ] } +/// 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, + cap: Option, + join: Option, + miter_limit: Option, + /// `Some(None)`: solid. + dash: Option>>, + dash_offset: Option, + align_dashes: Option, +} + +/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent. +fn named(table: &[(T, &str)], p: &Value, key: &str, cmd: &str) -> Result> { + 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::>().join("|")))), + } +} + +impl StrokeChange { + pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + 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::>()).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 { 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 { } }; 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 { 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 = 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 { 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 { + 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)> = diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c35b8a5c..09d0b482 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -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())); } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 02ef3cff..82ef9a9b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -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 { "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, fill: Option, stroke: Option, - stroke_width: Option, + /// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke. + stroke_opts: StrokeChange, underline: Option, strikethrough: Option, all_caps: Option, @@ -170,6 +172,14 @@ impl CharChange { pub(crate) fn parse(p: &Value, cmd: &str) -> Result { 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; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index bddfaa4f..34fcbf47 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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 { + 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(), + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c51424d2..43a30faa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests_charstroke.rs new file mode 100644 index 00000000..41af4f1a --- /dev/null +++ b/crates/engine/src/tests_charstroke.rs @@ -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"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index c7aa3882..e456c8c7 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -13,7 +13,6 @@ use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, St use krilla::surface::Surface; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; @@ -494,7 +493,7 @@ impl Exporter<'_> { } self.shape(s, n, &bp, *rule, page); } - NodeKind::Text(t) => self.text(s, n, t), + NodeKind::Text(t) => self.text(s, n, t, page), NodeKind::Image(im) => self.image(s, im), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) { @@ -562,7 +561,7 @@ impl Exporter<'_> { continue; } self.warn_raster(&st.effects); - self.stroke(s, bp, &path, r, st, page); + self.stroke(s, bp, &path, r, st, page, bounds); } } } @@ -570,11 +569,13 @@ impl Exporter<'_> { s.set_stroke(None); } - fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) { + /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its + /// unplaced gradients. + #[allow(clippy::too_many_arguments)] + fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } - let bounds = bp.bounding_box(); let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); let mut pushes = 0; @@ -675,7 +676,7 @@ impl Exporter<'_> { true } - fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { + fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let tb = t.xf.transform_rect_bbox(layout.bounds); // The object's own fills and strokes paint the whole outline: those below the Characters @@ -684,11 +685,11 @@ impl Exporter<'_> { let all = (!n.appearance.items.is_empty()).then(|| { let mut all = layout.to_bezpath(); all.apply_affine(t.xf); - to_path(&all) + to_path(&all).map(|p| (all, p)) }); let all = all.flatten(); - if let Some(path) = &all { - self.text_items(s, below, path, tb); + if let Some((bp, path)) = &all { + self.text_items(s, below, bp, path, page, tb); } s.push_transform(&xf(t.xf)); for (i, run) in t.runs.iter().enumerate() { @@ -702,24 +703,23 @@ impl Exporter<'_> { s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); } - if run.style.stroke_width > 0.0 - && let Some(paint) = self.paint(&run.style.stroke, stroke_paint_bounds(layout.bounds, run.style.stroke_width)) - { - s.set_fill(None); - s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() })); - s.draw_path(&path); + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. + self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds); } } s.set_fill(None); s.set_stroke(None); s.pop(); - if let Some(path) = &all { - self.text_items(s, above, path, tb); + if let Some((bp, path)) = &all { + self.text_items(s, above, bp, path, page, tb); } } - /// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds). - fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) { + /// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`; + /// `tb`: their bounds). Strokes take every stroke option, as on paths. + #[allow(clippy::too_many_arguments)] + fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => { @@ -729,13 +729,7 @@ impl Exporter<'_> { s.draw_path(path); } } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { - if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) { - s.set_fill(None); - s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); - s.draw_path(path); - } - } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => self.stroke(s, bp, path, FillRule::NonZero, st, page, tb), _ => {} } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1e6ec125..ef400b37 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -110,6 +110,8 @@ mod tests; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_charstroke; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_fx; diff --git a/crates/pdf/src/tests_charstroke.rs b/crates/pdf/src/tests_charstroke.rs new file mode 100644 index 00000000..fd51389d --- /dev/null +++ b/crates/pdf/src/tests_charstroke.rs @@ -0,0 +1,40 @@ +//! Strokes on type in PDF: the characters' and the object's strokes keep their join and dashes. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineJoin, Node, NodeKind, StrokeLayer, TextObject}; +use vectorcraft_geom::Point; + +use crate::*; + +/// The strokes of a PDF of `d` read back. +fn strokes_back(d: &Document) -> Vec { + let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap(); + let mut out = vec![]; + back.walk(|n| out.extend(n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }))); + out +} + +#[test] +fn character_and_object_strokes_on_type_keep_join_and_dashes() { + let dash = Some(Dash { pattern: vec![3.0, 2.0], ..Default::default() }); + let style = CharStyle { + size: 40.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 2.0, + stroke_join: LineJoin::Round, + stroke_dash: dash.clone(), + ..Default::default() + }; + let mut d = Document::new(200.0, 100.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Type", style)))); + let mut own = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + own.join = LineJoin::Bevel; + own.dash = Some(Dash { pattern: vec![5.0, 5.0], ..Default::default() }); + n.appearance.items.push(AppearanceItem::Stroke(own)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let strokes = strokes_back(&d); + let pattern = |s: &StrokeLayer| s.dash.as_ref().map(|d| d.pattern.iter().map(|v| (v * 100.0).round() / 100.0).collect::>()); + assert!(strokes.iter().any(|s| s.join == LineJoin::Round && pattern(s) == Some(vec![3.0, 2.0])), "character stroke: {strokes:?}"); + assert!(strokes.iter().any(|s| s.join == LineJoin::Bevel && pattern(s) == Some(vec![5.0, 5.0])), "object stroke: {strokes:?}"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6329da4d..d8a57237 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -652,7 +652,7 @@ impl Renderer { if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { return; } - self.draw_stroke(ctx, f, ig, rule, st, ib); + self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, ib); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 73c1b109..1fbb639d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,7 +17,6 @@ pub mod proof; use std::collections::HashMap; use std::sync::Arc; -use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -792,7 +791,7 @@ impl Renderer { if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) { continue; } - self.draw_stroke(ctx, f, bp, rule, st, bounds); + self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds); } } } @@ -808,7 +807,10 @@ impl Renderer { } } - fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { + /// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its + /// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend. + #[allow(clippy::too_many_arguments)] + fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { let pieces = effects::stroke::stroke_pieces(bp, st); // The line and its heads overlap: they take the opacity once, in one layer, instead of // each folding it into its paint. @@ -819,10 +821,10 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None); } - ctx.set_transform(f.view); + ctx.set_transform(f.view * xf); let closed = effects::stroke::is_closed(bp); // Keep hairlines visible when zoomed far out (at least ~1 device pixel). - let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5); + let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); let inside = st.align == StrokeAlign::Inside && closed; if inside { ctx.set_fill_rule(fill_rule(rule)); @@ -929,7 +931,7 @@ impl Renderer { all }); if let Some(all) = &all { - self.draw_text_items(ctx, f, below, all, tb); + self.draw_text_items(ctx, f, n, below, all, tb); } for (i, run) in t.runs.iter().enumerate() { let Some(path) = g.runs.get(i) else { continue }; @@ -943,44 +945,45 @@ impl Renderer { overprint(ctx, run.style.overprint_fill); ctx.fill_path(path); } - if !run.style.stroke.is_none() - && run.style.stroke_width > 0.0 - && paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc) - { + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. overprint(ctx, run.style.overprint_stroke); - ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); - ctx.stroke_path(path); + self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds); } } overprint(ctx, false); if let Some(all) = &all { - self.draw_text_items(ctx, f, above, all, tb); + self.draw_text_items(ctx, f, n, above, all, tb); } } - /// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document - /// space; `tb` their bounds), each with its own opacity and blend mode. - fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) { + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` + /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; + /// strokes take every stroke option, as on paths. + fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) { for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) { - let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); - } - ctx.set_transform(f.view); match item { - AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(all); + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, tb, f.doc) { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(all); + } + if layered { + ctx.pop_layer(); + } } - AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { - ctx.set_stroke(kurbo::Stroke::new(st.width)); - ctx.stroke_path(all); + AppearanceItem::Stroke(st) if st.width > 0.0 => { + if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) { + self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb); + } } - _ => {} - } - if layered { - ctx.pop_layer(); + AppearanceItem::Stroke(_) => {} } } } @@ -1152,6 +1155,8 @@ mod tests; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_charstroke; +#[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_container; diff --git a/crates/render/src/tests_charstroke.rs b/crates/render/src/tests_charstroke.rs new file mode 100644 index 00000000..ce44e8e9 --- /dev/null +++ b/crates/render/src/tests_charstroke.rs @@ -0,0 +1,83 @@ +//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type +//! take every stroke option, as path strokes do. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind}; +use vectorcraft_geom::Point; + +use super::*; + +const SIZE: u32 = 200; + +/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page. +fn render_text(text: &str, style: CharStyle, items: Vec) -> Rendered { + let mut d = Document::new(SIZE as f64, SIZE as f64); + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style })))); + n.appearance.items = items; + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn dark(img: &Rendered, x: u32, y: u32) -> bool { + let p = img.pixel(x, y); + (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 +} + +/// The box of the dark pixels: (x0, y0, x1, y1). +fn ink(img: &Rendered) -> (u32, u32, u32, u32) { + let mut b = (u32::MAX, u32::MAX, 0, 0); + for y in 0..SIZE { + for x in 0..SIZE { + if dark(img, x, y) { + b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + } + b +} + +fn stroked(join: LineJoin) -> CharStyle { + CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() } +} + +#[test] +fn round_joined_character_strokes_have_no_miter_spikes() { + // The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it, + // the default miter join spikes out further at the sharp corners of the A. + let glyph = ink(&render_text("A", CharStyle::default(), vec![])); + let round = ink(&render_text("A", stroked(LineJoin::Round), vec![])); + let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![])); + let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3); + assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}"); + assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}"); +} + +#[test] +fn character_and_object_strokes_on_type_are_dashed() { + // An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge. + let none = CharStyle { fill: Paint::None, ..Default::default() }; + let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() }); + let transitions = |img: &Rendered| { + let (x0, y0, _, y1) = ink(img); + let col = x0 + 1; + (y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count() + }; + let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() }; + assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous"); + let chars = CharStyle { stroke_dash: dash.clone(), ..solid }; + assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes"); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.dash = dash; + let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]); + assert!(transitions(&object) >= 6, "object dashes"); + // And its opacity. + st.opacity = 0.5; + st.dash = None; + let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]); + let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![])); + let p = half.pixel(x0, (y0 + y1) / 2); + assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 4832eb7f..fcce2df5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -822,8 +822,7 @@ impl Writer<'_> { let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); } - NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t), - NodeKind::Text(t) => self.text(n, t), + NodeKind::Text(t) => self.text_node(n, t), NodeKind::Image(im) => { let href = match self.doc.images.get(&im.key) { Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)), @@ -965,13 +964,14 @@ impl Writer<'_> { p => self.paint(p, None), }; p.push(("fill", fill)); - if !st.stroke.is_none() && st.stroke_width > 0.0 { - let s = match &st.stroke { - Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()), - p => self.paint(p, None), - }; - p.push(("stroke", s)); - p.push(("stroke-width", self.num(st.stroke_width))); + if st.has_stroke() { + // Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its + // first colour, as for the fill). + let mut layer = st.stroke_layer(); + if let Paint::Gradient(g) = &layer.paint { + layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color)); + } + self.stroke_props(&layer, layer.width, None, &mut p); } if st.tracking != 0.0 { p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size))); @@ -996,10 +996,43 @@ impl Writer<'_> { p } + /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, + /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those + /// below the Characters row under the characters, the others over them. + fn text_node(&mut self, n: &Node, t: &TextObject) { + let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) }; + if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { + return chars(self, n); + } + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf); + let d = self.path_d(&pd, self.xf); + let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); + let (below, above) = n.appearance.split_contents(); + let glyphs = |w: &mut Self, items: &[AppearanceItem]| { + if !items.is_empty() { + let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; + w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); + } + }; + glyphs(self, below); + // The group carries the id and the object's transparency. + let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + chars(self, &bare); + glyphs(self, above); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + /// Text as glyph outlines: one compound path per run, painted like the run. fn text_outlines(&mut self, n: &Node, t: &TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let m = self.xf * t.xf; let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; for g in &lay.glyphs { match runs.last_mut() { @@ -1013,16 +1046,12 @@ impl Writer<'_> { self.depth += 1; for (r, mut bp) in runs { let Some(run) = t.runs.get(r) else { continue }; - bp.apply_affine(m); + bp.apply_affine(t.xf); let pd = PathData::from_bezpath(&bp); - let st = &run.style; // An unnamed id: the pieces carry no id attribute (the group has it). - let glyphs = - Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width)); - let d = self.path_d(&pd, Affine::IDENTITY); - let bounds = pd.bounds(); - // Run strokes are plain: they need no document-space geometry. - self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds); + let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); + let d = self.path_d(&pd, self.xf); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, pd.bounds()); } self.depth -= 1; self.line(""); @@ -1068,7 +1097,7 @@ impl Writer<'_> { let last = l.pieces.len() - 1; for (k, (ri, text)) in l.pieces.into_iter().enumerate() { let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() }; - let diff: Props = self.char_props(&t.runs[ri].style).into_iter().filter(|kv| !base.contains(kv)).collect(); + let diff = run_diff(&base, self.char_props(&t.runs[ri].style)); let a = self.attrs(&diff); let hy = if k == last && l.hyphenated { "-" } else { "" }; s.push_str(&format!("{}{hy}", xml_escape(text))); @@ -1078,8 +1107,7 @@ impl Writer<'_> { let mut line = 0usize; let mut pending = false; for run in &t.runs { - let rp = self.char_props(&run.style); - let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect(); + let diff = run_diff(&base, self.char_props(&run.style)); for (i, piece) in run.text.split('\n').enumerate() { if i > 0 { line += 1; @@ -1147,6 +1175,29 @@ impl Writer<'_> { } } +/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the +/// properties that differ, and the initial value of each one `base` sets and the run doesn't. +fn run_diff(base: &Props, run: Props) -> Props { + let reset: Props = + base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); + run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() +} + +/// The initial value of an inherited property a run may leave unset (decorations can't be undone +/// on a ``). +fn initial_value(k: &str) -> Option<&'static str> { + Some(match k { + "font-weight" | "font-style" | "font-feature-settings" => "normal", + "stroke" | "stroke-dasharray" => "none", + "stroke-width" | "stroke-opacity" => "1", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-dashoffset" | "letter-spacing" => "0", + _ => return None, + }) +} + /// Any visible raster effect (shadow, glow, blur, feather) in `effects`? fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 4a6a300e..92e0d2f3 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -539,6 +539,21 @@ fn char_style(ctx: &TextCtx, n: XNode) -> CharStyle { if let Some(s) = ctx.prop(n, "stroke") { st.stroke = parse_color_paint(&s); st.stroke_width = ctx.prop(n, "stroke-width").map(|w| first_number(Some(&w))).unwrap_or(1.0); + st.stroke_cap = match ctx.prop(n, "stroke-linecap").as_deref() { + Some("round") => LineCap::Round, + Some("square") => LineCap::Square, + _ => LineCap::Butt, + }; + st.stroke_join = match ctx.prop(n, "stroke-linejoin").as_deref() { + Some("round") => LineJoin::Round, + Some("bevel") => LineJoin::Bevel, + _ => LineJoin::Miter, + }; + st.stroke_miter_limit = ctx.prop(n, "stroke-miterlimit").map_or(4.0, |m| first_number(Some(&m)).max(1.0)); + let pattern: Vec = + ctx.prop(n, "stroke-dasharray").map_or(vec![], |d| d.split([' ', ',']).filter_map(|v| v.trim().parse().ok()).collect()); + let offset = ctx.prop(n, "stroke-dashoffset").map_or(0.0, |o| first_number(Some(&o))); + st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } if let Some(ls) = ctx.prop(n, "letter-spacing") { let v = first_number(Some(&ls)); diff --git a/crates/svg/tests/text_stroke.rs b/crates/svg/tests/text_stroke.rs new file mode 100644 index 00000000..81293f8e --- /dev/null +++ b/crates/svg/tests/text_stroke.rs @@ -0,0 +1,78 @@ +//! Strokes on type in SVG: character stroke options on live text (and back), runs that differ +//! from the first, and the object's own strokes drawn over the glyph outlines. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, TextRun}; +use vectorcraft_geom::Point; +use vectorcraft_svg::{ExportOptions, export, import}; + +fn text_doc(t: TextObject, items: Vec) -> Document { + let mut d = Document::new(300.0, 200.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + n.appearance.items = items; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn stroked() -> CharStyle { + CharStyle { + size: 40.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 2.0, + stroke_cap: LineCap::Round, + stroke_join: LineJoin::Round, + stroke_dash: Some(Dash { pattern: vec![3.0, 1.0], ..Default::default() }), + ..Default::default() + } +} + +#[test] +fn an_object_stroke_on_type_is_written_with_its_dashes() { + let mut st = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 3.0); + st.dash = Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() }); + st.opacity = 0.5; + let t = TextObject::point(Point::new(20.0, 100.0), "Dashed", CharStyle { size: 40.0, ..Default::default() }); + for outline_text in [false, true] { + let svg = export(&text_doc(t.clone(), vec![AppearanceItem::Stroke(st.clone())]), &ExportOptions { outline_text, ..Default::default() }); + assert!(svg.contains("stroke-dasharray=\"4 2\""), "{svg}"); + assert!(svg.contains("stroke-opacity=\"0.5\""), "{svg}"); + assert_eq!(svg.contains(" (bool, } } -/// The stroke the Stroke panel, Control bar and Properties show: the Appearance panel's active -/// item when it is a stroke, else the first selected object's top stroke. +/// The stroke the Stroke panel, Control bar and Properties show: while the Type tool edits text, +/// the selected characters' stroke, else [`vectorcraft_engine::Session::shown_stroke`]. pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { - first_node(app)?.appearance.stroke_for(app.session.appearance_item()).cloned() + if character::text_editing(app).is_some() { + return character::text_style(app).map(|(c, _)| c.stroke_layer()); + } + app.session.shown_stroke() } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 4b6de5e3..5e6aa68c 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -5,7 +5,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; -use super::{current_stroke, pstate, set_pstate}; +use super::{character, current_stroke, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; @@ -73,8 +73,14 @@ fn arrow_label(a: Option) -> String { } } +/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the +/// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { - app.run("stroke.set", p).ok(); + if character::text_editing(app).is_some() { + character::range_style(app, json!({ "strokeOptions": p })); + } else { + app.run("stroke.set", p).ok(); + } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -450,4 +456,28 @@ mod tests { assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip)); assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); } + + #[test] + fn while_typing_the_panel_strokes_the_selected_characters() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::{Session, ViewInfo}; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, 50.0, 200.0), v).unwrap(); + } + app.session.tool_text("Make this bold", v).unwrap(); + app.session.set_tool_option("select", &json!({"start": 5, "end": 9})); + set(&mut app, json!({"weight": 4, "join": "round"})); + let id = app.session.active().unwrap().selection.objects[0]; + let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("type") }; + let joins: Vec<(&str, LineJoin, f64)> = t.runs.iter().map(|r| (r.text.as_str(), r.style.stroke_join, r.style.stroke_width)).collect(); + assert_eq!(joins, [("Make ", LineJoin::Miter, 0.0), ("this", LineJoin::Round, 4.0), (" bold", LineJoin::Miter, 0.0)]); + // The panel shows the selected characters' stroke. + assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0))); + frame(&mut app); + } } diff --git a/docs/mcp.md b/docs/mcp.md index f92cb9fc..c40eb95c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -324,3 +324,12 @@ as a library (`.vcswatches` keeps colour models, global, spot, gradients and col CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from. + +## Strokes on type + +`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash +options go to every run (the object keeps no stroke of its own); an `item` still edits an object-level stroke added +with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the +characters as its place relative to the Characters row says. To stroke +some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, +dashOffset?, alignDashes?}}`.