From f8307b19cde0cb71648d2b7a04fc4676355321d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 21:36:30 -0800 Subject: [PATCH 1/2] Type: on a vertical path, letters lie a quarter turn clockwise from upright glyphs, in a column centered on the path Follows #5557. Letters and digits lying on their side in vertical type on a path kept their top toward the path's end, opposite the upright glyphs. They are now set as horizontal type on the path sets them: baseline along the path, top to the path's left, a quarter turn clockwise from upright glyphs (CSS Writing Modes 3, Unicode UAX #50). - The column is centered on the path: upright glyphs, letters on their side and tate-chu-yoko blocks have their em box centers on the path line instead of a column one em wide to the path's right. - Upright glyphs are turned in the path effect's glyph space, so with Skew on a curve their top runs back along the tangent and their x axis stays up the page, as letters keep theirs. - Tate-chu-yoko blocks on a path stand upright, stay whole on a curve and move by Tate-chu-yoko Settings toward the path's start and left. - A tate-chu-yoko block of mixed sizes sets every glyph in its first character's em, on a path and off it. Existing vertical type on a path redraws with these placements. --- crates/text/src/layout.rs | 94 ++++++++-- crates/text/src/layout/driver.rs | 1 + crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/tests/ligature_carets.rs | 10 +- crates/text/src/tests/vertical.rs | 214 +++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 299 insertions(+), 23 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8e594c036..4a92c4ed3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -137,6 +137,10 @@ struct Ctx<'a> { /// next glyph of its cluster would take, the turn it got, the line-space x that next glyph is /// set after), so that a combining mark shaped into the same cluster turns with it. upright_cluster: Option<(usize, usize, Affine, f64)>, + /// Vertical type on a path: the tate-chu-yoko block [`Self::emit`] is placing, as (the + /// block's transform from its first glyph's place on the path, the center of its em in the + /// column before that transform), so that the block's other glyphs stand with it on a curve. + tcy_block: Option<(Affine, Point)>, out: TextLayout, } @@ -365,28 +369,82 @@ impl Ctx<'_> { let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; (cell + proportional, top) }); - if self.vertical && self.on_path { - // Vertical path type keeps the baseline path and turns each glyph across it. - m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; - if let Some((cell, top)) = path_cell { - // An upright glyph whose top then faces the path's end turns half a turn about the - // center of its cell (without its Character Rotation), so that its top faces the - // path's start and the characters of an upright ligature read along the path in - // order. The effects that keep the glyph's up direction fixed already turn it so on - // a path that runs to the left. - let turned = m * unturned; - let tangent = Vec2::new(angle.cos(), angle.sin()); - let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); - if along(-top) > along(cell - top) { - let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); - m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + // Vertical type on a path: the column is centered on the line Align to Path puts the baseline + // on, so the em boxes of upright glyphs, tate-chu-yoko blocks and glyphs lying on their side + // straddle the path. Glyph space here is horizontal path type's: x runs along the path and y + // across it, to the right of the path's direction. + if let (true, true, Some(t)) = (self.vertical, self.on_path, g.tcy) { + // Tate-chu-yoko stands upright as one block: set across the column, centered on its em of + // it and on the path, its top toward the path's start, then moved by + // its Up/Down toward the path's start and by its Left/Right to the path's left, whatever + // the effect does to the glyph's axes. The block's first glyph sets where the block + // stands; the others take its em, its place on the path and its column, so the block + // stays whole on a curve. + let (block, centre) = match self.tcy_block.filter(|_| t.pen > 0.0) { + Some(b) => b, + None => { + // A later glyph without its first glyph: placed back by its pen offset. + let (em, back) = + if t.pen > 0.0 { (t.pen, Affine::translate((-t.pen, 0.0))) } else { (self.style_at(g.byte).size, Affine::IDENTITY) }; + let centre = Point::new(em * 0.5, 0.0); + let squeeze = + Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); + let on_path = pre * back; + let mut block = on_path * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze; + // The effects that keep the glyph's axes fixed leave the block's top toward the + // path's end on a path that runs to the left: it turns half a turn about its + // center, as an upright glyph does. + let tangent = Vec2::new(angle.cos(), angle.sin()); + if (block * Point::new(centre.x, centre.y - 1.0) - block * centre).dot(tangent) > 0.0 { + block = Affine::translate((on_path * centre).to_vec2() * 2.0) * Affine::scale(-1.0) * block; + } + let left = Vec2::new(tangent.y, -tangent.x); + block = Affine::translate(-tangent * t.up + left * t.right) * block; + self.tcy_block = Some((block, centre)); + (block, centre) } + }; + let em = centre.x * 2.0; + let across = Affine::translate((centre.x - t.width * 0.5 + t.ink, centre.y + g.face.ideographic_centre() * em)); + m = block * across * local; + } else if self.vertical && self.on_path && (g.inline.is_some() || !stands_upright(g)) { + // A glyph lying on its side (and an inline graphic) is a quarter turn clockwise from an + // upright glyph, as horizontal type on the path sets it: its baseline runs along the path + // and its top faces the path's left. It moves across the path so that the center of its + // ideographic em box lies on the path. + // + // + let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy; + m = pre * Affine::translate((0.0, centre)) * local; + } else if self.vertical && self.on_path { + // An upright glyph is a quarter turn counterclockwise from horizontal path type's pose, + // turned in glyph space so that every effect bends it as it bends glyphs lying on their + // side: its top faces the path's start and the characters of an upright ligature read + // along the path in order. The center of its cell (without its Character Rotation) keeps + // its place along the path, and its em box is centered across the path. + let (cell, top) = path_cell.unwrap_or((0.0, 0.0)); + let unrotated = local * unturned; + let middle = Point::new(g.face.units_per_em() * 0.5, cell * 0.5 - top); + // The em box's middle across the column, which the shaper's offsets then move. + let at = Point::new(g.face.units_per_em() * g.sx * 0.5, (unrotated * middle).y); + let target = Point::new(-at.y, 0.0); + let quarter = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + let mut turn = Affine::translate(target.to_vec2() - (quarter * at).to_vec2()) * quarter; + // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the + // path's end on a path that runs to the left: it turns half a turn about its cell's + // center. + let tangent = Vec2::new(angle.cos(), angle.sin()); + let placed = pre * turn * unrotated; + if (placed * Point::new(middle.x, middle.y - 1.0) - placed * middle).dot(tangent) > 0.0 { + turn = Affine::translate(target.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; } + m = pre * turn * local; } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y - // from right to left across it). - let em = self.style_at(g.byte).size; + // from right to left across it). Every glyph of the block takes the em of its first + // glyph, the pen offset of the others. + let em = if t.pen > 0.0 { t.pen } else { self.style_at(g.byte).size }; let start = origin.x - t.pen; let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em); let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0)); @@ -629,6 +687,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1). @@ -692,6 +751,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Simulate the pre-fix path: clone run from the mark glyph only. diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 805c4dead..79a7ebd8d 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -374,6 +374,7 @@ fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&T system_layout: t.system_layout, fractional_widths: t.fractional_widths, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 30f8d76eb..c94d25f89 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -136,6 +136,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0); diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a93d1a9cd..2f9699de6 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -718,7 +718,7 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { /// On a path, an upright ligature in vertical type reads along the path in order: the top of its /// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click /// and the selection put the stop, on a path running either way with any path effect. A glyph lying -/// on its side keeps its top toward the path's end. +/// on its side is a quarter turn clockwise from that, as horizontal type on the path sets it. #[test] fn an_upright_ligature_on_a_path_reads_along_it_in_order() { use vectorcraft_doc::PathEffect; @@ -763,8 +763,8 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let (q0, q1) = (along(q[0][0]), along(q[0][1])); assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); } - // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces - // the path's end and its advance runs across the path, to its right. + // Without Standard Vertical Roman Alignment the letter lies on its side: its top faces the + // path's left and its advance runs along the path. let mut line = BezPath::new(); line.move_to((0.0, 0.0)); line.line_to((1000.0, 0.0)); @@ -774,9 +774,9 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); - let right = Vec2::new(-tangent.y, tangent.x); + let left = Vec2::new(tangent.y, -tangent.x); let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); - assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); + assert!((up - pen).dot(left) > 1.0 && (ahead - pen).dot(tangent) > 1.0, "{pen:?} {up:?} {ahead:?}"); } /// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 225395d34..4df48c51e 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -184,6 +184,220 @@ fn type_on_a_path_stays_horizontal() { assert!(!layout(FontDb::global(), &t).vertical); } +/// `text` in vertical type on a straight path from `from` to `to`, with path effect `effect`. +fn on_vertical_path(text: &str, from: (f64, f64), to: (f64, f64), effect: vectorcraft_doc::PathEffect) -> TextObject { + let mut t = vertical(text); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(from, to).to_path(0.1)), start: 0.0, end: None }; + t.path_effect = effect; + t +} + +/// Glyph `i`'s top (its ascender direction) and its own advance direction in text space, as unit +/// vectors. +fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) { + let g = &l.glyphs[i]; + let pen = g.xf * Point::ZERO; + let unit = |v: Vec2| v / v.hypot(); + (unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen)) +} + +/// On a vertical path, an upright glyph's top faces the path's start, and a letter lying on its side +/// is a quarter turn clockwise from it: its top faces the path's left and its baseline runs along +/// the path. Both turn with the path when it runs the other way. +#[test] +fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-1000.0, 0.0), (700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + let (side, ahead) = top_and_ahead(&l, 1); + assert!((upright + tangent).hypot() < 1e-6, "{to:?}: the upright top faces the start: {upright:?}"); + // A clockwise quarter turn with y down takes (x, y) to (-y, x). + assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}"); + assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}"); + } +} + +/// On a vertical path, letters lying on their side are set as horizontal type on the same path sets +/// them, with every path effect and on a path running either way, and they follow one another along +/// the path without overlapping where the effect keeps them square to it. +#[test] +fn on_a_vertical_path_letters_keep_the_horizontal_path_pose() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (700.0, 300.0)] { + let mut vertical = on_vertical_path("LLl", (0.0, 0.0), to, effect); + let v = layout(FontDb::global(), &vertical); + vertical.vertical = false; + let h = layout(FontDb::global(), &vertical); + for i in 0..3 { + let (a, b) = (v.glyphs[i].xf.as_coeffs(), h.glyphs[i].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{effect:?} {to:?} glyph {i}: {a:?} against {b:?}"); + } + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let span = |i: usize| { + let along = v.glyphs[i].outline.elements().iter().filter_map(|el| el.end_point()).map(|p| p.to_vec2().dot(tangent)); + along.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| (lo.min(d), hi.max(d))) + }; + // Skew slants the letters, Stair Step keeps them level and Gravity fans them toward the + // path's center, so their ink can reach past the next letter's. + if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { + assert!((0..2).all(|i| span(i).1 <= span(i + 1).0 + 1e-6), "{effect:?} {to:?}: {:?}", (0..3).map(span).collect::>()); + } + } + } +} + +/// On a vertical path, the column is centered on the path: the em boxes of upright glyphs, of letters +/// lying on their side and of a tate-chu-yoko block straddle it, their centers on the path line, on +/// a path running any way. +#[test] +fn on_a_vertical_path_the_column_is_centered_on_the_path() { + use vectorcraft_doc::PathEffect; + let Some(face) = FontDb::global().face("Source Sans 3", "Regular") else { panic!("the bundled face") }; + let (em, centre) = (face.units_per_em(), face.ideographic_centre()); + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§H12§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + // An upright glyph (the shaper centers a narrow one in its em box, as off a path) has its + // ink centered on the path. + for i in [0, 4] { + let mid = across(ink(&l, i).center()); + assert!(mid.abs() < 1.0, "{to:?}: upright glyph {i} is {mid} off the path"); + } + // A letter lying on its side: its em box's center is `centre` em above its baseline. + let mid = across(l.glyphs[1].xf * Point::new(0.0, -centre * em)); + assert!(mid.abs() < 1e-6, "{to:?}: the letter's em box is {mid} off the path"); + // The digits of the block stand side by side across the path, the block centered on it. + let block = (across(ink(&l, 2).center()) + across(ink(&l, 3).center())) * 0.5; + assert!(block.abs() < 1.5, "{to:?}: the block is {block} off the path"); + } +} + +/// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and +/// its axis across the path stays upright on the page: an upright glyph's top runs back along the +/// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up +/// the page and its baseline along the tangent. +#[test] +fn on_a_curved_vertical_path_skew_keeps_the_axis_across_the_path_upright() { + use vectorcraft_doc::PathEffect; + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((40.0, 0.0), (80.0, 40.0), (80.0, 120.0)); + let mut t = vertical("§L§L§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + t.path_effect = PathEffect::Skew; + let l = layout(FontDb::global(), &t); + let page_up = Vec2::new(0.0, -1.0); + for (i, g) in l.glyphs.iter().enumerate() { + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let (top, ahead) = top_and_ahead(&l, i); + if i % 2 == 0 { + assert!((top + tangent).hypot() < 1e-6 && (ahead - page_up).hypot() < 1e-6, "upright glyph {i}: {top:?} {ahead:?} at {tangent:?}"); + } else { + assert!((top - page_up).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "letter {i}: {top:?} {ahead:?} at {tangent:?}"); + } + } + let turns = l.glyphs.iter().map(|g| g.angle).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!(turns.1 - turns.0 > 0.3, "the glyphs sit on the curve: {turns:?}"); +} + +/// A tate-chu-yoko block whose characters differ in size stays one block: every character stands on +/// the block's baseline in the em of its first character, in vertical type and on a vertical path. +#[test] +fn a_tate_chu_yoko_block_of_mixed_sizes_shares_its_first_characters_em() { + let run = |text: &str, size: f64| TextRun { text: text.into(), style: CharStyle { size, ..CharStyle::default() }, inline: None }; + let mut t = vertical(""); + t.runs = vec![run("§", 20.0), run("1", 20.0), run("2", 10.0), run("§", 20.0)]; + let mut on_path = t.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 1000.0)).to_path(0.1)), start: 0.0, end: None }; + for (name, t) in [("vertical", &t), ("on a path", &on_path)] { + let l = layout(FontDb::global(), t); + assert!(l.glyphs[2].advance == 0.0, "{name}: 12 is one block: {:?}", l.glyphs.iter().map(|g| g.advance).collect::>()); + let (one, two) = (ink(&l, 1), ink(&l, 2)); + assert!((one.y1 - two.y1).abs() < 0.5 && two.center().x > one.center().x, "{name}: on one baseline: {one:?} {two:?}"); + } +} + +/// On a vertical path, Tate-chu-yoko Settings Up/Down move a marked block toward the path's start +/// and Left/Right move it to the right of its upright characters (the path's left), with every +/// effect on a path running any way. +#[test] +fn on_a_vertical_path_tate_chu_yoko_settings_keep_their_directions() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (0.0, 1000.0), (-700.0, -700.0)] { + let at = |up: f64, right: f64| { + let marked = CharStyle { size: 20.0, tate_chu_yoko: true, tcy_up_down: up, tcy_left_right: right, ..CharStyle::default() }; + let mut t = on_vertical_path("", (0.0, 0.0), to, effect); + t.runs = vec![ + TextRun { text: "§".into(), style: CharStyle { size: 20.0, ..CharStyle::default() }, inline: None }, + TextRun { text: "12".into(), style: marked, inline: None }, + ]; + ink(&layout(FontDb::global(), &t), 1).center().to_vec2() + }; + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (plain, up, right) = (at(0.0, 0.0), at(4.0, 0.0), at(0.0, 3.0)); + assert!((up - plain + tangent * 4.0).hypot() < 1e-6, "{effect:?} {to:?}: Up/Down moved it {:?}", up - plain); + assert!((right - plain - left * 3.0).hypot() < 1e-6, "{effect:?} {to:?}: Left/Right moved it {:?}", right - plain); + } + } +} + +/// On a vertical path, a tate-chu-yoko block stands upright like the glyphs around it: its digits +/// read across the column, their tops toward the path's start, centered on the column. +#[test] +fn on_a_vertical_path_tate_chu_yoko_stands_upright() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, PathEffect::Rainbow)); + assert!(l.glyphs[1].outline.elements().len() > 1, "{to:?}: the 1 draws"); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + for i in [1, 2] { + let (top, ahead) = top_and_ahead(&l, i); + assert!((top - upright).hypot() < 1e-6 && (ahead - left).hypot() < 1e-6, "{to:?} digit {i}: {top:?} {ahead:?}"); + } + let centre = |i: usize| ink(&l, i).center().to_vec2(); + let (one, two) = (centre(1), centre(2)); + assert!((two - one).dot(left) > 3.0 && (two - one).dot(tangent).abs() < 1.0, "{to:?}: 12 side by side: {one:?} {two:?}"); + let across = |v: Vec2| v.dot(left); + let block = (across(one) + across(two)) * 0.5; + assert!((block - across(centre(0))).abs() < 2.0, "{to:?}: the block is centered on the column: {block} against {}", across(centre(0))); + let (before, after) = ((centre(0)).dot(tangent), (centre(3)).dot(tangent)); + assert!(before < one.dot(tangent) && one.dot(tangent) < after, "{to:?}: the block stands between the marks"); + } + // On a curve the block stays whole: both digits turn alike and stand on one baseline. + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((0.0, 40.0), (20.0, 60.0), (60.0, 60.0)); + let mut t = vertical("§12§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + let l = layout(FontDb::global(), &t); + let (a, b) = (l.glyphs[1].xf.as_coeffs(), l.glyphs[2].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{a:?} against {b:?}"); + let (top, ahead) = top_and_ahead(&l, 1); + let (p1, p2) = (l.glyphs[1].xf * Point::ZERO, l.glyphs[2].xf * Point::ZERO); + assert!((p2 - p1).dot(top).abs() < 1e-6 && (p2 - p1).dot(ahead) > 3.0, "{p1:?} {p2:?}"); + // With every effect, on a path running either way, the block's top faces the path's start. + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, effect)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + for i in [1, 2] { + let (top, _) = top_and_ahead(&l, i); + assert!(top.dot(tangent) < -0.5, "{effect:?} {to:?} digit {i}: {top:?}"); + } + } + } +} + #[test] fn three_digits_stand_across_the_column_squeezed_into_one_em() { let l = layout(FontDb::global(), &vertical("§100§")); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c15e19cda..562574cde 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,7 +1165,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them in the same column, which is centered on the path); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. From 5a8a57d5f69964bbfd64ce2a9d946f6b9c78fb12 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:27:53 -0800 Subject: [PATCH 2/2] Type: upright glyphs on a vertical path keep vertical point type's cells, and the caret spans the column On a vertical path, an upright glyph is now placed in its column as vertical point type places it, then the column is centered on the path. - Its cell starts at its place on the path; it started 0.12 em earlier with fonts whose cell top is less than a full cell above the baseline. - A baseline shift moves it across the column instead of along the path. - A horizontal scale widens it evenly about the column's center. - A combining mark stays on its upright letter. - The caret and the selection highlight span the column, half its em on each side of the path, instead of the font's ascent and descent. With Skew, 3D Ribbon and Stair Step on a path that runs to the left, letters are a quarter turn counterclockwise from upright glyphs: Skew and 3D Ribbon mirror both kinds there, and Stair Step keeps letters level. The test, the progress doc and the params text of Tate-chu-yoko Settings now state these conditions and the block's directions on a path. --- crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/textedit.rs | 2 +- crates/text/src/layout.rs | 65 +++++++++++------ crates/text/src/layout/on_path.rs | 9 +++ crates/text/src/tests/ligature_carets.rs | 18 ++--- crates/text/src/tests/vertical.rs | 90 +++++++++++++++++++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 4 +- 8 files changed, 155 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 04b1ce685..d7788df10 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c315c2a1c..689a73ea0 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4a92c4ed3..15b71ae23 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -93,6 +93,34 @@ fn takes_cell(g: &SGlyph) -> bool { g.adv > 0.0 || g.face.advance(g.gid) > 0.0 } +/// Vertical type on a path: the turn that takes upright glyph `g` (of size `em`, its glyph space +/// mapped onto the path by `pre`) from its local transform `local` (`unturned` takes its Character +/// Rotation off) to the path, and where the next glyph of its cluster goes in its glyph space. +/// +/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn +/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift +/// kept across the column and a leading space taken off moving it along the path, then moved across +/// the path as a glyph lying on its side is. +fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { + let cell = if takes_cell(g) { upright_cell(g) } else { advance }; + let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); + let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); + let mut turn = + Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on + // a path that runs to the left: it turns half a turn about its cell's center. + let tangent = Vec2::new(angle.cos(), angle.sin()); + let placed = pre * turn * local * unturned; + if (placed * Point::new(0.0, -1.0) - placed * Point::ZERO).dot(tangent) > 0.0 { + let centre = turn * shift * about; + turn = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; + } + // Its own advance across, from where the cell centers it: where the shaper set the next glyph of + // its cluster. + let across = g.face.advance(g.gid) * g.sx; + (pre * turn, (cell - across) * 0.5 + across) +} + /// Clear the caret stops of each glyph that shares its cluster with another glyph: only a glyph /// that alone stands for its characters is a ligature. fn keep_lone_ligature_carets(glyphs: &mut [PositionedGlyph]) { @@ -333,7 +361,7 @@ impl Ctx<'_> { }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. - let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); + let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); let lead = if upright { 0.0 } else { g.lead }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by @@ -417,28 +445,21 @@ impl Ctx<'_> { let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy; m = pre * Affine::translate((0.0, centre)) * local; } else if self.vertical && self.on_path { - // An upright glyph is a quarter turn counterclockwise from horizontal path type's pose, - // turned in glyph space so that every effect bends it as it bends glyphs lying on their - // side: its top faces the path's start and the characters of an upright ligature read - // along the path in order. The center of its cell (without its Character Rotation) keeps - // its place along the path, and its em box is centered across the path. - let (cell, top) = path_cell.unwrap_or((0.0, 0.0)); - let unrotated = local * unturned; - let middle = Point::new(g.face.units_per_em() * 0.5, cell * 0.5 - top); - // The em box's middle across the column, which the shaper's offsets then move. - let at = Point::new(g.face.units_per_em() * g.sx * 0.5, (unrotated * middle).y); - let target = Point::new(-at.y, 0.0); - let quarter = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); - let mut turn = Affine::translate(target.to_vec2() - (quarter * at).to_vec2()) * quarter; - // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the - // path's end on a path that runs to the left: it turns half a turn about its cell's - // center. - let tangent = Vec2::new(angle.cos(), angle.sin()); - let placed = pre * turn * unrotated; - if (placed * Point::new(middle.x, middle.y - 1.0) - placed * middle).dot(tangent) > 0.0 { - turn = Affine::translate(target.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; + // An upright glyph is turned in glyph space ([`upright_path_turn`]), so every effect bends it + // as it bends glyphs lying on their side: its top faces the path's start and the characters + // of an upright ligature read along the path in order. + let next = self.out.glyphs.len(); + let after_base = self.out.glyphs.last().is_some_and(|p| p.byte == g.byte && p.line == line && p.len > 0); + if let Some((_, _, turn, after)) = cluster.filter(|c| c.0 == g.byte && c.1 == next && after_base && g.len > 0) { + // A later glyph of an upright glyph's cluster: set after it as the shaper placed it, + // and turned with it, in its glyph space. + m = turn * Affine::translate((after, 0.0)) * local; + self.upright_cluster = Some((g.byte, next + 1, turn, after)); + } else { + let (turn, after) = upright_path_turn(g, self.style_at(g.byte).size, pre, local, unturned, angle, advance); + m = turn * local; + self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, after)); } - m = pre * turn * local; } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index aafe17531..398928ee9 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -174,6 +174,15 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); + // Vertical type: the caret and the selection span the column, half its em on each side of the + // line Align to Path puts the baseline on. + let m = if cx.vertical { + let half = sg.iter().map(|g| g.face.units_per_em() * g.sx * 0.5).fold(f64::NAN, f64::max); + let half = if half.is_finite() { half } else { cx.style_at(0).size * 0.5 }; + (half, half) + } else { + m + }; if ap.is_empty() { cx.out.overflow = !sg.is_empty(); cx.out.lines.push(LineInfo { diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index 2f9699de6..a1e06887a 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -655,7 +655,7 @@ fn vertical_only_settings_keep_a_horizontal_ligature_whole() { } /// On a path, an upright ligature in vertical type stands with its top toward the path's start, and -/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// its cell runs one em from the pen, as in vertical point type, with or without the font's vertical /// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends /// there. #[test] @@ -673,21 +673,21 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { assert!(l.on_path); let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; assert_eq!(g.carets, vec![(1, 0.5)], "{family}"); - assert!((g.width - 100.0).abs() < 1e-9 && (g.width_start + 12.0).abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); + assert!((g.width - 100.0).abs() < 1e-9 && g.width_start.abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); let x = |b| caret_position(&l, b).0.x - g.origin.x; - assert!((x(1) - 38.0).abs() < 1e-6, "{family}: {}", x(1)); + assert!((x(1) - 50.0).abs() < 1e-6, "{family}: {}", x(1)); let q = selection_quads(&l, 0, 1); assert_eq!(q.len(), 1, "{q:?}"); - assert!(((q[0][1] - q[0][0]).hypot() - 38.0).abs() < 1e-6, "{family}: {q:?}"); - // A baseline shift moves the turned glyph along the path, and its stop with it. - let ink = g.outline.bounding_box().x0; + assert!(((q[0][1] - q[0][0]).hypot() - 50.0).abs() < 1e-6, "{family}: {q:?}"); + // A baseline shift moves the turned glyph across the path, and leaves its stop in place. + let ink = g.outline.bounding_box(); t.runs[0].style.baseline_shift = 10.0; let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; - let moved = s.outline.bounding_box().x0 - ink; - assert!(moved.abs() > 1.0, "{family}: the shift moves the glyph along the path: {moved}"); + let moved = s.outline.bounding_box().center() - ink.center(); + assert!((moved.x.abs()) < 1e-6 && (moved.y + 10.0).abs() < 1e-6, "{family}: the shift moves the glyph to the path's left: {moved:?}"); let stop = caret_position(&l, 1).0.x - s.origin.x; - assert!((stop - 38.0 - moved).abs() < 1e-6, "{family}: {stop} against {}", 38.0 + moved); + assert!((stop - 50.0).abs() < 1e-6, "{family}: {stop}"); // Horizontal Scale widens the glyph across the path; its stop stays inside the glyph. t.runs[0].style.baseline_shift = 0.0; t.runs[0].style.h_scale = 200.0; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 4df48c51e..12069e007 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -201,9 +201,10 @@ fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) { (unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen)) } -/// On a vertical path, an upright glyph's top faces the path's start, and a letter lying on its side -/// is a quarter turn clockwise from it: its top faces the path's left and its baseline runs along -/// the path. Both turn with the path when it runs the other way. +/// On a vertical path with Rainbow, an upright glyph's top faces the path's start, and a letter lying +/// on its side is a quarter turn clockwise from it: its top faces the path's left and its baseline +/// runs along the path. Both turn with the path when it runs the other way. The effects that keep +/// the glyphs' axes fixed on the page keep that relation only on a path that runs to the right. #[test] fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() { use vectorcraft_doc::PathEffect; @@ -218,6 +219,24 @@ fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}"); assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}"); } + // Skew, 3D Ribbon and Stair Step keep axes of the glyphs fixed on the page: on a path that runs + // to the right the relation holds. On one that runs to the left, Skew and 3D Ribbon mirror both + // kinds, and Stair Step keeps letters level while upright glyphs still face the path's start, so + // letters are a quarter turn counterclockwise from upright glyphs there. + for effect in [PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep] { + for to in [(1000.0, 0.0), (700.0, 700.0), (0.0, 1000.0), (0.0, -1000.0), (-1000.0, 0.0), (-700.0, 300.0)] { + // Skew and 3D Ribbon slant glyphs on a slanted path, where no turn relates them exactly. + if to.1 != 0.0 && effect != PathEffect::StairStep { + continue; + } + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, effect)); + let (upright, _) = top_and_ahead(&l, 0); + let (side, _) = top_and_ahead(&l, 1); + let clockwise = to.0 >= 0.0; + let want = if clockwise { Vec2::new(-upright.y, upright.x) } else { Vec2::new(upright.y, -upright.x) }; + assert!((side - want).hypot() < 1e-6, "{effect:?} {to:?}: {side:?} against {upright:?}"); + } + } } /// On a vertical path, letters lying on their side are set as horizontal type on the same path sets @@ -277,6 +296,71 @@ fn on_a_vertical_path_the_column_is_centered_on_the_path() { } } +/// On a vertical path, the caret and the selection span the column, half its em on each side of the +/// path, as the glyphs do. +#[test] +fn on_a_vertical_path_the_caret_and_selection_span_the_column() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + let (top, bottom) = caret_position(&l, "§".len()); + assert!((across(top) - 10.0).abs() < 1e-6 && (across(bottom) + 10.0).abs() < 1e-6, "{to:?}: caret {top:?} {bottom:?}"); + for q in selection_quads(&l, 0, "§L§".len()) { + let (lo, hi) = q.iter().map(|p| across(*p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!((lo + 10.0).abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{to:?}: highlight from {lo} to {hi}"); + } + } +} + +/// On a straight path running down from the anchor, vertical type sets every glyph where vertical +/// point type sets it: upright glyphs with a horizontal scale stay centered on the column, a baseline +/// shift moves an upright glyph across the column, every upright glyph keeps its own cell along the +/// path, and a combining mark stays on its upright letter. +#[test] +fn on_a_downward_vertical_path_glyphs_sit_where_vertical_point_type_sets_them() { + use crate::test_fonts::{VERTICAL_FAMILY, vertical_font}; + let run = |text: &str, style: CharStyle| TextRun { text: text.into(), style, inline: None }; + let runs = |plain: CharStyle| { + vec![ + run("§×", plain.clone()), + run("§", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("§", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("§", CharStyle { rotation: 30.0, ..plain.clone() }), + run("L", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("L", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("q\u{301}x", CharStyle { standard_vertical_roman: true, ..plain.clone() }), + run("§", plain), + ] + }; + let compare = |db: &FontDb, point: &TextObject, name: &str| { + let mut on_path = point.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let (p, v) = (layout(db, point), layout(db, &on_path)); + assert_eq!(p.glyphs.len(), v.glyphs.len(), "{name}"); + let apart: Vec<_> = (p.glyphs.iter().zip(&v.glyphs).enumerate()) + .filter(|(_, (a, b))| a.xf.as_coeffs().iter().zip(b.xf.as_coeffs()).any(|(a, b)| (a - b).abs() > 1e-6)) + .map(|(i, (a, b))| format!("glyph {i}: {:?} on the path, {:?} in point type", b.xf.as_coeffs(), a.xf.as_coeffs())) + .collect(); + assert!(apart.is_empty(), "{name}: {apart:#?}"); + }; + let mut point = vertical(""); + point.runs = runs(CharStyle { size: 20.0, ..CharStyle::default() }); + compare(FontDb::global(), &point, "the bundled face"); + // A face with vertical metrics, whose cells hang from their vertical origins. + let db = FontDb::with_font_dirs(vec![]); + db.add_font(vertical_font(true).unwrap()); + point.runs = runs(CharStyle { size: 20.0, font_family: VERTICAL_FAMILY.into(), ..CharStyle::default() }); + compare(&db, &point, "vertical metrics"); + // The horizontally scaled upright glyph is wider across the column, centered where the plain one is. + point.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let l = layout(&db, &point); + let (plain, wide) = (ink(&l, 0), ink(&l, 2)); + assert!(wide.width() > plain.width() * 1.4 && (wide.center().x - plain.center().x).abs() < 1e-6, "{plain:?} {wide:?}"); +} + /// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and /// its axis across the path stays upright on the page: an upright glyph's top runs back along the /// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up diff --git a/docs/mcp.md b/docs/mcp.md index 14a5bfb6c..d0b41e8ca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6374,8 +6374,9 @@ with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. `text.setFormat` and `text.setRangeStyle {tcyUpDown?, tcyLeftRight?}` set the Character panel menu's Tate-chu-yoko Settings of the characters, in points from -1296 to 1296 (document points, as `baselineShift`). In vertical type a block of tate-chu-yoko characters (`tateChuYoko`) moves up by a positive Up/Down and down by a negative one, and right -by a positive Left/Right and left by a negative one. The block moves as one piece with the values of its first -character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set +by a positive Left/Right and left by a negative one. On a vertical path, a positive Up/Down moves the block toward the +path's start and a positive Left/Right moves it to the path's left, with every path effect. The block moves as one piece +with the values of its first character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set across the column without the mark do not move, and horizontal type keeps the values without moving the characters. Neither value turns `tateChuYoko` on. Both are 0 by default and saved only when not 0, and character styles carry them. A value that is not a number is refused with `` `tcyUpDown` must be a number ``, and one outside the range with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 562574cde..d0db4c342 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,10 +1165,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them in the same column, which is centered on the path); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. + Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).