diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 671b18a55..8ce750a5e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first character of a line and for a right-to-left character; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", has_doc, touch_type ), @@ -1017,11 +1017,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // Document points per text-space point along `v`. let doc_len = |v: Vec2| linear(t.xf, v).hypot() / v.hypot(); let limit = 1296.0 / points; - if dx != 0.0 && g.rtl { - return Err(bad(C, "a right-to-left character moves only up and down (dy)")); - } // A list marker's glyphs (length 0) stand for no character. - let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a); + let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a && h.rtl == g.rtl); let pr = prev.map(|h| (h.byte, h.byte + h.len)); let db = vectorcraft_text::FontDb::global(); let em = glyph_em(db, &t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?; @@ -1065,8 +1062,10 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // to Fit and a path effect such as Skew scale: two layouts with the call's other changes and shifts one step apart, both within the range, // measure that length. Neither shift is one that a run already has, so the character's run // splits off alike in both. - let raise = if dy == 0.0 { - 0.0 + let drawn_move = g.rtl && moved != 0.0; + let up = Vec2::new(d.y, -d.x); + let (mut raise, baseline_step) = if dy == 0.0 && !drawn_move { + (0.0, 0.0) } else { let shift = edit::slice_runs(&t.runs, a, b).first().map_or(0.0, |r| r.style.baseline_shift); let from = shift.clamp(-limit, limit); @@ -1078,21 +1077,44 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // The character's pen with its baseline shift set to `to`. let pen_at = |to: f64| { let mut trial = t.clone(); - touch(&mut trial, None, None, to - shift); + // Hold kerning fixed: Optical spacing can change along the line as the baseline + // rises, which would make its horizontal drift count as baseline movement. + touch(&mut trial, pr.map(|_| 0.0), Some(0.0), to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) }; - let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) { - (Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(), - _ => 0.0, + let (per_step, across) = match (pen_at(from + step), pen_at(from + 2.0 * step)) { + (Some(p), Some(q)) => (linear(t.xf, q - p).hypot() / step.abs(), (q - p).dot(up) / step), + _ => (0.0, 0.0), }; - dy / per_step + (dy / per_step, across) }; if !raise.is_finite() { return Err(bad(C, "the character's size can't be measured")); } - // Where a glyph starts along the line. - let pen = |h: &vectorcraft_text::PositionedGlyph| h.origin.to_vec2().dot(along); + // Along-line RTL movement must measure the drawn glyph: turning off automatic kerning can + // also remove font placement, and Character Rotation turns that placement across the line. + let pen = |h: &vectorcraft_text::PositionedGlyph| { + let at = if drawn_move { + h.xf * Point::ZERO + } else if h.rtl { + h.origin + Vec2::new(h.angle.cos(), h.angle.sin()) * h.width_start + } else { + h.origin + }; + at.to_vec2().dot(along) + }; + // Scaling or rotation in the same call may intentionally turn the glyph's font placement + // about its pen. Keep that change while compensating placement lost to manual kerning. + let intrinsic_delta = if drawn_move && ["hScale", "vScale", "rotation"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + let mut trial = t.clone(); + touch(&mut trial, None, None, 0.0); + let l = vectorcraft_text::layout(db, &trial); + let intrinsic = |h: &vectorcraft_text::PositionedGlyph| h.xf * Point::ZERO - h.xf * h.pivot; + l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map_or(Vec2::ZERO, |h| intrinsic(h) - intrinsic(g)) + } else { + Vec2::ZERO + }; // How much further the character's new scales advance it along the line: the character laid // out alone, in point type of the same orientation, before and after the change. let grown = { @@ -1106,10 +1128,27 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { }; // The character lands `moved` further on, and the character after it on its line keeps its // distance from it, wider or narrower only by the character's new scales. - let goal = pen(g) + moved; + let goal = pen(g) + moved + intrinsic_delta.dot(along); let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.len > 0 && h.line == g.line).map(|h| pen(h) + grown); - // How far the character and the character after it on its line miss their places, along the - // line, with the kerning `k` (before, own) added. + let prev_goal = prev.map(pen); + let mut k = if moved != 0.0 { (em_prev.map(|ep| moved / ep * 1000.0), Some(-moved / em * 1000.0)) } else { (None, None) }; + if drawn_move { + if !baseline_step.is_finite() || baseline_step.abs() <= 1e-12 { + return Err(bad(C, "the character's size can't be measured")); + } + let mut trial = t.clone(); + touch(&mut trial, k.0, k.1, raise); + let l = vectorcraft_text::layout(db, &trial); + let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0).ok_or_else(|| bad(C, "the character's size can't be measured"))?; + let across_goal = (g.xf * Point::ZERO).to_vec2().dot(up) + dy / doc_len(up) + intrinsic_delta.dot(up); + raise += (across_goal - (h.xf * Point::ZERO).to_vec2().dot(up)) / baseline_step; + if !raise.is_finite() { + return Err(bad(C, "the character's size can't be measured")); + } + } + // Keep both neighbors: alignment can pin either end of an RTL line, making one neighbor + // insensitive to kerning even though the other one moves. The two spacing values must place + // the selected character and keep every available neighbor in place. let miss = |k: (Option, Option)| { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); @@ -1119,30 +1158,40 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; - Some((pen(h) - goal, next)) + let previous = match (pr.and_then(|(byte, _)| l.glyphs.iter().find(|n| n.byte == byte && n.len > 0 && n.line == h.line)), prev_goal) { + (Some(n), Some(at)) => pen(n) - at, + _ => 0.0, + }; + Some([pen(h) - goal, next, previous]) }; - let placed = |m: (f64, f64)| m.0.abs() <= 1e-9 && m.1.abs() <= 1e-9; - let mut k = if moved != 0.0 { (em_prev.map(|ep| moved / ep * 1000.0), Some(-moved / em * 1000.0)) } else { (None, None) }; + let placed = |m: [f64; 3]| m.iter().all(|v| v.abs() <= 1e-9); // A manual kerning value turns off the font's kerning for its character, each style run is // shaped apart, and centered, right-aligned and justified lines move as their width changes, so // the first values can miss. Layouts then measure what each kerning value does to the two - // characters, and the values are solved for from that (at most six layouts). - if !g.rtl && miss(k).is_some_and(|m| !placed(m)) { + // characters, and the values are solved for with a bounded set of layouts. + if miss(k).is_some_and(|m| !placed(m)) { k = (pr.map(|_| k.0.unwrap_or(0.0)), Some(k.1.unwrap_or(0.0))); if let Some(mut m) = miss(k) { const STEP: f64 = 10.0; - let column = |dk: (f64, f64)| miss((k.0.map(|v| v + dk.0), k.1.map(|v| v + dk.1))).map(|n| ((n.0 - m.0) / STEP, (n.1 - m.1) / STEP)); + let column = |dk: (f64, f64)| miss((k.0.map(|v| v + dk.0), k.1.map(|v| v + dk.1))).map(|n| std::array::from_fn(|i| (n[i] - m[i]) / STEP)); let by_before = if k.0.is_some() { column((STEP, 0.0)) } else { None }; let by_own = column((0.0, STEP)); // The kerning changes that cancel the misses `m`. - let solve = |m: (f64, f64)| match (by_before, by_own) { - (Some(p), Some(q)) if (p.0 * q.1 - q.0 * p.1).abs() > 1e-12 => { - let det = p.0 * q.1 - q.0 * p.1; - ((q.0 * m.1 - m.0 * q.1) / det, (m.0 * p.1 - p.0 * m.1) / det) + let solve = |m: [f64; 3]| { + let dot = |a: [f64; 3], b: [f64; 3]| a.into_iter().zip(b).map(|(a, b)| a * b).sum::(); + let (p, q) = (by_before.unwrap_or([0.0; 3]), by_own.unwrap_or([0.0; 3])); + let (pp, pq, qq) = (dot(p, p), dot(p, q), dot(q, q)); + let (pm, qm) = (dot(p, m), dot(q, m)); + let det = pp * qq - pq * pq; + if det > 1e-12 * pp * qq { + ((pq * qm - qq * pm) / det, (pq * pm - pp * qm) / det) + } else if pp >= qq && pp > 1e-24 { + (-pm / pp, 0.0) + } else if qq > 1e-24 { + (0.0, -qm / qq) + } else { + (0.0, 0.0) } - (Some(p), _) if p.0.abs() > 1e-12 => (-m.0 / p.0, 0.0), - (_, Some(q)) if q.1.abs() > 1e-12 => (0.0, -m.1 / q.1), - _ => (0.0, 0.0), }; for _ in 0..2 { if placed(m) { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 2cdf299ce..60243f343 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -627,6 +627,33 @@ fn touch_type_moves_one_character_in_centered_and_right_aligned_lines() { } } +#[test] +fn touch_type_centered_ltr_scale_keeps_the_expected_width_change() { + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 200, "y": 100, "text": "Type", "size": 40, "justify": "center"})).unwrap()["id"].as_u64().unwrap(), + ); + let before = glyph_pens(&s, id); + s.execute("text.touchType", &json!({"id": id.0, "start": 1, "hScale": 150})).unwrap(); + let after = glyph_pens(&s, id); + let previous = moved_by(&before, &after, 0).0; + let following = moved_by(&before, &after, 2).0; + assert!(previous < -1.0, "centered line shifts left: {previous}"); + assert!(following > 1.0, "the wider character shifts following text right: {following}"); + assert!((following + previous).abs() < 0.1, "width growth is shared around the center: {previous}, {following}"); +} + +#[test] +fn touch_type_refuses_horizontal_move_across_a_direction_boundary() { + let mut s = session(); + let id = text(&mut s, "abc אבג"); + let before = obj(&s, id); + let result = s.execute("text.touchType", &json!({"id": id.0, "start": 4, "dx": 5})); + assert!(result.is_err(), "a mixed-direction boundary has no safe preceding glyph for kerning"); + assert_eq!(obj(&s, id), before, "a refused move leaves the text unchanged"); + s.execute("text.touchType", &json!({"id": id.0, "start": 4, "dy": 3})).unwrap(); +} + /// `dy` moves a rotated character that many document points up from its line, also in type /// scaled unevenly. #[test] @@ -746,15 +773,15 @@ fn touch_type_raises_a_rotated_character_on_a_skewed_path() { assert!(((x * x + y * y).sqrt() - 10.0).abs() < 1e-6, "({x}, {y})"); } -/// A right-to-left character moves only up and down. +/// A right-to-left character can move along its line and up from it. #[test] -fn touch_type_moves_a_right_to_left_character_only_up_and_down() { +fn touch_type_moves_a_right_to_left_character_along_its_line_and_up() { let mut s = session(); let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "אבג", "size": 40})).unwrap()["id"].as_u64().unwrap()); - assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dx": 5})).is_err()); let before = glyph_pens(&s, id); - s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dy": 3})).unwrap(); - let (_, up) = moved_by(&before, &glyph_pens(&s, id), 2); + s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dx": 5, "dy": 3})).unwrap(); + let (along, up) = moved_by(&before, &glyph_pens(&s, id), 2); + assert!((along + 5.0).abs() < 1e-9, "{along}"); assert!((up + 3.0).abs() < 1e-9, "{up}"); } @@ -1568,6 +1595,141 @@ fn rtl_edit_commands_and_undo_preserve_logical_source_and_style_ranges() { assert_eq!(obj(&s, id).plain_text(), "שלום Rust 123"); } +#[test] +fn touch_type_rtl_move_keeps_neighbors_and_undo_restores_the_original() { + use vectorcraft_text::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font}; + + // Repeated registration of this synthetic face is harmless in the shared database. + vectorcraft_text::FontDb::global().add_font(right_to_left_optical_font().unwrap()); + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 300, "y": 250, "text": "אבא", "size": 100, "font": RIGHT_TO_LEFT_OPTICAL_FAMILY})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let original = obj(&s, id); + let before = glyph_pens(&s, id); + assert_eq!(before.len(), 3); + s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dx": 5, "dy": 3})).unwrap(); + let after = glyph_pens(&s, id); + assert!(near(moved_by(&before, &after, 2), (-5.0, -3.0)), "{:?}", moved_by(&before, &after, 2)); + for byte in [0, 4] { + assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "neighbor {byte}: {:?}", moved_by(&before, &after, byte)); + } + assert_eq!(obj(&s, id).plain_text(), original.plain_text()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id), original); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(near(moved_by(&before, &glyph_pens(&s, id), 2), (-5.0, -3.0))); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dx": 5})).is_err()); +} + +#[test] +fn touch_type_rtl_move_keeps_rotated_font_placement() { + use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; + vectorcraft_text::FontDb::global().add_font(pair_kerning_font().unwrap()); + let mut s = session(); + // An explicit RTL override gives the existing synthetic S/S positioning pair RTL order. + let id = + NodeId( + s.execute("text.create", &json!({"x": 300, "y": 250, "text": "\u{202e}SSS\u{202c}", "size": 100, "font": PAIR_KERNING_FAMILY})).unwrap() + ["id"] + .as_u64() + .unwrap(), + ); + s.execute("text.touchType", &json!({"id": id.0, "start": 4, "rotation": 90})).unwrap(); + let original = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &original); + let glyph = lay.glyphs.iter().find(|g| g.byte == 4).unwrap(); + assert!(glyph.rtl && glyph.width_start.abs() > 0.1, "fixture needs RTL font x-placement"); + let before = glyph_pens(&s, id); + s.execute("text.touchType", &json!({"id": id.0, "start": 4, "dx": 5, "dy": 3})).unwrap(); + let after = glyph_pens(&s, id); + assert!(near(moved_by(&before, &after, 4), (-5.0, -3.0)), "{:?}", moved_by(&before, &after, 4)); + for byte in [3, 5] { + assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "neighbor {byte}: {:?}", moved_by(&before, &after, byte)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id), original); +} + +#[test] +fn touch_type_rtl_moves_rotated_characters_in_aligned_and_transformed_text() { + use vectorcraft_geom::{Affine, Vec2}; + use vectorcraft_text::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font}; + + vectorcraft_text::FontDb::global().add_font(right_to_left_optical_font().unwrap()); + let matrices = + [Affine::IDENTITY, Affine::scale_non_uniform(2.0, 0.5), Affine::new([1.0, 0.25, 0.4, 1.2, 0.0, 0.0]), Affine::scale_non_uniform(-1.0, 1.0)]; + for justify in ["auto", "left", "center", "right"] { + for rotation in [0.0, 90.0, -90.0] { + for matrix in matrices { + for (kerning, start) in [("auto", 2), ("optical", 2), ("auto", 4), ("optical", 4)] { + let case = format!("{justify}, {rotation} degrees, {matrix:?}, {kerning}, byte {start}"); + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 300, "y": 250, "text": "אבא", "size": 100, "font": RIGHT_TO_LEFT_OPTICAL_FAMILY, "justify": justify}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": kerning})).unwrap(); + s.execute("object.transform", &json!({"ids": [id.0], "matrix": matrix.as_coeffs()})).unwrap(); + s.execute("text.touchType", &json!({"id": id.0, "start": start, "rotation": rotation})).unwrap(); + let original = obj(&s, id); + let before = glyph_pens(&s, id); + let linear = |v: Vec2| original.xf * (Point::ZERO + v) - original.xf * Point::ZERO; + let expected = linear(Vec2::new(-1.0, 0.0)).normalize() * 5.0 + linear(Vec2::new(0.0, -1.0)).normalize() * 3.0; + s.execute("text.touchType", &json!({"id": id.0, "start": start, "dx": 5, "dy": 3})).unwrap(); + let after = glyph_pens(&s, id); + assert!( + near(moved_by(&before, &after, start), (expected.x, expected.y)), + "{case}: {:?}, expected {expected:?}", + moved_by(&before, &after, start) + ); + for byte in [0, 2, 4].into_iter().filter(|byte| *byte != start) { + assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "{case}, neighbor {byte}: {:?}", moved_by(&before, &after, byte)); + } + let changed = obj(&s, id); + let reopened: TextObject = serde_json::from_value(serde_json::to_value(&changed).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(&changed).unwrap(), + serde_json::to_value(&reopened).unwrap(), + "{case}: stored attributes round trip" + ); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &reopened); + for g in &layout.glyphs { + let expected = after.iter().find(|(byte, _)| *byte == g.byte).unwrap().1; + assert!( + (reopened.xf * (g.xf * Point::ZERO) - expected).hypot() < 1e-6, + "{case}: reopened character {} keeps its place", + g.byte + ); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id), original, "{case}: Undo restores every attribute"); + } + } + } + } +} + +#[test] +fn touch_type_optical_spacing_does_not_shorten_a_baseline_nudge() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 300, "y": 250, "text": "AVA", "size": 100, "font": "Source Sans 3"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": "optical"})).unwrap(); + let before = glyph_pens(&s, id); + s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 3})).unwrap(); + let after = glyph_pens(&s, id); + assert!(near(moved_by(&before, &after, 1), (0.0, -3.0)), "{:?}", moved_by(&before, &after, 1)); + for byte in [0, 2] { + assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "neighbor {byte}: {:?}", moved_by(&before, &after, byte)); + } +} + #[test] fn automatic_alignment_updates_during_edits_and_can_be_overridden() { let mut s = session(); diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index 7f8e06ea2..c1184d72a 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -64,7 +64,7 @@ struct CharBox { ex: Vec2, ey: Vec2, /// Whether the tool moves it along its line by kerning: a character comes before it on its - /// line and it is not right-to-left. + /// line in logical order. kerned: bool, /// Its style as the Character panel shows it (horizontal scale with the object transform). style: CharStyle, @@ -184,7 +184,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { up: unit(linear(m * g.unturned, Vec2::new(0.0, -1.0))), ex, ey, - kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.len > 0 && h.byte + h.len == g.byte), + kerned: lay.glyphs.iter().any(|h| h.line == g.line && h.len > 0 && h.byte + h.len == g.byte && h.rtl == g.rtl), style: t.effective_char_style(&style)?, }) } diff --git a/crates/tools/src/touchtype/tests.rs b/crates/tools/src/touchtype/tests.rs index 5556efda0..99388178e 100644 --- a/crates/tools/src/touchtype/tests.rs +++ b/crates/tools/src/touchtype/tests.rs @@ -384,3 +384,48 @@ fn the_tool_is_made_by_its_id() { t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)); assert_eq!(t.options(), json!({"id": id.0, "start": 0})); } + +#[test] +fn touch_type_rtl_middle_character_can_drag_and_nudge_along_its_line() { + use vectorcraft_text::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font}; + + // The process-wide database deduplicates the same synthetic face across tests. + FontDb::global().add_font(right_to_left_optical_font().unwrap()); + let mut d = Document::new(500.0, 500.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let style = CharStyle { font_family: RIGHT_TO_LEFT_OPTICAL_FAMILY.into(), size: 100.0, ..Default::default() }; + let text = TextObject::point(Point::new(300.0, 250.0), "אבא", style); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + assert_eq!(lay.glyphs.len(), 3); + assert!(lay.glyphs.iter().all(|g| g.rtl)); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut tool = TouchTypeTool { target: Some((id, 2)), ..Default::default() }; + let q = quad(&tool, &cx); + let acts = drag(&mut tool, &cx, q[3], q[3] + Vec2::new(-5.0, -3.0)); + let params = preview(&acts[1]); + assert!(close(params["dx"].as_f64().unwrap(), 5.0) && close(params["dy"].as_f64().unwrap(), 3.0), "{params}"); + let acts = tool.key(&cx, ToolKey::Left, Mods::default()); + assert_eq!(acts.len(), 1); + let Action::Exec(command, params) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(command, "text.touchType"); + assert!(close(params["dx"].as_f64().unwrap(), 1.0), "left is forward in reading order: {params}"); + tool.target = Some((id, 0)); + assert!(tool.key(&cx, ToolKey::Left, Mods::default()).is_empty(), "the first logical character has no preceding character to kern"); +} + +#[test] +fn touch_type_direction_boundary_offers_only_vertical_movement() { + let text = TextObject::point(Point::new(100.0, 200.0), "abc אבג", CharStyle { size: 40.0, ..Default::default() }); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + let at = lay.glyphs.iter().position(|g| g.byte == 4).unwrap(); + let b = char_box(&text, &lay, at).unwrap(); + assert!(!b.kerned, "the previous logical character has a different reading direction"); + let params = TouchTypeTool::nudge_params(NodeId(1), &text, &b, -5.0, -3.0); + assert!(params.get("dx").is_none(), "{params}"); + assert!(params.get("dy").is_some(), "{params}"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e8d3af08..cd27fae7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,6 +24,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard ### Feature revisions +M7 type: Touch Type moves a right-to-left character along its line and up from it while keeping the text editable. A preceding logical character on the same line is required for along-line movement. Direction rules are VectorCraft's choices; estimates are unchanged. + | Date | Change | Summary | |---|---|---| | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | @@ -160,6 +162,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-02 | minor | Font-family fields in Character, Properties and Control step selected text with the wheel (#5253), with one undo step per notch; scores and estimates unchanged. | | 2026-11-03 | minor | M7 records Find Font's shipped replacement-source chooser (#5217); estimates unchanged. | | 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. | +| 2026-11-03 | minor | M7: Touch Type moves right-to-left characters along their line while keeping text editable; first-logical-character restriction retained. Estimates unchanged. | | 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e52f45a1..b4370a3a0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -29,6 +29,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 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-11-03 | minor | Enclosing Lasso adds freehand whole-object enclosure selection under Object › Plug-ins; scores and estimates 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. | +| 2026-11-03 | minor | Touch Type moves right-to-left characters along their line when a preceding logical character is on the same line; direction rules are VectorCraft's choices. Scores and estimates unchanged. | | 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | | 2026-11-03 | minor | Composition spacing stays along the line under Character Rotation, including the turning point and unrotated placement; scores and estimates unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | @@ -779,6 +780,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. + Right-to-left characters can also move along their line when a preceding logical character is on that line. Positive `text.touchType` `dx` follows reading order, toward the left in horizontal right-to-left text; the first logical character still moves only up and down. These direction rules are VectorCraft's choices; the public Touch Type page describes moving a character without specifying this case. Scores and estimates are unchanged. - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. @@ -1568,6 +1570,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente | 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. | | 2026-11-03 | minor | SVG imports preserve exported ellipses as four-anchor paths (#5243). Inline graphics use manual kerning (#5245). Symbol editing shows draggable 9-slice guides (#5280). Recolor Artwork offers smooth and segmented wheels and color bars (#5249). Scores and hours remain unchanged. | | 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. | +| 2026-11-03 | minor | Touch Type supports along-line RTL movement while retaining the first-logical-character restriction; scores and estimates unchanged. | | 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. | | 2026-11-03 | minor | Rotated characters retain line-axis composition trimming; scores and estimates unchanged. | | 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 48a08598d..5e506c236 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-03 · **Change:** minor (Character-menu Kashidas switch; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Touch Type moves right-to-left characters along their line; 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 @@ -49,7 +49,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | -| Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); the first character of a line and right-to-left characters move only up and down | +| Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | | Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`): with Smart Guides on, new point type and drawing tools snap to live-text baselines and glyph bounds (`Targets::for_drawing`, `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`). Character panel menu › Show Snap to Glyph Options: Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides and Anchor Point each turn their guides on or off (`view.snapToGlyph.options`, all on by default; #4232) | | Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web | @@ -80,6 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. | | 2026-11-02 | minor | The wheel over Character, Properties and Control font-family fields changes selected text, one undo step per notch (#5253); scores and estimates unchanged. | +| 2026-11-03 | minor | Touch Type moves right-to-left characters along their line and up from it; first logical characters retain the along-line restriction. Scores and estimates unchanged. | | 2026-11-01 | minor | The Type and Vertical Type tools make area type in a clicked closed path and type on a path along a clicked open one, with their own pointers; each type tool has its own icon; scores unchanged (estimated) | | 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; scores unchanged | | 2026-11-01 | minor | Glyphs panel (#4908): the character selected with the Type tool (a range's first, else the one before the caret) is highlighted in the grid and scrolled into view when the selection moves to another character; Show › Alternates for Current Selection not yet; scores unchanged |