diff --git a/ROADMAP.md b/ROADMAP.md index 16bd8b1da..e055747f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Type Tools add-on: Release Type on a Path, #5532; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -131,6 +131,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). @@ -219,6 +220,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | | 2026-11-02 | minor | Closed font-family fields accept wheel input for selected text in Character, Properties and Control (#5253); scores and estimates unchanged. | | 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. | diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index a478b8c00..bc1d4e4f2 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -2,22 +2,27 @@ //! the selected characters letter by letter, from a start value to an end value along a shape //! (straight, eased, a bow, a spike, or at random), while the text stays live. Values are set as //! the Character panel sets them (`text.setRangeStyle`), in its units and ranges -//! (). Its submenu -//! (Object › Plug-ins › Type Tools) and dialog are the UI's. +//! (). Release Type on a Path +//! splits type on a path into point type holding the same text and the plain path it was set on +//! (#5532). The reference app has no command for it, so it goes beyond parity and changes no +//! parity score. Its submenu (Object › Plug-ins › Type Tools) and dialogs are the UI's. #[cfg(test)] mod tests; use serde_json::{Value, json}; +use vectorcraft_color::Paint; use vectorcraft_color::recolor::Rng; -use vectorcraft_doc::{NodeId, TextRun}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; +use vectorcraft_geom::{Affine, ArcPath, Point}; use vectorcraft_text::edit; use super::super::menucmds::whole_or; use super::super::object::choice; use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; use super::super::typecmd::refresh_bounds; -use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or}; +use super::super::typemenu::text_ids; +use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets}; use crate::{EngineError, Result, Session}; /// An attribute Text Tweaker sets: its `text.setRangeStyle` param, the values it takes and its @@ -41,17 +46,33 @@ pub const SHAPES: [&str; 5] = ["straight", "ease", "bow", "spike", "random"]; const MAX_CHARS: usize = 100_000; pub fn specs() -> Vec { - vec![cmd!( - "addon.type.textTweaker", - "Text Tweaker", - ["Object", "Plug-ins", "Type Tools"], - None, - "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte (range endpoints inside a character round down to its start), ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", - has_selection, - text_tweaker - )] + vec![ + cmd!( + "addon.type.textTweaker", + "Text Tweaker", + ["Object", "Plug-ins", "Type Tools"], + None, + "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte (range endpoints inside a character round down to its start), ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", + has_selection, + text_tweaker + ), + cmd!( + "addon.type.releasePath", + "Release Type on a Path…", + ["Object", "Plug-ins", "Type Tools"], + None, + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + has_selection, + release_path + ), + ] } +/// Release Type on a Path's command (the id in [`specs`]). +pub const RELEASE: &str = "addon.type.releasePath"; +/// What Release Type on a Path keeps: both, the path only, or the text only. +pub const RELEASE_MODES: [&str; 3] = ["both", "path", "text"]; + /// Where along `shape` (0 at `from`, 1 at `to`) character `i` of `n` lands; `random` for the /// random shape. fn amount(shape: &str, i: usize, n: usize, random: f64) -> f64 { @@ -187,3 +208,143 @@ fn tweak(runs: Vec, changes: &[(usize, CharChange)]) -> Vec { edit::normalize(&mut out); out } + +/// Type on a path among the objects `p` names (or the selection), inside groups too, in stacking +/// order. An id that names no object is an error. +fn path_types(s: &Session, p: &Value) -> Result> { + let ids = targets(s, p)?; + let d = &s.doc()?.doc; + if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { + return Err(EngineError::NoNode(*id)); + } + let on_path = |id: &NodeId| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::OnPath { .. })); + Ok(text_ids(d, &ids).into_iter().filter(on_path).collect()) +} + +/// Where the first character laid out in `lay` starts its baseline: the pen position of the first +/// glyph in text order (a list marker, which has no characters, before the character it leads). +/// None without a glyph. +fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { + lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin) +} + +/// `t`, type on a path, as point type on one straight line: the same runs and character and +/// paragraph attributes, its baseline starting where its first character sat on the path (in the +/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for +/// other type, or when it can't be placed. +fn released_text(t: &TextObject) -> Option { + let TextKind::OnPath { path, .. } = &t.kind else { return None }; + let db = vectorcraft_text::FontDb::global(); + // Where the first character sat. Text that didn't fit on the path has no glyph there: it + // starts at the start bracket. + let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| { + let ap = ArcPath::new(path); + let (from, _) = t.kind.path_span()?; + let (p, _) = ap.at(from * ap.len()); + (p.x.is_finite() && p.y.is_finite()).then_some(p) + })?; + let mut out = t.clone(); + out.kind = TextKind::Point; + out.path_effect = Default::default(); + out.path_align = Default::default(); + out.path_spacing = 0.0; + // Point type is in no thread: its first paragraph starts in it. + out.first_para_continues = false; + // Where the point type puts its own first character; the origin moves so it lands at `at`, + // whatever the paragraph's alignment and direction. + let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO); + let shift = at - own; + if !(shift.x.is_finite() && shift.y.is_finite()) { + return None; + } + out.xf *= Affine::translate(shift); + refresh_bounds(&mut out); + Some(out) +} + +/// The path `t` is set on, as a plain path object `id` in document space, with no fill and no +/// stroke: type paths carry no paint of their own. None for other type. +fn released_path(id: NodeId, t: &TextObject) -> Option { + Some(Node::path(id, t.type_path()?, Appearance::basic(Paint::None, Paint::None, 1.0))) +} + +fn release_path(s: &mut Session, p: &Value) -> Result { + const C: &str = RELEASE; + let mode = choice(p, C, "mode", &RELEASE_MODES)?.unwrap_or("both"); + let keep = switch_or(p, "keepOriginal", false, C)?; + let want_path = mode != "text"; + let ids = path_types(s, p)?; + if ids.is_empty() { + return Ok(json!({ "ids": [], "paths": [], "texts": [] })); + } + // The point type, worked out before the edit: one object that can't be placed fails the run. + let mut plans: Vec<(NodeId, Option)> = Vec::with_capacity(ids.len()); + { + let d = &s.doc()?.doc; + for id in ids { + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { continue }; + let text = match mode { + "path" => None, + _ => Some(released_text(t).ok_or_else(|| bad(C, format!("object {} can't be set as point type", id.0)))?), + }; + plans.push((id, text)); + } + } + let (paths, texts) = s.edit("Release Type on a Path", |d, sel| { + let (mut paths, mut texts) = (vec![], vec![]); + for (id, text) in plans { + let (path, text) = release_one(d, id, text, want_path, keep)?; + paths.extend(path); + texts.extend(text); + } + sel.set(paths.iter().chain(&texts).copied()); + Ok((paths, texts)) + })?; + // Bottom to top. + let d = &s.doc()?.doc; + let mut all: Vec<(Vec, NodeId)> = paths.iter().chain(&texts).map(|id| (d.index_path(*id).unwrap_or_default(), *id)).collect(); + all.sort(); + let nums = |v: &[NodeId]| v.iter().map(|i| i.0).collect::>(); + let all: Vec = all.into_iter().map(|(_, id)| id).collect(); + Ok(json!({ "ids": nums(&all), "paths": nums(&paths), "texts": nums(&texts) })) +} + +/// Release type on a path `id` in `d`: its path (`want_path`) where the type was, or just above it +/// when the original stays (`keep`), then the point type `text` (None: no text) above that. +/// Without `keep` the original goes, and released text takes its place as the same object, so a +/// frame of threaded text leaves its thread with the text it holds, as Convert To Point Type's +/// does. The released (path, text). +fn release_one(d: &mut Document, id: NodeId, text: Option, want_path: bool, keep: bool) -> Result<(Option, Option)> { + let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let original = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let NodeKind::Text(t) = &original.kind else { return Ok((None, None)) }; + let path = + if want_path { Some(released_path(d.alloc_id(), t).ok_or_else(|| bad(RELEASE, format!("object {} has no path", id.0)))?) } else { None }; + if !keep && text.is_none() { + d.remove(id)?; + } + // Where the next released object goes: above the original when it stays, else in its place. + let mut at = if keep { idx + 1 } else { idx }; + let path = match path { + Some(n) => { + at += 1; + Some(d.insert(parent, at - 1, n)?) + } + None => None, + }; + let text = match text { + None => None, + Some(text) if keep => { + let mut n = original.clone(); + n.id = d.alloc_id(); + n.kind = NodeKind::Text(Box::new(text)); + Some(d.insert(parent, at, n)?) + } + Some(text) => { + // The original, now just above the path. + d.node_mut(id).ok_or(EngineError::NoNode(id))?.kind = NodeKind::Text(Box::new(text)); + Some(id) + } + }; + Ok((path, text)) +} diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index 7f05cb823..e597525c9 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -1,5 +1,5 @@ //! Text Tweaker: the values each shape gives each character, what it skips and changes, one undo -//! step, and bad params refused without a change. +//! step, and bad params refused without a change. Release Type on a Path ([`release`]). use serde_json::json; use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; @@ -221,3 +221,386 @@ fn it_needs_a_selection() { select(&mut s, &[]); assert!(s.execute(C, &json!({})).is_err()); } + +/// Release Type on a Path (#5532): each mode, Keep Original, mixed and empty selections, closed, +/// open, curved and flipped paths, transformed and grouped type, multi-style and unicode text, +/// undo and redo, the preview a dialog shows, and bad params. +mod release { + use serde_json::{Value, json}; + use vectorcraft_color::Paint; + use vectorcraft_doc::{Appearance, Document, Justify, NodeId, NodeKind, TextKind, TextObject}; + use vectorcraft_geom::{PathData, Point}; + + use crate::Session; + + const C: &str = "addon.type.releasePath"; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s + } + + fn new_id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) + } + + /// 12 pt type `words` on a path along `d` (SVG path data), starting where the path is nearest + /// `at`, selected. + fn path_type(s: &mut Session, d: &str, at: [f64; 2], words: &str) -> NodeId { + let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap(); + let id = new_id(&s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": words, "size": 12, "at": at})).unwrap()); + select(s, &[id]); + id + } + + fn rect(s: &mut Session, x: f64) -> NodeId { + new_id(&s.execute("shape.rectangle", &json!({"x": x, "y": 20, "width": 20, "height": 20})).unwrap()) + } + + fn point_type(s: &mut Session) -> NodeId { + new_id(&s.execute("text.create", &json!({"x": 40, "y": 500, "text": "point", "size": 12})).unwrap()) + } + + fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); + } + + fn doc(s: &Session) -> Document { + (*s.doc().unwrap().doc).clone() + } + + fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.objects.clone() + } + + fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } + } + + fn path(s: &Session, id: NodeId) -> PathData { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + k => panic!("not a path: {k:?}"), + } + } + + /// (parent, index) of `id`. + fn place(s: &Session, id: NodeId) -> (Option, usize) { + let (parent, idx, _) = s.doc().unwrap().doc.position(id).unwrap(); + (parent, idx) + } + + /// Each glyph's pen position in the document and its baseline angle, in text order. + fn pens(t: &TextObject) -> Vec<(Point, f64)> { + let mut g: Vec<_> = + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.into_iter().map(|g| (g.byte, t.xf * g.origin, g.angle)).collect(); + g.sort_by_key(|g| g.0); + g.into_iter().map(|(_, p, a)| (p, a)).collect() + } + + /// How many path objects the document has. + fn paths_in(s: &Session) -> usize { + let mut n = 0; + for l in &s.doc().unwrap().doc.layers { + l.walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); + } + n + } + + fn close(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-4 + } + + fn ids(v: &Value, key: &str) -> Vec { + v[key].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() + } + + /// `after`, released from `before`: point type with the same text and attributes, on one + /// straight line whose first character starts where `before`'s did on its path, with no Type + /// on a Path Options left. + fn assert_released(before: &TextObject, after: &TextObject) { + assert_eq!(after.kind, TextKind::Point); + assert_eq!(after.runs, before.runs, "the same text and character attributes"); + assert_eq!((&after.para, &after.paras), (&before.para, &before.paras), "the same paragraph attributes"); + assert_eq!(after.plain_text(), before.plain_text()); + assert_eq!((after.path_effect, after.path_align, after.path_spacing), Default::default(), "path options dropped"); + let (was, now) = (pens(before), pens(after)); + assert!(!now.is_empty()); + if let Some((first, _)) = was.first() { + assert!(close(now[0].0, *first), "the baseline starts where the first character sat: {:?} vs {first:?}", now[0].0); + } + assert!(now.iter().all(|(_, a)| *a == 0.0), "a straight baseline"); + // On one line through the first pen, along the object's own x axis. + let [a, b, ..] = after.xf.as_coeffs(); + let axis = vectorcraft_geom::Vec2::new(a, b); + for (p, _) in &now { + let off = *p - now[0].0; + assert!(off.cross(axis).abs() < 1e-4 * axis.hypot().max(1.0) * off.hypot().max(1.0), "{p:?} is on the line"); + } + } + + #[test] + fn both_gives_point_type_and_the_path_in_the_types_place() { + let mut s = session(); + let below = rect(&mut s, 10.0); + let id = path_type(&mut s, "M100 300 C200 150 400 150 500 300", [100.0, 300.0], "Curved line"); + let above = rect(&mut s, 50.0); + select(&mut s, &[id]); + let before = text(&s, id); + let (layer, idx) = place(&s, id); + let (start, n) = (doc(&s), steps(&s)); + + let out = s.execute(C, &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + let (paths, texts) = (ids(&out, "paths"), ids(&out, "texts")); + assert_eq!((paths.len(), texts.len()), (1, 1)); + let (p, t) = (paths[0], texts[0]); + assert_eq!(ids(&out, "ids"), [p, t], "bottom to top"); + assert_eq!(selected(&s), [p, t], "the released objects are selected"); + + // The path: its exact shape, anchors and handles, with no fill and no stroke, where the type was. + assert_eq!(path(&s, p), before.type_path().unwrap()); + assert_eq!(*s.doc().unwrap().doc.node(p).unwrap().appearance, Appearance::basic(Paint::None, Paint::None, 1.0)); + assert_eq!( + (place(&s, below), place(&s, p), place(&s, t), place(&s, above)), + ((layer, idx - 1), (layer, idx), (layer, idx + 1), (layer, idx + 2)) + ); + // The text replaces the type on a path. + assert_released(&before, &text(&s, t)); + assert_eq!(t, id, "the same type object, now point type"); + + let released = doc(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc(&s), start, "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(doc(&s), released, "redone"); + } + + #[test] + fn path_only_and_text_only() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [300.0, 300.0], "Line"); + let before = text(&s, id); + let (layer, idx) = place(&s, id); + let out = s.execute(C, &json!({"mode": "path"})).unwrap(); + let p = ids(&out, "paths")[0]; + assert!(ids(&out, "texts").is_empty()); + assert!(s.doc().unwrap().doc.node(id).is_none(), "the type is gone"); + assert_eq!((path(&s, p), place(&s, p)), (before.type_path().unwrap(), (layer, idx))); + assert_eq!(selected(&s), [p]); + + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[id]); + let out = s.execute(C, &json!({"mode": "text"})).unwrap(); + assert!(ids(&out, "paths").is_empty()); + assert_eq!((ids(&out, "texts"), place(&s, id)), (vec![id], (layer, idx)), "the type stays in its place"); + assert_released(&before, &text(&s, id)); + assert_eq!(paths_in(&s), 0, "no path made"); + } + + #[test] + fn keep_original_adds_the_released_objects_above_it() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Keep"); + let (before, (layer, idx)) = (text(&s, id), place(&s, id)); + let original = s.doc().unwrap().doc.node(id).cloned().unwrap(); + let out = s.execute(C, &json!({"keepOriginal": true})).unwrap(); + let (p, t) = (ids(&out, "paths")[0], ids(&out, "texts")[0]); + assert_eq!(s.doc().unwrap().doc.node(id), Some(&original), "the type on a path stays as it is"); + assert_eq!((place(&s, id), place(&s, p), place(&s, t)), ((layer, idx), (layer, idx + 1), (layer, idx + 2))); + assert_ne!(t, id); + assert_eq!(selected(&s), [p, t], "only the released objects are selected"); + assert_released(&before, &text(&s, t)); + + // Text only, kept: the copy goes just above. + select(&mut s, &[id]); + let out = s.execute(C, &json!({"mode": "text", "keepOriginal": true})).unwrap(); + let t2 = ids(&out, "texts")[0]; + assert_eq!(place(&s, t2), (layer, idx + 1)); + assert_released(&before, &text(&s, t2)); + } + + #[test] + fn other_objects_are_left_alone() { + let mut s = session(); + let r = rect(&mut s, 10.0); + let pt = point_type(&mut s); + let a = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "One"); + let b = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "Two"); + let others = [r, pt].map(|id| s.doc().unwrap().doc.node(id).cloned().unwrap()); + select(&mut s, &[r, pt, a, b]); + let n = steps(&s); + let out = s.execute(C, &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step for the whole selection"); + assert_eq!((ids(&out, "paths").len(), ids(&out, "texts")), (2, vec![a, b])); + assert_eq!([r, pt].map(|id| s.doc().unwrap().doc.node(id).cloned().unwrap()), others); + assert_eq!(selected(&s).len(), 4, "the two paths and the two texts"); + assert!(!selected(&s).contains(&r) && !selected(&s).contains(&pt)); + + // `ids` picks the objects instead of the selection. + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[r]); + let out = s.execute(C, &json!({"ids": [r.0, b.0]})).unwrap(); + assert_eq!(ids(&out, "texts"), [b]); + assert!(matches!(text(&s, a).kind, TextKind::OnPath { .. })); + } + + #[test] + fn nothing_to_release_changes_nothing() { + let mut s = session(); + let r = rect(&mut s, 10.0); + let pt = point_type(&mut s); + select(&mut s, &[r, pt]); + let (before, n, sel) = (doc(&s), steps(&s), selected(&s)); + assert_eq!(s.execute(C, &json!({})).unwrap(), json!({"ids": [], "paths": [], "texts": []})); + assert_eq!((doc(&s), steps(&s), selected(&s)), (before, n, sel), "no change, no step"); + } + + #[test] + fn closed_and_open_paths_keep_their_shape() { + for (d, closed) in [ + ("M200 200 L400 200 L400 400 L200 400 Z", true), + ("M100 300 C150 100 250 500 300 300 S450 100 500 300", false), + ("M300 150 C380 150 450 220 450 300 C450 380 380 450 300 450 C220 450 150 380 150 300 C150 220 220 150 300 150 Z", true), + ] { + let mut s = session(); + let id = path_type(&mut s, d, [200.0, 200.0], "Round and round"); + let before = text(&s, id); + let out = s.execute(C, &json!({})).unwrap(); + let p = path(&s, ids(&out, "paths")[0]); + assert_eq!(p, before.type_path().unwrap(), "{d}"); + assert_eq!(p.is_closed(), closed, "{d}"); + assert_released(&before, &text(&s, ids(&out, "texts")[0])); + } + } + + #[test] + fn flipped_type_releases_its_path_as_it_runs() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 C200 150 400 150 500 300", [300.0, 200.0], "Flipped"); + s.execute("type.pathOptions", &json!({"flip": true, "effect": "skew", "alignToPath": "center", "spacing": 4})).unwrap(); + let before = text(&s, id); + let out = s.execute(C, &json!({})).unwrap(); + assert_eq!(path(&s, ids(&out, "paths")[0]), before.type_path().unwrap()); + assert_released(&before, &text(&s, id)); + } + + #[test] + fn transformed_type_keeps_its_transform() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 C200 150 400 150 500 300", [100.0, 300.0], "Turned"); + s.execute("object.rotate", &json!({"angle": 30, "origin": [300, 300]})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [300, 300]})).unwrap(); + let before = text(&s, id); + let out = s.execute(C, &json!({})).unwrap(); + assert_eq!(path(&s, ids(&out, "paths")[0]), before.type_path().unwrap(), "the transform is in the path"); + let after = text(&s, id); + assert_eq!(after.xf.as_coeffs()[..4], before.xf.as_coeffs()[..4], "turned and scaled as the type was"); + assert_released(&before, &after); + } + + #[test] + fn grouped_type_releases_inside_its_group() { + let mut s = session(); + let r = rect(&mut s, 10.0); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Grouped"); + select(&mut s, &[r, id]); + s.execute("object.group", &json!({})).unwrap(); + let g = place(&s, id).0.unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Group { .. })); + select(&mut s, &[g]); + let out = s.execute(C, &json!({})).unwrap(); + let (p, t) = (ids(&out, "paths")[0], ids(&out, "texts")[0]); + assert_eq!((place(&s, p).0, place(&s, t).0), (Some(g), Some(g))); + } + + #[test] + fn styles_and_unicode_text_survive() { + let mut s = session(); + let words = "Größe ✓ 日本語 éa"; + let id = path_type(&mut s, "M100 300 C200 150 400 150 500 300", [100.0, 300.0], words); + // "Gr" (bytes 0..2), and "日本" (bytes 12..18, after "Größe ✓ "). + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 2, "size": 24, "fill": "#ff0000"})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 12, "end": 18, "tracking": 200, "hScale": 150})).unwrap(); + // A centred paragraph: the first character still starts where it sat. + s.edit("centre", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.justify = Justify::Center; + } + Ok(()) + }) + .unwrap(); + let before = text(&s, id); + assert!(before.runs.len() >= 3, "several styles: {:?}", before.runs); + let out = s.execute(C, &json!({})).unwrap(); + let after = text(&s, ids(&out, "texts")[0]); + assert_eq!(after.plain_text(), words); + assert_released(&before, &after); + } + + #[test] + fn empty_type_starts_at_its_start_bracket() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [300.0, 300.0], ""); + let out = s.execute(C, &json!({})).unwrap(); + assert_eq!(ids(&out, "texts"), [id]); + let t = text(&s, id); + assert_eq!(t.kind, TextKind::Point); + assert!(close(t.xf * Point::ZERO, Point::new(300.0, 300.0)), "{:?}", t.xf * Point::ZERO); + } + + /// What a dialog's Preview does: each preview replaces the last, Cancel puts the type back with + /// no step of history, and OK keeps the preview as one step. + #[test] + fn previews_cancel_and_commit() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Preview"); + let (start, n, sel) = (doc(&s), steps(&s), selected(&s)); + let count = paths_in; + s.begin_interaction("Release Type on a Path").unwrap(); + s.preview(C, &json!({"mode": "both"})).unwrap(); + assert_eq!((count(&s), text(&s, id).kind), (1, TextKind::Point)); + s.preview(C, &json!({"mode": "text"})).unwrap(); + assert_eq!(count(&s), 0, "the next preview replaces the last"); + s.preview(C, &json!({"mode": "path", "keepOriginal": true})).unwrap(); + assert_eq!(count(&s), 1); + assert!(matches!(text(&s, id).kind, TextKind::OnPath { .. })); + s.cancel_interaction().unwrap(); + assert_eq!((doc(&s), steps(&s), selected(&s)), (start.clone(), n, sel), "Cancel: as it was, no step"); + + s.begin_interaction("Release Type on a Path").unwrap(); + s.preview(C, &json!({"mode": "both"})).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(steps(&s), n + 1, "OK: one step"); + assert_eq!(text(&s, id).kind, TextKind::Point); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc(&s), start); + } + + #[test] + fn bad_params_are_refused_without_a_change() { + let mut s = session(); + path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Bad"); + let (before, n) = (doc(&s), steps(&s)); + for p in [ + json!({"mode": "all"}), + json!({"mode": 1}), + json!({"keepOriginal": "yes"}), + json!({"ids": [999_999]}), + json!({"ids": "1"}), + json!({"id": -1}), + json!({"colour": "red"}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!((doc(&s), steps(&s)), (before.clone(), n), "{p}"); + } + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b66083178..0db8cc655 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -668,6 +668,11 @@ fn structured_junk() { ("addon.type.textTweaker", json!({"attribute": "rotation", "from": -360, "to": 360, "shape": "bow", "skipSpaces": false, "ids": [u64::MAX]})), ("addon.type.textTweaker", json!({"attribute": "hScale", "id": 2, "start": u64::MAX, "end": 0, "shape": "ease"})), ("addon.type.textTweaker", json!({"attribute": ["size"], "from": "x", "seed": 1e308, "skipSpaces": "no", "ids": "all"})), + ("addon.type.releasePath", json!({"mode": "both", "keepOriginal": true, "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8]})), + ("addon.type.releasePath", json!({"mode": "path", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), + ("addon.type.releasePath", json!({"mode": "text", "keepOriginal": false, "id": 2})), + ("addon.type.releasePath", json!({"mode": ["both"], "keepOriginal": "no", "ids": "all", "id": -1})), + ("addon.type.releasePath", json!({"mode": "nope", "keepOriginal": 1e308, "ids": [1.5, null, "x"]})), ("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), ("addon.cleanup.breakText", json!({"by": ["lines"]})), ("addon.cleanup.breakText", json!({"by": "paragraphs"})), diff --git a/crates/ui-egui/src/addons/typetools.rs b/crates/ui-egui/src/addons/typetools.rs index f9bfa3ca3..859c69fa3 100644 --- a/crates/ui-egui/src/addons/typetools.rs +++ b/crates/ui-egui/src/addons/typetools.rs @@ -34,9 +34,18 @@ const SEEDS: u64 = 1_000_000; const LABEL_W: f32 = 90.0; const FIELD_W: f32 = 90.0; +/// Release Type on a Path's command. Its menu item opens the generic command dialog +/// (`ui.paramDialog`) on these options, with a Preview (`dialogs::command`). +pub const RELEASE: &str = "addon.type.releasePath"; +/// Release Type on a Path › Keep: what the command's `mode` takes, by name. +pub const RELEASE_MODES: &[(&str, &str)] = &[("Text and Path", "both"), ("Path Only", "path"), ("Text Only", "text")]; + /// Object › Plug-ins › Type Tools. pub fn menu() -> Vec { - vec![Item::Cmd("Text Tweaker…", COMMAND, Value::Null)] + vec![ + Item::Cmd("Text Tweaker…", COMMAND, Value::Null), + Item::Cmd("Release Type on a Path…", RELEASE, json!({"mode": "both", "keepOriginal": false})), + ] } /// The attribute row of param `key` (the first, Font Size, for an unknown one). diff --git a/crates/ui-egui/src/addons/typetools/tests.rs b/crates/ui-egui/src/addons/typetools/tests.rs index 36e3ef2f3..e7118de08 100644 --- a/crates/ui-egui/src/addons/typetools/tests.rs +++ b/crates/ui-egui/src/addons/typetools/tests.rs @@ -60,3 +60,69 @@ fn another_attribute_starts_from_its_own_values() { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.fields["from"].clone(), d.fields["to"].clone()), (json!(0.0), json!(30.0))); } + +/// The kind of object `id` is, and whether it is type on a path. +fn on_path(app: &VectorcraftApp, id: NodeId) -> Option { + match &app.session.doc().unwrap().doc.node(id)?.kind { + NodeKind::Text(t) => Some(matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })), + _ => None, + } +} + +/// #5532: Object › Plug-ins › Type Tools › Release Type on a Path… opens the command dialog on +/// Text and Path, Keep Original off, with Preview off. With Preview on the canvas shows the +/// release while the dialog is open; Cancel puts the type back with no step of history, and OK +/// keeps it as one step. +#[test] +fn release_type_on_a_path_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let path = app.run("path.create", json!({"d": "M100 200 C200 100 400 100 500 200"})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Release me"})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (app.session.doc().unwrap().doc.clone(), steps(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Type Tools"] && e.label == "Release Type on a Path…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::RELEASE.to_string(), "Release Type on a Path".to_string())); + assert_eq!((d.str("mode"), d.bool("keepOriginal"), d.bool("preview")), ("both".to_string(), false, false)); + assert!(crate::dialogs::accepts_field(d, "preview")); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Release Type on a Path", "Mode:", "Text and Path", "Keep Original", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + assert_eq!(on_path(&app, id), Some(true), "no preview without the checkbox"); + + // Preview on: the type is released on the canvas; another mode replaces that preview. + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(on_path(&app, id), Some(false), "previewed as point type"); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("path")); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(on_path(&app, id), None, "path only: the type is gone in the preview"); + + // Cancel: as it was, with no step of history. + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel restores the type"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + // OK: one step, the released objects selected. + open(&mut app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + assert_eq!(on_path(&app, id), Some(false)); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 2, "the path and the text are selected"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, start); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 2dc8efd06..ef781fb43 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -61,12 +61,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does -/// too (#5360), as public tutorials of the dialog show. (Cancel rolls a preview back whichever -/// dialog started it.) +/// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path +/// works the same way (#5532). (Cancel rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" => true, + "type.pathOptions" | crate::addons::typetools::RELEASE => true, _ => false, } } @@ -114,6 +114,7 @@ fn order(command: &str) -> &'static [&'static str] { // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], + crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], _ => &[], } } @@ -123,6 +124,7 @@ fn choices(command: &str, key: &str) -> Option { match (command, key) { ("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS), ("type.pathOptions", "alignToPath") => Some(PATH_ALIGN), + (crate::addons::typetools::RELEASE, "mode") => Some(crate::addons::typetools::RELEASE_MODES), ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8948a1482..ee25d5057 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6186,6 +6186,10 @@ breakText Glyphs Глифи Bow Дъга Spike Връх Skip Spaces Пропускане на интервалите + Release Type on a Path… Освобождаване на текст по пътя… + Release Type on a Path Освобождаване на текст по пътя + Text and Path Текст и път + Path Only Само път @msg The art is too large to copy as a picture: other apps get no PNG of it Графиката е твърде голяма, за да се копира като изображение: другите приложения не получават PNG от нея # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6410a827..1f4ea2422 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6220,6 +6220,11 @@ shape Open Otvoren Bow Luk Spike Šiljak Skip Spaces Preskoči razmake + Release Type on a Path… Oslobodi tekst na putanji… + Release Type on a Path Oslobodi tekst na putanji + Text and Path Tekst i putanja + Path Only Samo putanja + Text Only Samo tekst @msg The art is too large to copy as a picture: other apps get no PNG of it Grafika je prevelika da bi se kopirala kao slika: druge aplikacije ne dobijaju njen PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index afedb11f9..73df162e8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6321,6 +6321,10 @@ breakText Glyphs Glifs Bow Arc Spike Pic Skip Spaces Omet els espais + Release Type on a Path… Allibera el text sobre un traçat… + Release Type on a Path Allibera el text sobre un traçat + Text and Path Text i traçat + Path Only Només traçat @msg The art is too large to copy as a picture: other apps get no PNG of it La il·lustració és massa gran per copiar-la com a imatge: les altres aplicacions no en reben cap PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc3a7c5cc..084f68e32 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6335,6 +6335,10 @@ breakText Glyphs Glyfy Bow Oblouk Spike Špička Skip Spaces Přeskočit mezery + Release Type on a Path… Uvolnit text na cestě… + Release Type on a Path Uvolnit text na cestě + Text and Path Text a cesta + Path Only Pouze cesta @msg The art is too large to copy as a picture: other apps get no PNG of it Kresba je příliš velká na zkopírování jako obrázek: ostatní aplikace z ní nedostanou PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d58f0610b..623e70038 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6202,6 +6202,10 @@ breakText Glyphs Glyffer Bow Bue Spike Spids Skip Spaces Spring mellemrum over + Release Type on a Path… Frigør tekst på kurve… + Release Type on a Path Frigør tekst på kurve + Text and Path Tekst og kurve + Path Only Kun kurve @msg The art is too large to copy as a picture: other apps get no PNG of it Grafikken er for stor til at blive kopieret som et billede: andre programmer får ingen PNG af den # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d88d86b5f..93c8f21d4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5921,6 +5921,10 @@ breakText Glyphs Glyphen Bow Bogen Spike Spitze Skip Spaces Leerzeichen überspringen + Release Type on a Path… Pfadtext zurückwandeln… + Release Type on a Path Pfadtext zurückwandeln + Text and Path Text und Pfad + Path Only Nur Pfad @msg The art is too large to copy as a picture: other apps get no PNG of it Das Bildmaterial ist zu groß, um es als Bild zu kopieren: Andere Apps erhalten kein PNG davon # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1c116fe3d..9b0d4049d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6287,6 +6287,10 @@ breakText Glyphs Γλύφοι Bow Τόξο Spike Αιχμή Skip Spaces Παράλειψη κενών + Release Type on a Path… Αποδέσμευση κειμένου σε διαδρομή… + Release Type on a Path Αποδέσμευση κειμένου σε διαδρομή + Text and Path Κείμενο και διαδρομή + Path Only Μόνο διαδρομή @msg The art is too large to copy as a picture: other apps get no PNG of it Το γραφικό είναι πολύ μεγάλο για αντιγραφή ως εικόνα: οι άλλες εφαρμογές δεν λαμβάνουν PNG του # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d9bad3fc8..cd98ba9ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6277,6 +6277,10 @@ breakText Glyphs Glifos Bow Arco Spike Pico Skip Spaces Omitir espacios + Release Type on a Path… Soltar texto en trazado… + Release Type on a Path Soltar texto en trazado + Text and Path Texto y trazado + Path Only Solo trazado @msg The art is too large to copy as a picture: other apps get no PNG of it La ilustración es demasiado grande para copiarla como imagen: las demás aplicaciones no reciben un PNG de ella # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d66e806d8..ea55b031d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6365,6 +6365,10 @@ breakText Glyphs Merkit Bow Kaari Spike Piikki Skip Spaces Ohita välilyönnit + Release Type on a Path… Vapauta teksti polulta… + Release Type on a Path Vapauta teksti polulta + Text and Path Teksti ja polku + Path Only Vain polku @msg The art is too large to copy as a picture: other apps get no PNG of it Grafiikka on liian suuri kopioitavaksi kuvana: muut sovellukset eivät saa siitä PNG-kuvaa Check for Updates… Tarkista päivitykset… VectorCraft Updates VectorCraft-päivitykset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4ddf66a51..0ed2c4d71 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6330,6 +6330,10 @@ breakText Glyphs Glyphes Bow Arc Spike Pointe Skip Spaces Ignorer les espaces + Release Type on a Path… Libérer le texte curviligne… + Release Type on a Path Libérer le texte curviligne + Text and Path Texte et tracé + Path Only Tracé uniquement @msg The art is too large to copy as a picture: other apps get no PNG of it L’illustration est trop grande pour être copiée en tant qu’image : les autres applications n’en reçoivent pas de PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b42cb25bb..a6cef2de6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6319,6 +6319,10 @@ breakText Glyphs Glifák Bow Ív Spike Csúcs Skip Spaces Szóközök kihagyása + Release Type on a Path… Útvonalszöveg szétválasztása… + Release Type on a Path Útvonalszöveg szétválasztása + Text and Path Szöveg és útvonal + Path Only Csak útvonal @msg The art is too large to copy as a picture: other apps get no PNG of it A grafika túl nagy ahhoz, hogy képként másolható legyen: más alkalmazások nem kapnak róla PNG-t # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 843de1391..c0c6489bf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6202,6 +6202,10 @@ breakText Glyphs Glif Bow Busur Spike Puncak Skip Spaces Lewati Spasi + Release Type on a Path… Lepaskan Teks pada Jalur… + Release Type on a Path Lepaskan Teks pada Jalur + Text and Path Teks dan jalur + Path Only Hanya jalur @msg The art is too large to copy as a picture: other apps get no PNG of it Gambar terlalu besar untuk disalin sebagai gambar: aplikasi lain tidak mendapatkan PNG darinya # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2a90453d4..d41988a0c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5955,6 +5955,10 @@ breakText Glyphs Glifi Bow Arco Spike Picco Skip Spaces Salta gli spazi + Release Type on a Path… Rilascia testo su tracciato… + Release Type on a Path Rilascia testo su tracciato + Text and Path Testo e tracciato + Path Only Solo tracciato @msg The art is too large to copy as a picture: other apps get no PNG of it La grafica è troppo grande per essere copiata come immagine: le altre app non ne ricevono un PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a73e548d8..12a444230 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6020,6 +6020,10 @@ breakText Glyphs 字形 Bow 弓形 Spike 山形 Skip Spaces スペースをスキップ + Release Type on a Path… パス上文字を解除… + Release Type on a Path パス上文字を解除 + Text and Path テキストとパス + Path Only パスのみ @msg The art is too large to copy as a picture: other apps get no PNG of it アートワークが大きすぎるため画像としてコピーできません:他のアプリには PNG が渡されません # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 78de8173f..2c89b17ee 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6346,6 +6346,10 @@ font-script Japanese იაპონური Bow რკალი Spike პიკი Skip Spaces ჰარების გამოტოვება + Release Type on a Path… ტექსტის ტრაექტორიიდან მოხსნა… + Release Type on a Path ტექსტის ტრაექტორიიდან მოხსნა + Text and Path ტექსტი და ტრაექტორია + Path Only მხოლოდ ტრაექტორია @msg The art is too large to copy as a picture: other apps get no PNG of it ნახატი ზედმეტად დიდია სურათად დასაკოპირებლად: სხვა აპები მის PNG-ს ვერ მიიღებენ # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index aefbd17df..979fdefec 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5916,6 +5916,10 @@ breakText Glyphs 글리프 Bow 활 모양 Spike 뾰족한 모양 Skip Spaces 공백 건너뛰기 + Release Type on a Path… 패스 위 문자 해제… + Release Type on a Path 패스 위 문자 해제 + Text and Path 문자와 패스 + Path Only 패스만 @msg The art is too large to copy as a picture: other apps get no PNG of it 아트워크가 너무 커서 이미지로 복사할 수 없습니다: 다른 앱에는 PNG가 전달되지 않습니다 # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dbb31fd12..0a5943f82 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6246,6 +6246,10 @@ breakText Glyphs Glyphs Bow Boog Spike Piek Skip Spaces Spaties overslaan + Release Type on a Path… Tekst op pad vrijgeven… + Release Type on a Path Tekst op pad vrijgeven + Text and Path Tekst en pad + Path Only Alleen pad @msg The art is too large to copy as a picture: other apps get no PNG of it Het illustratiewerk is te groot om als afbeelding te kopiëren: andere apps krijgen er geen PNG van # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 88322303b..82b042541 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6190,6 +6190,10 @@ breakText Glyphs Glify Bow Łuk Spike Szpic Skip Spaces Pomiń spacje + Release Type on a Path… Uwolnij tekst na ścieżce… + Release Type on a Path Uwolnij tekst na ścieżce + Text and Path Tekst i ścieżka + Path Only Tylko ścieżka @msg The art is too large to copy as a picture: other apps get no PNG of it Grafika jest zbyt duża, aby skopiować ją jako obraz: inne aplikacje nie otrzymają jej w formacie PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0293e24e3..245d23775 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6408,6 +6408,10 @@ swatch Yellow Green Verde-amarelado Bow Arco Spike Pico Skip Spaces Ignorar espaços + Release Type on a Path… Liberar Texto em um Caminho… + Release Type on a Path Liberar Texto em um Caminho + Text and Path Texto e caminho + Path Only Somente caminho @msg The art is too large to copy as a picture: other apps get no PNG of it A arte é grande demais para ser copiada como imagem: outros aplicativos não recebem um PNG dela # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9791fe1c3..f49a5f68b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6424,6 +6424,10 @@ shape Open Открытый Bow Дуга Spike Пик Skip Spaces Пропускать пробелы + Release Type on a Path… Освободить текст по контуру… + Release Type on a Path Освободить текст по контуру + Text and Path Текст и контур + Path Only Только контур @msg The art is too large to copy as a picture: other apps get no PNG of it Графика слишком велика для копирования в виде изображения: другие приложения не получат её PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58f82f37b..0588ec261 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6391,6 +6391,10 @@ digits Hindi Hindi Bow Båge Spike Spets Skip Spaces Hoppa över mellanslag + Release Type on a Path… Släpp text på bana… + Release Type on a Path Släpp text på bana + Text and Path Text och bana + Path Only Endast bana @msg The art is too large to copy as a picture: other apps get no PNG of it Teckningen är för stor för att kopieras som en bild: andra appar får ingen PNG av den # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ae5a58dd6..f0ae591f4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6292,6 +6292,10 @@ breakText Glyphs Glifler Bow Yay Spike Sivri Uç Skip Spaces Boşlukları Atla + Release Type on a Path… Yoldaki Metni Ayır… + Release Type on a Path Yoldaki Metni Ayır + Text and Path Metin ve yol + Path Only Yalnızca yol @msg The art is too large to copy as a picture: other apps get no PNG of it Çizim, resim olarak kopyalanamayacak kadar büyük: diğer uygulamalar bunun PNG'sini almaz # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 363a8f6fb..109a59176 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5935,6 +5935,10 @@ breakText Glyphs Гліфи Bow Дуга Spike Пік Skip Spaces Пропускати пробіли + Release Type on a Path… Звільнити текст за контуром… + Release Type on a Path Звільнити текст за контуром + Text and Path Текст і контур + Path Only Лише контур @msg The art is too large to copy as a picture: other apps get no PNG of it Графіка завелика, щоб скопіювати її як зображення: інші програми не отримають її PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2275c27d8..07ecd390e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5936,6 +5936,10 @@ breakText Glyphs Glyph Bow Cung Spike Đỉnh nhọn Skip Spaces Bỏ qua khoảng trắng + Release Type on a Path… Giải phóng chữ trên đường dẫn… + Release Type on a Path Giải phóng chữ trên đường dẫn + Text and Path Văn bản và đường dẫn + Path Only Chỉ đường dẫn @msg The art is too large to copy as a picture: other apps get no PNG of it Hình vẽ quá lớn để sao chép dưới dạng ảnh: các ứng dụng khác không nhận được PNG của nó # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1d44f8cc6..be24cce50 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6176,6 +6176,10 @@ breakText Glyphs 字形 Bow 弓形 Spike 尖峰 Skip Spaces 跳过空格 + Release Type on a Path… 释放路径文字… + Release Type on a Path 释放路径文字 + Text and Path 文字和路径 + Path Only 仅路径 @msg The art is too large to copy as a picture: other apps get no PNG of it 图稿太大,无法作为图片复制:其他应用不会获得它的 PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6d6b10751..777873899 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5973,6 +5973,10 @@ breakText Glyphs 字符 Bow 弓形 Spike 尖峰 Skip Spaces 略過空格 + Release Type on a Path… 釋放路徑文字… + Release Type on a Path 釋放路徑文字 + Text and Path 文字和路徑 + Path Only 僅路徑 @msg The art is too large to copy as a picture: other apps get no PNG of it 圖稿太大,無法作為圖片複製:其他應用程式不會取得它的 PNG # Selection Tools add-on: Select by Artboards (Object › Plug-ins › Selection Tools, #5548). diff --git a/docs/mcp.md b/docs/mcp.md index 63f41074c..797ac023b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7037,8 +7037,8 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la ## Type Tools add-on -Type Tools is a built-in add-on for type: its command is `addon.type.textTweaker`, and in the desktop app it lives in -Object › Plug-ins › Type Tools (#4924). Text Tweaker gives one character attribute to the characters letter by letter, +Type Tools is a built-in add-on for type: its commands are `addon.type.textTweaker` and `addon.type.releasePath`, and +in the desktop app it lives in Object › Plug-ins › Type Tools (#4924, #5532). Text Tweaker gives one character attribute to the characters letter by letter, from a start value to an end value along a shape, as one undo step; the text stays live and editable. Each value is set as `text.setRangeStyle` sets it (the Character panel), in its units and ranges ([formatting type](https://helpx.adobe.com/illustrator/using/formatting-type.html)), so on scaled type it is the size shown. @@ -7063,3 +7063,38 @@ default values, and the seed starts new whenever the document has changed. {"name":"run_command","arguments":{"command":"addon.type.textTweaker","params":{"attribute":"baselineShift","from":0,"to":18,"shape":"bow"}}} {"name":"run_command","arguments":{"command":"addon.type.textTweaker","params":{"attribute":"rotation","from":-15,"to":15,"shape":"random","seed":4}}} ``` + +### Release Type on a Path + +`addon.type.releasePath` splits each type on a path (`text.createInPath` with `mode: "onPath"`) into point type that +holds the same text and the plain path it was set on (#5532). The reference app has no command for this, so it is a +VectorCraft addition and changes no parity score. + +- **The path** keeps its exact shape (open or closed, every anchor and handle, the object's transform) and goes in the + type's layer at its place in the stacking order. Type paths carry no paint of their own (making type on a path drops + the path's fill and stroke), so the released path has no fill and no stroke. +- **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on + one straight line along the object's own axis, with the baseline starting where the first character sat on the path + (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, + brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point + type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as + `type.convertToPointType` does for area type. +- It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left + alone. With none among them nothing changes and no undo step is made. Otherwise the whole run is one undo step and + the released objects are selected. + +| Parameter | Values (default) | +|---|---| +| `mode` | `both` (point type and the path), `path` (the path only: the type goes), `text` (point type only) (`both`) | +| `keepOriginal` | false; true: the type on a path stays as it is and the released objects go just above it | +| `ids`, `id` | the objects to release (default: the selection) | + +→ `{ids (the released objects, bottom to top), paths, texts}`. Release Type on a Path… opens the command dialog +(`ui.paramDialog`) on Mode (Text and Path, Path Only, Text Only) and Keep Original, with Preview: while Preview is on +the canvas shows the release, Cancel restores the original with no undo step, and OK applies it as one, as Type on a +Path Options' Preview does (#5360). + +```json +{"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{}}} +{"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{"mode":"path","keepOriginal":true}}} +``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f851ca41..216698554 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Gradient panel: Delete Stop beside the ramp, #5403; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Type Tools add-on: Release Type on a Path, a VectorCraft addition, #5532; optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -8,7 +8,7 @@ Ligature caret stops use a font's GDEF contour-point positions in horizontal and Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. -**Feature evidence last updated:** 2026-11-03. +**Feature evidence last updated:** 2026-11-05. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. @@ -23,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Type Tools add-on: Release Type on a Path (a VectorCraft addition, #5532): evidence added; scores unchanged. | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `minAngle`/`maxAngle` round only the corners whose angle is in a range (#5545); evidence added; scores unchanged. | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… and `addon.pathtools.cornerSet` `every` round all, the odd-numbered or the even-numbered anchors of each path (#5546); evidence added; scores unchanged. | | 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons (`object.transformEach` `relative`: percentages of each object's size, or the width and height it becomes, measured on each object's own bounds) and the link that keeps the two scales in proportion; the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order. Scores and estimates unchanged. | @@ -1582,6 +1583,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Type Tools › Text Tweaker… gives one character attribute (size, baseline shift, character rotation, horizontal or vertical scale, tracking, leading or stroke weight) to the selected characters letter by letter, from a start value to an end value along a straight ramp, an ease in and out, a bow, a spike or at random with a seed, while the text stays live and editable (#4924). Values are set as the Character panel sets them, in its units and ranges, so scaled type takes the sizes shown; spaces and line breaks are skipped unless asked for, each type object gets its own ramp, the range is the Type tool's selection or all the text of the selected type objects, and the run is one undo step from a dialog with Preview. Tests: `cmd::addons::typetools::tests` in `vectorcraft-engine` (each shape, each attribute, skipped spaces, ranges, one ramp per object, seeded random values, scaled type, one undo step, bad params refused), `addons::typetools::tests` in `vectorcraft-ui-egui` (the submenu, the dialog, Preview and Cancel, OK) and `command_sweep`. Commands: `docs/mcp.md` › Type Tools add-on. +## Type Tools add-on: Release Type on a Path (2026-11-05) + +A VectorCraft addition, not a feature of the reference app (it has no command for this), so no area score moves. Object › Plug-ins › Type Tools › Release Type on a Path… splits each selected type on a path into point type holding the same text and the plain path it was set on, or keeps only one of them (#5532). The path keeps its exact shape (open or closed, anchors and handles, the object's transform), layer and place in the stacking order, with no fill and no stroke since type paths carry no paint of their own. The text keeps its runs, character and paragraph attributes and becomes point type on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options don't apply to point type and are dropped. Keep Original leaves the type on a path and adds the released objects above it. Other objects are left alone and a selection with no type on a path makes no undo step; otherwise the run is one undo step, the released objects are selected, and the dialog's Preview works as Type on a Path Options' does (#5360). Tests: `cmd::addons::typetools::tests::release` in `vectorcraft-engine` (each mode, Keep Original, mixed and empty selections, closed, open, curved and flipped paths, transformed and grouped type, multi-style and unicode text with a centred paragraph, empty type, undo and redo, preview, cancel and commit, bad params refused), `release_type_on_a_path_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Type Tools add-on › Release Type on a Path. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. diff --git a/docs/type-parity.md b/docs/type-parity.md index 2013f513b..d62d010f4 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Find Font's Replace With Font lists family and style rows, searchable, with its own scroll state and a refresh after each replacement; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Release Type on a Path in the Type Tools add-on, beyond Illustrator, #5532; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -69,6 +69,7 @@ against Illustrator's public documentation; **estimated** unless noted. | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` | | 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) | +| Release Type on a Path (Object › Plug-ins › Type Tools) | beyond Illustrator | not scored; `addon.type.releasePath` splits type on a path into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on (exact shape, place in the stacking order, no paint), or keeps only one of them, optionally leaving the original; a dialog with Preview, one undo step (#5532) | 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. @@ -76,6 +77,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | | 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. |