diff --git a/ROADMAP.md b/ROADMAP.md index bce1cab0c..7aa720205 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,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-02 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Progress log row for the Type Tools add-on's Text Tweaker, #4924; headline numbers unchanged; previously: font-family fields accept the mouse wheel, #5253; 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 @@ -138,6 +138,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. +- 2026-11-03: Text Tweaker, the first tool of the Type Tools add-on (Object › Plug-ins › Type Tools): one character attribute (size, baseline shift, rotation, horizontal or vertical scale, tracking, leading or stroke weight) goes letter by letter from a start value to an end value along a straight ramp, an ease, a bow, a spike or at random with a seed, set as the Character panel sets it, while the text stays live; a dialog with Preview, one undo step (`addon.type.textTweaker`, #4924). A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. @@ -215,6 +216,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. | diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 21d73cc4f..02f886442 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -7,6 +7,7 @@ mod color; mod document; mod pathtools; mod pen; +mod typetools; use super::CommandSpec; @@ -18,5 +19,6 @@ pub fn specs() -> Vec { v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); + v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs new file mode 100644 index 000000000..0cd8e21ea --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -0,0 +1,165 @@ +//! Type Tools (`addon.type.*`), a built-in add-on: Text Tweaker gives one character attribute to +//! 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. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; +use vectorcraft_doc::{NodeId, TextRun}; +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 crate::{EngineError, Result, Session}; + +/// An attribute Text Tweaker sets: its `text.setRangeStyle` param, the values it takes and its +/// default start and end. +type Attribute = (&'static str, (f64, f64), (f64, f64)); + +/// The attributes Text Tweaker sets. +pub const ATTRIBUTES: [Attribute; 8] = [ + ("size", (0.1, 1296.0), (12.0, 36.0)), + ("baselineShift", (-1296.0, 1296.0), (0.0, 12.0)), + ("rotation", (-360.0, 360.0), (0.0, 30.0)), + ("hScale", (1.0, 10000.0), (100.0, 150.0)), + ("vScale", (1.0, 10000.0), (100.0, 150.0)), + ("tracking", (-1000.0, 10000.0), (0.0, 200.0)), + ("leading", (0.1, 5000.0), (12.0, 36.0)), + ("strokeWidth", (0.0, 1000.0), (0.0, 2.0)), +]; +/// The shapes the values follow from the first character to the last. +pub const SHAPES: [&str; 5] = ["straight", "ease", "bow", "spike", "random"]; +/// The most characters one run changes. +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, 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 + )] +} + +/// 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 { + let t = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + match shape { + "ease" => t * t * (3.0 - 2.0 * t), + "bow" => (std::f64::consts::PI * t).sin(), + "spike" => 1.0 - (2.0 * t - 1.0).abs(), + "random" => random, + _ => t, + } +} + +/// A random number from 0 to 1. +fn unit(rng: &mut Rng) -> f64 { + (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64 +} + +fn text_tweaker(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.type.textTweaker"; + let attribute = choice(p, C, "attribute", &ATTRIBUTES.map(|a| a.0))?.unwrap_or("size"); + let Some(&(key, (lo, hi), (from0, to0))) = ATTRIBUTES.iter().find(|a| a.0 == attribute) else { + return Err(bad(C, format!("unknown attribute `{attribute}`"))); + }; + let mut ends = [0.0; 2]; + for (end, (k, default)) in ends.iter_mut().zip([("from", from0), ("to", to0)]) { + let v = number_or(p, k, default, C)?; + if !(lo..=hi).contains(&v) { + return Err(bad(C, format!("`{k}` must be from {lo} to {hi} for {key}"))); + } + *end = v; + } + let [from, to] = ends; + let shape = choice(p, C, "shape", &SHAPES)?.unwrap_or("straight"); + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let skip = switch_or(p, "skipSpaces", true, C)?; + let setup = s.doc()?.doc.setup.clone(); + // At a caret, all of the object's text. + let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; + // Each object's changed characters: (byte, its change), worked out before the edit. + let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; + let mut count = 0; + for (id, a, b, _) in ranges { + let t = text_ref(s, id)?; + let text = t.plain_text(); + let chars: Vec = + text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); + count += chars.len(); + if count > MAX_CHARS { + return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); + } + let n = chars.len(); + let mut changes = Vec::with_capacity(n); + for (i, at) in chars.into_iter().enumerate() { + let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; + let value = from + (to - from) * amount(shape, i, n, random); + let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; + change.localize(&t, C)?; + changes.push((at, change)); + } + if !changes.is_empty() { + plans.push((id, changes)); + } + } + if plans.is_empty() { + return Ok(json!({ "count": 0, "ids": [] })); + } + let ids: Vec = plans.iter().map(|(id, _)| id.0).collect(); + s.edit("Text Tweaker", |d, _| { + for (id, changes) in &plans { + let t = text_mut(d, *id).ok_or(EngineError::NoNode(*id))?; + t.runs = tweak(std::mem::take(&mut t.runs), changes); + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "count": count, "ids": ids })) +} + +/// `runs` with each character at a byte of `changes` (in order) changed: its run split into +/// characters, the changes applied, and runs that came out alike merged again. +fn tweak(runs: Vec, changes: &[(usize, CharChange)]) -> Vec { + let mut out = Vec::with_capacity(runs.len() + changes.len() * 2); + let mut next = changes.iter().peekable(); + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if r.inline.is_some() || next.peek().is_none_or(|(at, _)| *at >= end) { + // An inline graphic is one character: its style changes whole. + if let Some((_, change)) = next.next_if(|(at, _)| (off..end).contains(at)) { + let mut r = r; + change.apply(&mut r.style); + out.push(r); + } else { + out.push(r); + } + off = end; + continue; + } + for (i, c) in r.text.char_indices() { + let mut piece = TextRun::new(c.to_string(), r.style.clone()); + if let Some((_, change)) = next.next_if(|(at, _)| *at == off + i) { + change.apply(&mut piece.style); + } + out.push(piece); + } + off = end; + } + edit::normalize(&mut out); + out +} diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs new file mode 100644 index 000000000..47a1b92b9 --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -0,0 +1,195 @@ +//! Text Tweaker: the values each shape gives each character, what it skips and changes, one undo +//! step, and bad params refused without a change. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; + +use super::amount; +use crate::Session; + +const C: &str = "addon.type.textTweaker"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, words: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 40, "y": 100, "text": words, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// Each character of `id` with its style. +fn styles(s: &Session, id: NodeId) -> Vec<(char, CharStyle)> { + obj(s, id).runs.iter().flat_map(|r| r.text.chars().map(|c| (c, r.style.clone())).collect::>()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +#[test] +fn the_shapes_run_from_the_start_value_to_the_end_value() { + let at = |shape, n| (0..n).map(|i| amount(shape, i, n, 0.5)).collect::>(); + assert!(near(&at("straight", 5), &[0.0, 0.25, 0.5, 0.75, 1.0])); + assert!(near(&at("ease", 5), &[0.0, 0.15625, 0.5, 0.84375, 1.0]), "slow at both ends"); + let bow = at("bow", 5); + assert!(near(&[bow[0], bow[2], bow[4]], &[0.0, 1.0, 0.0]) && (bow[1] - 0.5f64.sqrt()).abs() < 1e-9, "a smooth arc"); + assert!(near(&at("spike", 5), &[0.0, 0.5, 1.0, 0.5, 0.0]), "straight up and down"); + assert!(near(&at("random", 3), &[0.5; 3])); + assert!(near(&at("straight", 1), &[0.0]), "one character takes the start value"); +} + +#[test] +fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { + let mut s = session(); + let id = text(&mut s, "ab cd"); + select(&mut s, &[id]); + let n = steps(&s); + let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); + assert_eq!(out, json!({"count": 4, "ids": [id.0]})); + assert_eq!(steps(&s), n + 1, "one undo step"); + let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); + assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); + assert_eq!(obj(&s, id).plain_text(), "ab cd", "the text stays live"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(styles(&s, id).iter().all(|(_, st)| st.size == 20.0), "undone"); +} + +#[test] +fn spaces_can_count_too() { + let mut s = session(); + let id = text(&mut s, "a b"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": "baselineShift", "from": 0, "to": 10, "skipSpaces": false})).unwrap(); + let shifts: Vec = styles(&s, id).iter().map(|(_, st)| st.baseline_shift).collect(); + assert_eq!(shifts, [0.0, 5.0, 10.0]); +} + +#[test] +fn every_attribute_is_set_as_the_character_panel_sets_it() { + let mut s = session(); + for (attribute, get) in [ + ("size", (|st: &CharStyle| st.size) as fn(&CharStyle) -> f64), + ("baselineShift", |st| st.baseline_shift), + ("rotation", |st| st.rotation), + ("hScale", |st| st.h_scale), + ("vScale", |st| st.v_scale), + ("tracking", |st| st.tracking), + ("leading", |st| st.leading.unwrap_or(-1.0)), + ("strokeWidth", |st| st.stroke_width), + ] { + let id = text(&mut s, "xyz"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": attribute, "from": 2, "to": 6})).unwrap(); + let got: Vec = styles(&s, id).iter().map(|(_, st)| get(st)).collect(); + assert!(near(&got, &[2.0, 4.0, 6.0]), "{attribute}: {got:?}"); + } +} + +#[test] +fn each_object_gets_its_own_ramp() { + let mut s = session(); + let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); + select(&mut s, &[a, b]); + let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); + assert_eq!(out["count"], json!(5)); + let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); + assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); +} + +#[test] +fn a_range_changes_only_its_characters() { + let mut s = session(); + let id = text(&mut s, "abcde"); + s.execute(C, &json!({"id": id.0, "start": 1, "end": 4, "attribute": "tracking", "from": 100, "to": 300})).unwrap(); + let tracking: Vec = styles(&s, id).iter().map(|(_, st)| st.tracking).collect(); + assert_eq!(tracking, [0.0, 100.0, 200.0, 300.0, 0.0]); +} + +#[test] +fn random_values_stay_in_range_and_repeat_with_the_seed() { + let mut s = session(); + let id = text(&mut s, "randomness"); + select(&mut s, &[id]); + let run = |s: &mut Session, seed| { + s.execute(C, &json!({"attribute": "vScale", "from": 50, "to": 150, "shape": "random", "seed": seed})).unwrap(); + styles(s, id).iter().map(|(_, st)| st.v_scale).collect::>() + }; + let first = run(&mut s, 7); + assert!(first.iter().all(|v| (50.0..=150.0).contains(v)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "values vary"); + assert_eq!(run(&mut s, 7), first, "same seed, same values"); + assert_ne!(run(&mut s, 8), first, "another seed, other values"); +} + +#[test] +fn values_follow_the_text_transform_as_the_character_panel_does() { + let mut s = session(); + let id = text(&mut s, "ab"); + select(&mut s, &[id]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute(C, &json!({"attribute": "size", "from": 20, "to": 40})).unwrap(); + let sizes: Vec = styles(&s, id).iter().map(|(_, st)| st.size).collect(); + // Shown sizes are the asked ones: stored sizes are half, the text being scaled twice. + let scale = obj(&s, id).style_scale().unwrap().points; + assert!(near(&sizes.iter().map(|v| v * scale).collect::>(), &[20.0, 40.0]), "{sizes:?} at {scale}"); +} + +#[test] +fn empty_text_and_only_spaces_change_nothing() { + let mut s = session(); + let id = text(&mut s, " "); + select(&mut s, &[id]); + let n = steps(&s); + assert_eq!(s.execute(C, &json!({})).unwrap(), json!({"count": 0, "ids": []})); + assert_eq!(steps(&s), n, "no step"); +} + +#[test] +fn bad_params_are_refused_without_a_change() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for p in [ + json!({"attribute": "colour"}), + json!({"attribute": 3}), + json!({"shape": "zigzag"}), + json!({"attribute": "size", "from": 0}), + json!({"attribute": "size", "to": 5000}), + json!({"attribute": "hScale", "from": -1}), + json!({"from": "big"}), + json!({"to": f64::MAX}), + json!({"seed": -1}), + json!({"skipSpaces": "yes"}), + json!({"ids": [999_999]}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + assert_eq!(steps(&s), n, "{p}"); + } +} + +#[test] +fn it_needs_a_selection() { + let mut s = session(); + text(&mut s, "abc"); + select(&mut s, &[]); + assert!(s.execute(C, &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 133a3d788..ace8e84e0 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -154,14 +154,14 @@ pub fn specs() -> Vec { ] } -fn text_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Option<&mut TextObject> { +pub(crate) fn text_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Option<&mut TextObject> { match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Text(t)) => Some(t), _ => None, } } -fn text_ref(s: &Session, id: NodeId) -> Result { +pub(crate) fn text_ref(s: &Session, id: NodeId) -> Result { match s.doc()?.doc.node(id).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Ok((**t).clone()), Some(_) => Err(EngineError::Other(format!("node {} is not text", id.0))), @@ -708,7 +708,7 @@ impl CharChange { && self.font_height_reference.is_none() } - fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { + pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { use super::typecmd::local_type_value; self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?; self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?; @@ -1255,7 +1255,7 @@ pub(crate) fn editing_range(s: &mut Session) -> Result Vec { Item::Sub("Document Tools", document::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), Item::Sub("Smart Pen", pen::menu()), + Item::Sub("Type Tools", typetools::menu()), Item::Sep, ] } diff --git a/crates/ui-egui/src/addons/typetools.rs b/crates/ui-egui/src/addons/typetools.rs new file mode 100644 index 000000000..f9bfa3ca3 --- /dev/null +++ b/crates/ui-egui/src/addons/typetools.rs @@ -0,0 +1,96 @@ +//! Type Tools in the interface: its submenu (Object › Plug-ins › Type Tools) and Text Tweaker's +//! dialog, which picks an attribute, its start and end values and a shape, previews on the canvas +//! and runs `addon.type.textTweaker` on OK. + +use egui::Ui; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::dialogs::{DialogSpec, form}; +use crate::menus::Item; +use crate::state::Dialog; +use crate::widgets; + +/// The command the dialog runs, and its kind. +pub const COMMAND: &str = "addon.type.textTweaker"; +pub const KIND: &str = "textTweaker"; +/// An attribute: (param, label, unit, decimals, values taken, default start and end), as the +/// command takes it. +type Attribute = (&'static str, &'static str, &'static str, usize, (f64, f64), (f64, f64)); + +const ATTRIBUTES: [Attribute; 8] = [ + ("size", "Font Size", "pt", 1, (0.1, 1296.0), (12.0, 36.0)), + ("baselineShift", "Baseline Shift", "pt", 1, (-1296.0, 1296.0), (0.0, 12.0)), + ("rotation", "Character Rotation", "°", 1, (-360.0, 360.0), (0.0, 30.0)), + ("hScale", "Horizontal Scale", "%", 0, (1.0, 10000.0), (100.0, 150.0)), + ("vScale", "Vertical Scale", "%", 0, (1.0, 10000.0), (100.0, 150.0)), + ("tracking", "Tracking", "", 0, (-1000.0, 10000.0), (0.0, 200.0)), + ("leading", "Leading", "pt", 1, (0.1, 5000.0), (12.0, 36.0)), + ("strokeWidth", "Stroke Weight", "pt", 2, (0.0, 1000.0), (0.0, 2.0)), +]; +const SHAPES: [(&str, &str); 5] = [("straight", "Straight"), ("ease", "Ease In Out"), ("bow", "Bow"), ("spike", "Spike"), ("random", "Random")]; +/// The seeds the dialog offers. +const SEEDS: u64 = 1_000_000; +const LABEL_W: f32 = 90.0; +const FIELD_W: f32 = 90.0; + +/// Object › Plug-ins › Type Tools. +pub fn menu() -> Vec { + vec![Item::Cmd("Text Tweaker…", COMMAND, Value::Null)] +} + +/// The attribute row of param `key` (the first, Font Size, for an unknown one). +fn attribute(key: &str) -> &'static Attribute { + ATTRIBUTES.iter().find(|a| a.0 == key).unwrap_or(&ATTRIBUTES[0]) +} + +/// The dialog's fields to start with: Font Size from 12 to 36 pt in a straight ramp, and a new +/// seed each time the document has changed, so running it again varies a random result. +pub(crate) fn fields(app: &VectorcraftApp) -> Value { + let seed = app.session.active().map_or(0, |st| st.revision % SEEDS); + json!({ + "attribute": "size", "__shown": "size", "from": 12, "to": 36, "shape": "straight", "seed": seed, "skipSpaces": true, + }) +} + +pub(crate) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Text Tweaker").to_string(), body, confirm, preview: true, min_width: 320.0, ..DialogSpec::FORM }; + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let widths = (LABEL_W, 170.0); + let options: Vec<(&str, &str)> = ATTRIBUTES.iter().map(|a| (a.0, a.1)).collect(); + form::choice(ui, d, "attribute", tl!("Attribute:"), widths, &options); + let &(key, _, unit, decimals, (lo, hi), (from, to)) = attribute(&d.str("attribute")); + // Another attribute starts from its own values. + if d.str("__shown") != key { + d.fields.insert("__shown".into(), json!(key)); + d.fields.insert("from".into(), json!(from)); + d.fields.insert("to".into(), json!(to)); + } + for (field, label) in [("from", tl!("From:")), ("to", tl!("To:"))] { + widgets::label_row(ui, label, LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, ("text-tweaker", field), d.f64(field, lo), lo..=hi, unit, decimals, FIELD_W) { + d.fields.insert(field.into(), json!(v)); + } + }); + } + form::choice(ui, d, "shape", tl!("Shape:"), widths, &SHAPES); + ui.add_enabled_ui(d.str("shape") == "random", |ui| { + widgets::label_row(ui, tl!("Seed:"), LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, ("text-tweaker", "seed"), d.f64("seed", 0.0), 0.0..=(SEEDS - 1) as f64, "", 0, FIELD_W) { + d.fields.insert("seed".into(), json!(v as u64)); + } + }); + }); + form::check(ui, d, "skipSpaces", tl!("Skip Spaces")); + form::preview(app, ui, d, "Text Tweaker", COMMAND, form::params(d)); + false +} + +/// OK: run the command (keep its preview) and close. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, COMMAND, form::params(d)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/typetools/tests.rs b/crates/ui-egui/src/addons/typetools/tests.rs new file mode 100644 index 000000000..36e3ef2f3 --- /dev/null +++ b/crates/ui-egui/src/addons/typetools/tests.rs @@ -0,0 +1,62 @@ +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_engine::Session; + +use super::KIND; +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// An app with one type object, selected. +fn app() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = NodeId(app.run("text.create", json!({"x": 40, "y": 100, "text": "abc", "size": 20})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + (app, id) +} + +fn sizes(app: &VectorcraftApp, id: NodeId) -> Vec { + match &app.session.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().flat_map(|r| r.text.chars().map(|_| r.style.size).collect::>()).collect(), + _ => panic!("not text"), + } +} + +fn 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 == "Text Tweaker…").unwrap(); + let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &id, p); +} + +#[test] +fn text_tweaker_opens_its_dialog_previews_and_runs_on_ok() { + let (mut app, id) = app(); + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Text Tweaker", "Attribute:", "Font Size", "From:", "To:", "Shape:", "Straight", "Seed:", "Skip Spaces", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(sizes(&app, id), [12.0, 24.0, 36.0], "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(sizes(&app, id), [20.0; 3], "rolled back"); + open(&mut app); + let n = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1, "one undo step"); + assert_eq!(sizes(&app, id), [12.0, 24.0, 36.0]); +} + +#[test] +fn another_attribute_starts_from_its_own_values() { + let (mut app, _) = app(); + open(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("attribute".into(), json!("rotation")); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["from"].clone(), d.fields["to"].clone()), (json!(0.0), json!(30.0))); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6221e6d90..805388dd4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -408,6 +408,7 @@ registry! { DivideDashes: [crate::addons::pathtools::dashes::KIND] => crate::addons::pathtools::dashes::SPEC, NearestSwatch: [crate::addons::color::nearest::KIND] => crate::addons::color::nearest::SPEC, SwatchLegend: [crate::addons::color::legend::KIND] => crate::addons::color::legend::SPEC, + TextTweaker: [crate::addons::typetools::KIND] => crate::addons::typetools::SPEC, ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY, crate::addons::arrange::SORT] => crate::addons::arrange::SPEC, ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d64cd5117..691f33f88 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6112,3 +6112,13 @@ breakText Glyphs Глифи Create Swatch Legend Създаване на легенда на образците Create Swatch Legend… Създаване на легенда на образците… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Инструменти за текст + Text Tweaker… Настройка на текста… + Text Tweaker Настройка на текста + Attribute: Атрибут: + From: От: + To: До: + Bow Дъга + Spike Връх + Skip Spaces Пропускане на интервалите diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 7e29b1908..25dde07c6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6146,3 +6146,13 @@ shape Open Otvoren Create Swatch Legend Kreiraj legendu uzoraka boja Create Swatch Legend… Kreiraj legendu uzoraka boja… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Alati za tekst + Text Tweaker… Podešavanje teksta… + Text Tweaker Podešavanje teksta + Attribute: Atribut: + From: Od: + To: Do: + Bow Luk + Spike Šiljak + Skip Spaces Preskoči razmake diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2c9ea1267..d44d0594a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6246,3 +6246,13 @@ breakText Glyphs Glifs Create Swatch Legend Crea una llegenda de mostres Create Swatch Legend… Crea una llegenda de mostres… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Eines de text + Text Tweaker… Ajustador de text… + Text Tweaker Ajustador de text + Attribute: Atribut: + From: De: + To: A: + Bow Arc + Spike Pic + Skip Spaces Omet els espais diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index dede785a7..3051468cd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6260,3 +6260,13 @@ breakText Glyphs Glyfy Create Swatch Legend Vytvořit legendu vzorníku Create Swatch Legend… Vytvořit legendu vzorníku… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Nástroje textu + Text Tweaker… Ladění textu… + Text Tweaker Ladění textu + Attribute: Atribut: + From: Od: + To: Do: + Bow Oblouk + Spike Špička + Skip Spaces Přeskočit mezery diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6d307f60f..aecb2a8c8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6128,3 +6128,13 @@ breakText Glyphs Glyffer Create Swatch Legend Opret farveprøveforklaring Create Swatch Legend… Opret farveprøveforklaring… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Tekstværktøjer + Text Tweaker… Tekstjustering… + Text Tweaker Tekstjustering + Attribute: Attribut: + From: Fra: + To: Til: + Bow Bue + Spike Spids + Skip Spaces Spring mellemrum over diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f1aece539..6fe42bdfa 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5846,3 +5846,13 @@ breakText Glyphs Glyphen Create Swatch Legend Farbfeldlegende erstellen Create Swatch Legend… Farbfeldlegende erstellen… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Textwerkzeuge + Text Tweaker… Text-Tweaker… + Text Tweaker Text-Tweaker + Attribute: Attribut: + From: Von: + To: Bis: + Bow Bogen + Spike Spitze + Skip Spaces Leerzeichen überspringen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 56e14d395..a0ca7ab17 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6212,3 +6212,13 @@ breakText Glyphs Γλύφοι Create Swatch Legend Δημιουργία υπομνήματος δειγμάτων χρωμάτων Create Swatch Legend… Δημιουργία υπομνήματος δειγμάτων χρωμάτων… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Εργαλεία κειμένου + Text Tweaker… Ρύθμιση κειμένου… + Text Tweaker Ρύθμιση κειμένου + Attribute: Ιδιότητα: + From: Από: + To: Έως: + Bow Τόξο + Spike Αιχμή + Skip Spaces Παράλειψη κενών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 84b860be9..9907c409d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6202,3 +6202,13 @@ breakText Glyphs Glifos Create Swatch Legend Crear leyenda de muestras Create Swatch Legend… Crear leyenda de muestras… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Herramientas de texto + Text Tweaker… Ajustador de texto… + Text Tweaker Ajustador de texto + Attribute: Atributo: + From: Desde: + To: Hasta: + Bow Arco + Spike Pico + Skip Spaces Omitir espacios diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 14d0957e0..176498542 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6290,3 +6290,13 @@ breakText Glyphs Merkit Create Swatch Legend Luo värimallien selite Create Swatch Legend… Luo värimallien selite… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Tekstityökalut + Text Tweaker… Tekstin säätö… + Text Tweaker Tekstin säätö + Attribute: Määrite: + From: Alku: + To: Loppu: + Bow Kaari + Spike Piikki + Skip Spaces Ohita välilyönnit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0e8434dc4..3d4eb8583 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6255,3 +6255,13 @@ breakText Glyphs Glyphes Create Swatch Legend Créer une légende des nuances Create Swatch Legend… Créer une légende des nuances… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Outils de texte + Text Tweaker… Ajusteur de texte… + Text Tweaker Ajusteur de texte + Attribute: Attribut : + From: De : + To: À : + Bow Arc + Spike Pointe + Skip Spaces Ignorer les espaces diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e3fee02cb..7dfdf687c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6244,3 +6244,13 @@ breakText Glyphs Glifák Create Swatch Legend Színmintajelmagyarázat létrehozása Create Swatch Legend… Színmintajelmagyarázat létrehozása… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Szövegeszközök + Text Tweaker… Szöveghangoló… + Text Tweaker Szöveghangoló + Attribute: Jellemző: + From: Ettől: + To: Eddig: + Bow Ív + Spike Csúcs + Skip Spaces Szóközök kihagyása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 06cf4da3a..b2aa56920 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6128,3 +6128,13 @@ breakText Glyphs Glif Create Swatch Legend Buat Legenda Swatch Create Swatch Legend… Buat Legenda Swatch… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Alat Teks + Text Tweaker… Penyetel Teks… + Text Tweaker Penyetel Teks + Attribute: Atribut: + From: Dari: + To: Ke: + Bow Busur + Spike Puncak + Skip Spaces Lewati Spasi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 742624e12..50bf09162 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5880,3 +5880,13 @@ breakText Glyphs Glifi Create Swatch Legend Crea legenda campioni Create Swatch Legend… Crea legenda campioni… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Strumenti testo + Text Tweaker… Regolatore testo… + Text Tweaker Regolatore testo + Attribute: Attributo: + From: Da: + To: A: + Bow Arco + Spike Picco + Skip Spaces Salta gli spazi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1584618d1..7b0a1e639 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5945,3 +5945,13 @@ breakText Glyphs 字形 Create Swatch Legend スウォッチの凡例を作成 Create Swatch Legend… スウォッチの凡例を作成… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools テキストツール + Text Tweaker… テキストの調整… + Text Tweaker テキストの調整 + Attribute: 属性: + From: 開始: + To: 終了: + Bow 弓形 + Spike 山形 + Skip Spaces スペースをスキップ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fabc4a369..52bdf7599 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6248,3 +6248,13 @@ breakText Glyphs გლიფები Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools ტექსტის ხელსაწყოები + Text Tweaker… ტექსტის მორგება… + Text Tweaker ტექსტის მორგება + Attribute: ატრიბუტი: + From: საწყისი: + To: საბოლოო: + Bow რკალი + Spike პიკი + Skip Spaces ჰარების გამოტოვება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7376f14d8..a36341694 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5841,3 +5841,13 @@ breakText Glyphs 글리프 Create Swatch Legend 견본 범례 만들기 Create Swatch Legend… 견본 범례 만들기… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools 텍스트 도구 + Text Tweaker… 텍스트 조정… + Text Tweaker 텍스트 조정 + Attribute: 속성: + From: 시작: + To: 끝: + Bow 활 모양 + Spike 뾰족한 모양 + Skip Spaces 공백 건너뛰기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c2670658d..cc9970da9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6172,3 +6172,13 @@ breakText Glyphs Glyphs Create Swatch Legend Stalenlegenda maken Create Swatch Legend… Stalenlegenda maken… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Teksthulpmiddelen + Text Tweaker… Tekst bijstellen… + Text Tweaker Tekst bijstellen + Attribute: Kenmerk: + From: Van: + To: Tot: + Bow Boog + Spike Piek + Skip Spaces Spaties overslaan diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 10ebc96bd..14765e9e7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6115,3 +6115,13 @@ breakText Glyphs Glify Create Swatch Legend Utwórz legendę próbek Create Swatch Legend… Utwórz legendę próbek… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Narzędzia tekstu + Text Tweaker… Strojenie tekstu… + Text Tweaker Strojenie tekstu + Attribute: Atrybut: + From: Od: + To: Do: + Bow Łuk + Spike Szpic + Skip Spaces Pomiń spacje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f43b74b97..798b3db35 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6333,3 +6333,13 @@ swatch Yellow Green Verde-amarelado Create Swatch Legend Criar Legenda de Amostras Create Swatch Legend… Criar Legenda de Amostras… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Ferramentas de texto + Text Tweaker… Ajustador de texto… + Text Tweaker Ajustador de texto + Attribute: Atributo: + From: De: + To: Até: + Bow Arco + Spike Pico + Skip Spaces Ignorar espaços diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0a42894b6..52fc8da78 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6349,3 +6349,13 @@ breakText Glyphs Глифы digits Hindi Хинди font-script Japanese Японский shape Open Открытый +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Инструменты текста + Text Tweaker… Настройка текста… + Text Tweaker Настройка текста + Attribute: Атрибут: + From: От: + To: До: + Bow Дуга + Spike Пик + Skip Spaces Пропускать пробелы diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 214b19aed..725bad018 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6316,3 +6316,13 @@ breakText Glyphs Glyfer # The strings the report listed as missing for sv. Font Size: Teckenstorlek: digits Hindi Hindi +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Textverktyg + Text Tweaker… Textjustering… + Text Tweaker Textjustering + Attribute: Attribut: + From: Från: + To: Till: + Bow Båge + Spike Spets + Skip Spaces Hoppa över mellanslag diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cdf14d3f5..9f7c47f74 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6217,3 +6217,13 @@ breakText Glyphs Glifler Create Swatch Legend Renk Örneği Açıklaması Oluştur Create Swatch Legend… Renk Örneği Açıklaması Oluştur… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Metin Araçları + Text Tweaker… Metin Ayarlayıcı… + Text Tweaker Metin Ayarlayıcı + Attribute: Öznitelik: + From: Başlangıç: + To: Bitiş: + Bow Yay + Spike Sivri Uç + Skip Spaces Boşlukları Atla diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 112ed5840..43c5208dd 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5860,3 +5860,13 @@ breakText Glyphs Гліфи Create Swatch Legend Створити легенду зразків Create Swatch Legend… Створити легенду зразків… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Інструменти тексту + Text Tweaker… Налаштування тексту… + Text Tweaker Налаштування тексту + Attribute: Атрибут: + From: Від: + To: До: + Bow Дуга + Spike Пік + Skip Spaces Пропускати пробіли diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 05b1dda5c..49b89fb6f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5861,3 +5861,13 @@ breakText Glyphs Glyph Create Swatch Legend Tạo chú giải mẫu màu Create Swatch Legend… Tạo chú giải mẫu màu… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools Công cụ văn bản + Text Tweaker… Tinh chỉnh văn bản… + Text Tweaker Tinh chỉnh văn bản + Attribute: Thuộc tính: + From: Từ: + To: Đến: + Bow Cung + Spike Đỉnh nhọn + Skip Spaces Bỏ qua khoảng trắng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 336057c43..372a0681d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6101,3 +6101,13 @@ breakText Glyphs 字形 Create Swatch Legend 创建色板图例 Create Swatch Legend… 创建色板图例… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools 文字工具 + Text Tweaker… 文字微调… + Text Tweaker 文字微调 + Attribute: 属性: + From: 起始: + To: 结束: + Bow 弓形 + Spike 尖峰 + Skip Spaces 跳过空格 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4ddbf0f69..ac1629201 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5898,3 +5898,13 @@ breakText Glyphs 字符 Create Swatch Legend 建立色票圖例 Create Swatch Legend… 建立色票圖例… HEX HEX +# Type Tools add-on (Object › Plug-ins › Type Tools). + Type Tools 文字工具 + Text Tweaker… 文字微調… + Text Tweaker 文字微調 + Attribute: 屬性: + From: 起始: + To: 結束: + Bow 弓形 + Spike 尖峰 + Skip Spaces 略過空格 diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 7a3213f78..0b30e0dc6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -71,6 +71,7 @@ pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static st crate::addons::pathtools::dashes::COMMAND => (crate::addons::pathtools::dashes::KIND, crate::addons::pathtools::dashes::fields()), crate::addons::color::nearest::COMMAND => (crate::addons::color::nearest::KIND, crate::addons::color::nearest::fields()), crate::addons::color::legend::COMMAND => (crate::addons::color::legend::KIND, crate::addons::color::legend::fields()), + crate::addons::typetools::COMMAND => (crate::addons::typetools::KIND, crate::addons::typetools::fields(app)), "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::open_fields(app)), _ => return None, }) diff --git a/docs/mcp.md b/docs/mcp.md index bbd4451f5..a7fb8f10f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6777,3 +6777,30 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la ```json {"name":"run_command","arguments":{"command":"addon.document.symbolsFromLayers","params":{"replace":true}}} ``` + +## 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, +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, so on scaled type it is the size shown. +It changes the range of `id` (`start`/`end` bytes), else all the text of the type objects in `ids`, else the text the +Type tool has selected (at a caret: all of that object's text), else all the text of every selected type object; each +object gets its own ramp, and at most 100000 characters change in one run. A run that changes nothing makes no step. + +| Parameter | Values (default) | +|---|---| +| `attribute` | `size` (pt 0.1…1296), `baselineShift` (pt −1296…1296), `rotation` (° −360…360), `hScale`, `vScale` (% 1…10000), `tracking` (1/1000 em −1000…10000), `leading` (pt 0.1…5000), `strokeWidth` (pt 0…1000; shows only on characters with a stroke) (`size`) | +| `from`, `to` | the first and last character's values, in the attribute's units and range (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` (the same, in straight lines), `random` (each character anywhere from `from` to `to`) (`straight`) | +| `seed` | whole number (0): `random` with the same seed and text gives the same values | +| `skipSpaces` | true: spaces and line breaks keep their values and don't count | + +→ `{count (characters changed), ids}`. Text Tweaker… opens a dialog (`ui.menuDialog {command}`, dialog kind +`textTweaker`) whose fields are the command's parameters, with Preview; another attribute starts from its own +default values, and the seed starts new whenever the document has changed. + +```json +{"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}}} +``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a76d8e44a..9f54f0f89 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-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; Units › East Asian Type acts, with Q and Ha units; 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-03 · **Change:** minor (Type Tools add-on: Text Tweaker, #4924; font-family fields accept the mouse wheel, #5253; Units › East Asian Type acts, with Q and Ha units; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | | 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | @@ -1500,6 +1501,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 › Arrange Tools › Sort Objects… sorts the top-level selected objects (or `ids`, at most 20000) by one attribute (width, height, area, x or y position, opacity or stacking order; small to large, large to small, or a random order from a seed) and gives them another in that order, the first the smallest value: the objects trade the values they have, or with Distribute Evenly the values step evenly from the smallest to the largest. Lining objects up left to right by size, restacking them so the largest is at the back, or giving them heights that step with their x position are each one run. Sizes and places are measured and set as `object.setBounds` measures them (Use Preview Bounds); a width, height or area changes about each object's centre (an area in proportion), and stacking order trades places only among objects of the same layer or group. One undo step; no step when nothing changes. Tests: `sort_*` in `cmd::addons::arrange::tests` in `vectorcraft-engine` (each attribute sorted by and applied, both directions, distribute, layers kept apart, the seeded random order, `ids`, no step when in order, bad params), `sort_objects_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Arrange Tools add-on. +## Type Tools add-on: Text Tweaker (2026-11-03) + +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. + ## 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.