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..9c1c49a68 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 (around its glyph outlines; the type stays live) 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)", 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,8 +275,8 @@ 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 - /// every frame cache it by revision. + /// 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![]; for id in &self.selection.objects { @@ -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..5ccdedce4 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,64 @@ 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 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:?}"); +} + +/// Copying type's appearance onto a path keeps its characters' stroke alignment (the Eyedropper's +/// own stroke attributes have none, on paths as on type). +#[test] +fn a_path_copying_types_appearance_takes_its_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); +} + +#[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, diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index b10b80da9..df836118e 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -1,5 +1,6 @@ //! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and -//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the +//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type (also +//! while the Type tool edits it), and the //! weight presets. use serde_json::json; @@ -82,7 +83,7 @@ fn mixed_weights_show_blank() { } #[test] -fn inside_and_outside_are_off_for_open_paths_and_type() { +fn inside_and_outside_are_off_for_open_paths_and_on_for_type() { let ctx = egui::Context::default(); let mut app = app_with_rect(); let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align; @@ -95,9 +96,29 @@ fn inside_and_outside_are_off_for_open_paths_and_type() { let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap(); run(&mut app, "select.set", json!({ "ids": [id] })); run(&mut app, "paint.setStroke", json!({"color": "#000000"})); - let undo = app.session.active().unwrap().history.undo.len(); click(&ctx, &mut app, at, stroke::show); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't"); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "type's characters do, around their glyphs"); +} + +/// While the Type tool edits text, Align Stroke styles the selected characters' stroke. +#[test] +fn align_stroke_styles_the_characters_the_type_tool_selects() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 2); + app.session.select_tool("type", Default::default()).unwrap(); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hello"}))["id"].as_u64().unwrap(); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + assert!(character::text_editing(&app).is_some()); + assert!(stroke_mixed(&app, &ctx).can_align, "the buttons are on"); + click(&ctx, &mut app, at, stroke::show); + let vectorcraft_doc::NodeKind::Text(t) = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { + panic!("not type") + }; + let aligns: Vec<_> = t.runs.iter().map(|r| r.style.stroke_align).collect(); + assert_eq!(aligns, [vectorcraft_doc::StrokeAlign::Outside, vectorcraft_doc::StrokeAlign::Center], "only the selected characters"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 38d0a0e8a..afa8c836d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1323,12 +1323,14 @@ groups naming items that aren't there dropped. Files written before groups load ## 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 +`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit, `align` 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?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first +some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, align?, +dash?, dashOffset?, alignDashes?}}`. `align: "inside"|"outside"` keeps the characters' stroke on that side of the +glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export +writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone. ## Inline graphics in type diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f94b53dd..5d8be640a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -373,6 +373,7 @@ since 2026-10-01. | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | | **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | +| **Align Stroke on live type** | beyond Illustrator | The Stroke panel's Align Stroke works on type's character stroke: inside or outside the glyphs without Create Outlines, the type staying editable (as InDesign does; users have asked Illustrator for it since 2010, e.g. [redd.it/1dwhos6](https://redd.it/1dwhos6)). Canvas, PDF and EPS clip it to its side of the glyphs; SVG keeps the text live and draws the stroke on the glyph outlines (#2294) | ## Evidence: what ships today, by area @@ -677,6 +678,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | Symbols: Symbol Options › Export Type (Graphic / Movie Clip) with Registration; Symbols score 85% → 87% (estimated) | | 2026-10-14 | minor | Localization: Brazilian Portuguese (`pt-br`) catalog completed under the tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85; full readiness unchanged (~62%) | diff --git a/docs/type-parity.md b/docs/type-parity.md index 16b61c618..dcbc5897c 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -51,6 +51,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: ruby, manual tate-chu-yoko | missing | #633 | | Inline symbols in text | beyond Illustrator | not scored | +| Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) | Character Styles menu New/Redefine and footer New capture the first selected character of a nonempty Type selection, including exact style-run and UTF-8 boundaries; New adds a definition without applying it. New/Redefine commands accept optional `end` for range capture. Start-only command affinity, existing UI caret/object behavior and paragraph capture at `start` remain unchanged. Pointer regressions assert captured definitions, linked-text updates and local overrides, complete-document Undo/Redo and saved state, with whole-object, collapsed-caret and range-Apply controls. Capture closes prior typing while preserving the Type selection. Live text and Select All regressions exercise menu New/Redefine and footer New, asserting that further typing retains the captured definition and that typing, capture and further typing have separate Undo/Redo steps. Focused engine and egui input tests pass for boundary capture, all three live-typing capture routes and the existing compatibility controls. Full workspace CI passes. @@ -58,6 +59,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy; a double-click on area type's bottom middle handle turns on Auto Size (#1940); scores and hours unchanged | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. |