mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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%) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user