Merge pull request #2297 from switch87/stroke-align-type-2294

Fix #2294: Align Stroke inside or outside on live type
This commit is contained in:
BFlat
2026-10-16 15:45:55 +07:00
committed by GitHub
15 changed files with 284 additions and 29 deletions
+26
View File
@@ -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());
+26 -5
View File
@@ -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<Dash>,
/// 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 {
+14 -9
View File
@@ -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<CommandSpec> {
"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<Option<Vec<f64>>>,
dash_offset: Option<f64>,
align_dashes: Option<bool>,
/// Align Stroke: centred, inside or outside the shape (or the glyphs, on type).
align: Option<StrokeAlign>,
}
/// 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<Value> {
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
let align = named(&ALIGNS, p, "align", C)?;
let arrow_align = match str_param(p, "arrowAlign") {
None => None,
Some("extend") => Some(ArrowAlign::Extend),
@@ -202,9 +210,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
};
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()));
}
_ => {
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+62 -1
View File
@@ -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");
}
+4 -1
View File
@@ -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]);
+18
View File
@@ -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");
}
}
+22
View File
@@ -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(&centre))), "centred: half the weight past the glyph, {:?}", ink(&centre));
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");
}
+47 -2
View File
@@ -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("</text>");
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 `<tspan>` per segment
@@ -2046,9 +2085,15 @@ impl Writer<'_> {
}
s.push_str("</textPath></text>");
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 `<tspan>` of a run whose properties are `run` sets over its `<text>`'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 {
+24
View File
@@ -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("<text"), !outline_text, "{svg}");
assert!(svg.contains("stroke-width=\"4\"") && svg.contains(side), "{align:?} {outline_text}: {svg}");
if !outline_text {
let text = &svg[svg.find("<text").unwrap()..svg.find("</text>").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("</text>").unwrap()..].contains("stroke-width=\"4\""), "the aligned stroke lies over the text");
}
}
}
}
+5 -2
View File
@@ -319,9 +319,12 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option<StrokeLayer> {
/// 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::<Option<((u64, u64), StrokeMixed)>>(ctx, "stroke-mixed") {
Some((k, mixed)) if k == key => mixed,
+25 -4
View File
@@ -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]
+6 -4
View File
@@ -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
+2
View File
@@ -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%) |
+2
View File
@@ -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. |