From ca0673e0bca54ebea7388041e91c55a7fb3c9394 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 00:35:02 +0200 Subject: [PATCH 1/2] Align Stroke inside or outside on live type (#2294) The characters' stroke takes Align Stroke like an object stroke: CharStyle keeps stroke_align (written only when not centred), stroke.set and text.setRangeStyle strokeOptions apply align to type, and the Stroke panel enables the buttons for type. Canvas, PDF and EPS draw it around the glyphs through stroke_layer(); SVG keeps the text live and writes the aligned stroke on the glyph outlines, clipped or masked. Type's visual bounds grow with its character strokes. --- crates/doc/src/node.rs | 26 +++++++++++ crates/doc/src/text.rs | 31 ++++++++++--- crates/engine/src/cmd/stroke.rs | 21 +++++---- crates/engine/src/cmd/textedit.rs | 2 +- crates/engine/src/tests_charstroke.rs | 64 ++++++++++++++++++++++++++- crates/engine/src/tests_strokeux.rs | 5 ++- crates/pdf/src/tests_textstroke.rs | 18 ++++++++ crates/render/src/tests_charstroke.rs | 22 +++++++++ crates/svg/src/export.rs | 49 +++++++++++++++++++- crates/svg/tests/text_stroke.rs | 24 ++++++++++ crates/ui-egui/src/panels/mod.rs | 7 ++- 11 files changed, 249 insertions(+), 20 deletions(-) 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, From 065e237f7fe7d6557dbf8e886fd24d69f538ee29 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 00:57:31 +0200 Subject: [PATCH 2/2] Align Stroke on live type: tests for the Type tool's selection, docs (#2294) The Stroke panel enables Align Stroke while the Type tool edits text, and styles the selected characters. docs: mcp.md (stroke.set and strokeOptions align on type), type-parity and target-app-parity rows. --- crates/engine/src/cmd/stroke.rs | 6 ++--- crates/engine/src/tests_charstroke.rs | 9 +++---- crates/ui-egui/src/panels/tests_strokeux.rs | 29 ++++++++++++++++++--- docs/mcp.md | 10 ++++--- docs/target-app-parity.md | 2 ++ docs/type-parity.md | 2 ++ 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 952bc30f6..9c1c49a68 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,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, 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)", + "{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 ), @@ -275,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 (no open path among 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 { diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests_charstroke.rs index 815b7265f..5ccdedce4 100644 --- a/crates/engine/src/tests_charstroke.rs +++ b/crates/engine/src/tests_charstroke.rs @@ -136,7 +136,7 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() { /// 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. +/// native format and Create Outlines. #[test] fn align_stroke_reaches_the_characters_stroke() { let mut s = session(); @@ -173,17 +173,16 @@ fn align_stroke_reaches_the_characters_stroke() { 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 the_eyedropper_carries_the_characters_stroke_alignment() { +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); - 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] 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. |