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.