diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index bce0ad42b..05f6bc1bd 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1217,6 +1217,13 @@ impl Node { let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol)); self.projected(Some(b)).map(|b| b.inflate(o, o)) } + NodeKind::Text(t) => { + // The characters' strokes paint around the glyphs, scaled with the type. + let k = Scaling::factor(t.xf).unwrap_or(1.0); + let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; + let o = self.appearance.outset().max(chars); + self.geometric(symbols).map(|b| b.inflate(o, o)) + } _ => { let o = self.appearance.outset(); self.geometric(symbols).map(|b| b.inflate(o, o)) @@ -2336,6 +2343,25 @@ mod tests { assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); } + /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the + /// weight centred, the whole weight outside, nothing inside; scaled with the type. + #[test] + fn type_visual_bounds_grow_with_the_character_stroke() { + use crate::appearance::StrokeAlign; + use crate::text::{CharStyle, TextObject}; + let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); + t.xf = Affine::scale(2.0) * t.xf; + let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + let g = node(&t).geometric_bounds().unwrap(); + for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { + t.runs[0].style.stroke_align = align; + assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); + } + t.runs[0].style.stroke = vectorcraft_color::Paint::None; + assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); + } + /// A group holding a path, a compound, an image and a nested group, as a file writes it. fn nested_group() -> Node { let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ad0f6b92e..1fcad8a35 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; -use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer}; +use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { @@ -84,6 +84,10 @@ pub struct CharStyle { pub stroke_miter_limit: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub stroke_dash: Option, + /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the + /// glyph outlines, inside or outside them, as for object strokes; the type stays live. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_align: StrokeAlign, /// Superscript or subscript (Character panel). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub position: CharPosition, @@ -249,6 +253,7 @@ impl Default for CharStyle { stroke_join: LineJoin::Miter, stroke_miter_limit: 10.0, stroke_dash: None, + stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, proportional_metrics: false, @@ -266,8 +271,9 @@ impl CharStyle { 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. + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, + /// alignment and overprint), so type strokes share the object strokes' geometry, rendering + /// and export. pub fn stroke_layer(&self) -> StrokeLayer { StrokeLayer { overprint: self.overprint_stroke, @@ -275,11 +281,13 @@ impl CharStyle { join: self.stroke_join, miter_limit: self.stroke_miter_limit, dash: self.stroke_dash.clone(), + align: self.stroke_align, ..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). + /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the + /// character stroke (the options characters have no use for, such as arrowheads and width + /// profiles, are dropped). pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { self.stroke = st.paint.clone(); self.stroke_width = st.width; @@ -287,6 +295,19 @@ impl CharStyle { self.stroke_join = st.join; self.stroke_miter_limit = st.miter_limit; self.stroke_dash = st.dash.clone(); + self.stroke_align = st.align; + } + /// How far the character stroke paints past the glyph outlines (text space): half the weight + /// centred, the whole weight outside, nothing inside (miter spikes aside). + pub fn stroke_outset(&self) -> f64 { + if !self.has_stroke() { + return 0.0; + } + match self.stroke_align { + StrokeAlign::Center => self.stroke_width / 2.0, + StrokeAlign::Outside => self.stroke_width, + StrokeAlign::Inside => 0.0, + } } /// The character fill as a fill layer, overprinting as the characters do. fn fill_layer(&self) -> FillLayer { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 3db7df6d7..952bc30f6 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -3,7 +3,9 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ + ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile, +}; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -18,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{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|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?|id?, target?: \"object\"|\"contents\"} 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), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", + "{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|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align and the dash options as its characters' stroke (align: around the glyph outlines, the type staying live) (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", has_doc, stroke_set ), @@ -82,6 +84,8 @@ pub(crate) struct StrokeChange { dash: Option>>, dash_offset: Option, align_dashes: Option, + /// Align Stroke: centred, inside or outside the shape (or the glyphs, on type). + align: Option, } /// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent. @@ -116,6 +120,7 @@ impl StrokeChange { dash, dash_offset: num("dashOffset"), align_dashes: p.get("alignDashes").and_then(Value::as_bool), + align: named(&ALIGNS, p, "align", cmd)?, }) } @@ -127,12 +132,16 @@ impl StrokeChange { && self.dash.is_none() && self.dash_offset.is_none() && self.align_dashes.is_none() + && self.align.is_none() } pub(crate) fn apply(&self, st: &mut StrokeLayer) { if let Some(w) = self.weight { st.width = w; } + if let Some(a) = self.align { + st.align = a; + } if let Some(c) = self.cap { st.cap = c; } @@ -189,7 +198,6 @@ 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), @@ -202,9 +210,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = align { - st.align = a; - } if let Some(a) = sa { st.start_arrow = a; } @@ -270,7 +275,7 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them. One walk of the selection, so callers that ask + /// and whether Align Stroke applies to them (no open path among them). One walk of the selection, so callers that ask /// every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { let mut nodes = vec![]; @@ -284,7 +289,7 @@ impl DocState { for n in &nodes { match &n.kind { NodeKind::Text(t) => { - can_align = false; + // The characters' stroke aligns to the glyph outlines, which are closed. strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer())); } _ => { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 75fa760bb..a18de08ed 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), 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, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", + "{id? (default: the one selected type object), 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?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests_charstroke.rs index 41af4f1aa..815b7265f 100644 --- a/crates/engine/src/tests_charstroke.rs +++ b/crates/engine/src/tests_charstroke.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject}; use super::*; @@ -133,3 +133,65 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() { 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"); } + +/// Align Stroke on live type (Reddit redd.it/1dwhos6): the characters' stroke goes inside or +/// outside the glyphs without creating outlines, for every run or a range, and is kept by the +/// native format, the Eyedropper and Create Outlines. +#[test] +fn align_stroke_reaches_the_characters_stroke() { + let mut s = session(); + let id = stroked_text(&mut s); + assert!(s.active().unwrap().stroke_mixed().can_align, "type can take an inside or outside stroke"); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60})); + run(&mut s, "stroke.set", json!({"align": "outside"})); + let t = text(&s, id); + assert!(t.runs.iter().all(|r| r.style.stroke_align == StrokeAlign::Outside), "every run"); + assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added"); + assert_eq!(s.shown_stroke().unwrap().align, StrokeAlign::Outside, "the Stroke panel shows it"); + let m = s.active().unwrap().stroke_mixed(); + assert!(!m.align && m.can_align, "{m:?}"); + // A range takes its own alignment through strokeOptions; the runs then differ. + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeOptions": {"align": "inside"}})); + let t = text(&s, id); + assert_eq!(t.runs[0].style.stroke_align, StrokeAlign::Inside); + assert_eq!(t.runs.last().unwrap().style.stroke_align, StrokeAlign::Outside); + assert!(s.active().unwrap().stroke_mixed().align, "mixed alignments light no button"); + // document.inspect reports it, as for paths. + let doc = run(&mut s, "document.inspect", json!({})); + assert_eq!(doc["layers"][0]["children"][0]["strokeOptions"]["align"], "inside", "{doc}"); + // Saved and reopened. + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + let NodeKind::Text(b) = &back.node(id).unwrap().kind else { panic!("not type") }; + assert_eq!(b.runs, text(&s, id).runs); + // Create Outlines keeps it on the glyphs. + 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 mut aligns = vec![]; + d.node(NodeId(g)).unwrap().walk(&mut |n: &vectorcraft_doc::Node| aligns.extend(n.appearance.stroke().map(|st| st.align))); + assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}"); +} + +#[test] +fn the_eyedropper_carries_the_characters_stroke_alignment() { + let mut s = session(); + let id = stroked_text(&mut s); + run(&mut s, "stroke.set", json!({"align": "inside"})); + 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]})); + assert_eq!(s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().align, StrokeAlign::Inside); + run(&mut s, "stroke.set", json!({"ids": [rect], "align": "outside"})); + run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]})); + assert_eq!(text(&s, id).runs[0].style.stroke_align, StrokeAlign::Outside); +} + +#[test] +fn a_centred_character_stroke_writes_no_alignment() { + assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_align").is_none(), "the default isn't written"); + let mut old = serde_json::to_value(CharStyle { stroke_align: StrokeAlign::Outside, ..Default::default() }).unwrap(); + assert_eq!(old["stroke_align"], "Outside"); + old.as_object_mut().unwrap().remove("stroke_align"); + let st: CharStyle = serde_json::from_value(old).unwrap(); + assert_eq!(st.stroke_align, StrokeAlign::Center, "files written before it existed stroke centred"); +} diff --git a/crates/engine/src/tests_strokeux.rs b/crates/engine/src/tests_strokeux.rs index 7dd73cbeb..8effa2844 100644 --- a/crates/engine/src/tests_strokeux.rs +++ b/crates/engine/src/tests_strokeux.rs @@ -52,12 +52,15 @@ fn mixed_values_and_align_stroke_follow_the_selection() { select(&mut s, &[a, b]); let m = s.active().unwrap().stroke_mixed(); assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}"); - // An open path or type in the selection can't take an inside or outside stroke. + // An open path in the selection can't take an inside or outside stroke; type can (its + // characters' stroke goes around the glyphs). let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap()); select(&mut s, &[a, line]); assert!(!s.active().unwrap().stroke_mixed().can_align); let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap()); select(&mut s, &[text]); + assert!(s.active().unwrap().stroke_mixed().can_align); + select(&mut s, &[text, line]); assert!(!s.active().unwrap().stroke_mixed().can_align); // Grouped closed paths can. select(&mut s, &[a, b]); diff --git a/crates/pdf/src/tests_textstroke.rs b/crates/pdf/src/tests_textstroke.rs index b6fd50347..2669f0284 100644 --- a/crates/pdf/src/tests_textstroke.rs +++ b/crates/pdf/src/tests_textstroke.rs @@ -37,3 +37,21 @@ fn text_strokes_keep_cap_join_miter_dashes_and_alignment() { // An inside stroke is drawn twice as wide, clipped to the glyphs. assert!(strokes.iter().any(|s| s.dash.is_none() && (s.width - 6.0).abs() < 1e-3), "{strokes:?}"); } + +/// Align Stroke on live type: the characters' stroke drawn twice as wide, clipped inside or +/// outside the glyphs, as on the canvas; the type stays text. +#[test] +fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() { + for (align, clip) in [(StrokeAlign::Inside, false), (StrokeAlign::Outside, true)] { + let style = CharStyle { size: 48.0, stroke: Paint::solid(Color::BLACK), stroke_width: 1.5, stroke_align: align, ..Default::default() }; + let mut d = Document::new(240.0, 100.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 70.0), "Wave", style)))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let pdf = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned(); + let ops: Vec<&str> = pdf.split_ascii_whitespace().collect(); + assert!(ops.windows(2).any(|w| w == ["3", "w"]), "{align:?}: twice the weight"); + assert!(ops.contains(&"BT"), "{align:?}: the type stays text"); + assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side"); + } +} diff --git a/crates/render/src/tests_charstroke.rs b/crates/render/src/tests_charstroke.rs index ce44e8e94..f2d9e85b4 100644 --- a/crates/render/src/tests_charstroke.rs +++ b/crates/render/src/tests_charstroke.rs @@ -81,3 +81,25 @@ fn character_and_object_strokes_on_type_are_dashed() { let p = half.pixel(x0, (y0 + y1) / 2); assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}"); } + +/// Align Stroke on live type: an outside character stroke paints its whole weight around the +/// glyphs and leaves them unpainted, an inside one stays within them, as on paths. +#[test] +fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() { + use vectorcraft_doc::StrokeAlign; + let glyph = ink(&render_text("I", CharStyle::default(), vec![])); + let reach = |b: (u32, u32, u32, u32)| { + let d = |a: u32, b: u32| a as i64 - b as i64; + d(glyph.0, b.0).max(d(glyph.1, b.1)).max(d(b.2, glyph.2)).max(d(b.3, glyph.3)) + }; + let style = |align| CharStyle { fill: Paint::None, stroke_align: align, ..stroked(LineJoin::Round) }; + let centre = render_text("I", style(StrokeAlign::Center), vec![]); + let outside = render_text("I", style(StrokeAlign::Outside), vec![]); + let inside = render_text("I", style(StrokeAlign::Inside), vec![]); + assert!((7..=9).contains(&reach(ink(¢re))), "centred: half the weight past the glyph, {:?}", ink(¢re)); + assert!((15..=17).contains(&reach(ink(&outside))), "outside: the whole weight past the glyph, {:?}", ink(&outside)); + assert!(reach(ink(&inside)) <= 0, "inside: within the glyph, {:?}", ink(&inside)); + let mid = ((glyph.0 + glyph.2) / 2, (glyph.1 + glyph.3) / 2); + assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted"); + assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 122c532b9..b51dffe43 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1716,7 +1716,9 @@ impl Writer<'_> { let saved = std::mem::replace(&mut self.xf, to_user); let fill = self.paint(&st.fill, Some(bounds)); p.push(("fill", fill)); - if st.has_stroke() { + // An inside or outside stroke is drawn on the glyph outlines instead + // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. + if st.has_stroke() && !aligned(st) { // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans // the text grown by half the weight, as on the canvas. let layer = st.stroke_layer(); @@ -1744,7 +1746,10 @@ impl Writer<'_> { let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. })); let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); - if !painted && !inline { + // Live type with an inside or outside character stroke writes that stroke apart from the + // text, in the group. + let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); + if !painted && !inline && !apart { return chars(self, n); } let id = self.id_attr(n); @@ -1898,6 +1903,40 @@ impl Writer<'_> { } s.push_str(""); self.line(&s); + self.aligned_char_strokes(t); + } + + /// The inside and outside character strokes of live type `t`, over its text (which goes + /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn + /// twice as wide and clipped or masked to its side as aligned path strokes are + /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. + fn aligned_char_strokes(&mut self, t: &TextObject) { + if !t.runs.iter().any(|r| aligned(&r.style)) { + return; + } + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let bars = vectorcraft_text::decorations(&lay, db, t); + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { + let mut bp = BezPath::new(); + for g in lay.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + bp.extend(bar.iter()); + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(&bp); + let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; + let d = self.path_d(&pd, self.xf); + self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; } /// One line of laid-out text starting at `start`. Default: a positioned `` per segment @@ -2046,9 +2085,15 @@ impl Writer<'_> { } s.push_str(""); self.line(&s); + self.aligned_char_strokes(t); } } +/// Does a character style stroke inside or outside its glyphs (not centred)? +fn aligned(st: &CharStyle) -> bool { + st.has_stroke() && st.stroke_align != StrokeAlign::Center +} + /// 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 { diff --git a/crates/svg/tests/text_stroke.rs b/crates/svg/tests/text_stroke.rs index 11a7ece96..33ee81354 100644 --- a/crates/svg/tests/text_stroke.rs +++ b/crates/svg/tests/text_stroke.rs @@ -78,3 +78,27 @@ fn a_run_without_the_first_runs_stroke_resets_it() { ); assert!(svg.contains("stroke-linejoin=\"round\"") && svg.contains("stroke-dasharray=\"3 1\""), "{svg}"); } + +/// Align Stroke on live type: the text stays live (filled, without the stroke) and the aligned +/// stroke is drawn on the glyph outlines, twice as wide and clipped (inside) or masked (outside), +/// as aligned strokes on paths are; outlined type carries it on its glyphs. +#[test] +fn an_aligned_character_stroke_keeps_the_text_live() { + use vectorcraft_doc::StrokeAlign; + for (align, side) in [(StrokeAlign::Outside, "mask=\"url(#"), (StrokeAlign::Inside, "clip-path=\"url(#")] { + let style = CharStyle { size: 40.0, stroke: Paint::solid(Color::BLACK), stroke_width: 2.0, stroke_align: align, ..Default::default() }; + let mut t = TextObject::point(Point::new(20.0, 100.0), "Live", style); + t.runs.push(TextRun { text: " plain".into(), style: CharStyle { size: 40.0, ..stroked() }, inline: None }); + for outline_text in [false, true] { + let svg = export(&text_doc(t.clone(), vec![]), &ExportOptions { outline_text, ..Default::default() }); + assert_eq!(svg.contains("").unwrap()]; + assert!(!text.contains("stroke-width=\"4\""), "the live text doesn't stroke the aligned run: {text}"); + assert!(text.contains("stroke-width=\"2\""), "a centred run keeps its stroke on the text: {text}"); + assert!(svg[svg.find("").unwrap()..].contains("stroke-width=\"4\""), "the aligned stroke lies over the text"); + } + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8f36f231d..e76596486 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -319,9 +319,12 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { /// Which Stroke panel values the selection doesn't share and whether Align Stroke applies /// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type -/// tool edits text, the selected characters' stroke shows as it is. +/// tool edits text, the selected characters' stroke shows as it is, and aligns to their glyphs. pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed { - let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() }; + if character::text_editing(app).is_some() { + return StrokeMixed { can_align: true, ..Default::default() }; + } + let Some(st) = app.session.active() else { return StrokeMixed::default() }; let key = (st.uid, st.revision); match pstate::>(ctx, "stroke-mixed") { Some((k, mixed)) if k == key => mixed,