mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 16:39:55 +00:00
In a frame that is not a rectangle, or with text wrap, layout moves a line down to the first span wide enough for its first word. The word was measured at full width before Mojikumi set an opening bracket at the line's start flush, and with its last character at full width, so a word that fit as set moved down to a wider line. It also ended at any break opportunity, so an opening bracket counted without the character kinsoku keeps with it and could be placed alone on a line too narrow for that character. - word_width ends the word only where break_line may end a line: at a cluster end that kinsoku allows, not inside a No Break run, whose spaces count. It leaves out the spaces at the word's end and narrows its last glyph by the Mojikumi line-end credit. - place_flow_line measures the word after the opening-bracket trim, and the Every-line Composer's first-line placement takes off the same start credit, so the composed widths match the placed lines.
1520 lines
91 KiB
Rust
1520 lines
91 KiB
Rust
//! Vertical type (bundled fonts: `§` and `×` stand upright like CJK, letters lie on their side;
|
||
//! Japanese text only with craft-fonts' faces, skipped without them).
|
||
use crate::*;
|
||
use kurbo::Shape;
|
||
use vectorcraft_doc::{AreaFit, CharStyle, Justify, TextKind, TextRun};
|
||
use vectorcraft_geom::PathData;
|
||
|
||
fn vertical(text: &str) -> TextObject {
|
||
vertical_styled(text, CharStyle { size: 20.0, ..CharStyle::default() })
|
||
}
|
||
|
||
fn vertical_styled(text: &str, style: CharStyle) -> TextObject {
|
||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = true;
|
||
t
|
||
}
|
||
|
||
fn ink(l: &TextLayout, i: usize) -> Rect {
|
||
l.glyphs[i].outline.bounding_box()
|
||
}
|
||
|
||
#[test]
|
||
fn columns_run_down_and_follow_each_other_to_the_left() {
|
||
let l = layout(FontDb::global(), &vertical("§§\n§"));
|
||
assert!(l.vertical);
|
||
let (a, b, c) = (ink(&l, 0), ink(&l, 1), ink(&l, 2));
|
||
assert!(b.center().y > a.center().y + 15.0, "down the column: {a:?} {b:?}");
|
||
assert!((a.center().x - b.center().x).abs() < 0.5, "one column");
|
||
assert!(c.center().x < a.center().x - 15.0, "the next column is to the left: {c:?}");
|
||
// Point type: the anchor is on the first column's centre line.
|
||
assert!(a.center().x.abs() < 4.0, "{a:?}");
|
||
assert!(l.bounds.contains(a.center()) && l.bounds.contains(c.center()));
|
||
}
|
||
|
||
#[test]
|
||
fn upright_marks_stand_and_letters_lie_on_their_side() {
|
||
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "§l", CharStyle { size: 20.0, ..CharStyle::default() }));
|
||
let v = layout(FontDb::global(), &vertical("§l"));
|
||
let (hs, hl, vs, vl) = (ink(&h, 0), ink(&h, 1), ink(&v, 0), ink(&v, 1));
|
||
assert!((vs.width() - hs.width()).abs() < 0.01 && (vs.height() - hs.height()).abs() < 0.01, "§ upright: {hs:?} {vs:?}");
|
||
assert!((vl.width() - hl.height()).abs() < 0.01 && (vl.height() - hl.width()).abs() < 0.01, "l turned: {hl:?} {vl:?}");
|
||
assert!(vl.width() > vl.height(), "a tall l lies across the column");
|
||
}
|
||
|
||
/// Unicode Vertical_Orientation Tu: without a vertical alternate, fullwidth !/? stay upright.
|
||
/// Tr controls (a bracket and the long vowel mark) still rotate when they have no alternate.
|
||
#[test]
|
||
fn fullwidth_punctuation_stays_upright_without_vertical_alternates() {
|
||
check_fullwidth_punctuation(false);
|
||
}
|
||
|
||
#[test]
|
||
fn fullwidth_punctuation_keeps_vertical_alternates_upright() {
|
||
check_fullwidth_punctuation(true);
|
||
}
|
||
|
||
fn check_fullwidth_punctuation(alternates: bool) {
|
||
use crate::test_fonts::{VERTICAL_PUNCTUATION_FAMILY, vertical_punctuation_font};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(vertical_punctuation_font(alternates).unwrap());
|
||
let style = CharStyle { font_family: VERTICAL_PUNCTUATION_FAMILY.into(), size: 20.0, ..CharStyle::default() };
|
||
let face = db.face(VERTICAL_PUNCTUATION_FAMILY, "Regular").unwrap();
|
||
for (ch, nominal, alternate) in [('!', '!', 'I'), ('?', '?', 'J'), ('「', 'L', 'L'), ('ー', '-', '-')] {
|
||
let upright = matches!(ch, '!' | '?');
|
||
let expected = if alternates { alternate } else { nominal };
|
||
let h = layout(&db, &TextObject::point(Point::ZERO, &expected.to_string(), style.clone()));
|
||
for area in [false, true] {
|
||
let mut t = vertical_styled(&ch.to_string(), style.clone());
|
||
if area {
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)) };
|
||
}
|
||
let v = layout(&db, &t);
|
||
assert_eq!(v.glyphs.len(), 1, "{ch} area {area}");
|
||
assert!(!v.overflow);
|
||
let g = &v.glyphs[0];
|
||
assert_eq!(g.font_id, face.id());
|
||
assert_eq!(g.gid, face.glyph_for(expected), "{ch}: the expected nominal or vert glyph");
|
||
let reference = if upright { h.glyphs[0].xf } else { Affine::rotate(std::f64::consts::FRAC_PI_2) * h.glyphs[0].xf };
|
||
for (actual, expected) in g.xf.as_coeffs().iter().zip(reference.as_coeffs().iter()).take(4) {
|
||
assert!((actual - expected).abs() < 1e-9, "{ch} area {area}, alternates {alternates}: {:?} vs {reference:?}", g.xf);
|
||
}
|
||
}
|
||
// Vertical substitutions must not change horizontal type.
|
||
let h = layout(&db, &TextObject::point(Point::ZERO, &ch.to_string(), style.clone()));
|
||
assert_eq!(h.glyphs[0].gid, face.glyph_for(nominal));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn caret_and_selection_turn_with_the_columns() {
|
||
let l = layout(FontDb::global(), &vertical("§§§"));
|
||
let (top, bottom) = caret_position(&l, "§".len());
|
||
assert!((top.y - bottom.y).abs() < 1e-6 && (top.x - bottom.x).abs() > 10.0, "a horizontal caret across the column: {top:?} {bottom:?}");
|
||
let between = (ink(&l, 0).center().y + ink(&l, 1).center().y) / 2.0;
|
||
assert!((top.y - between).abs() < 3.0, "between the first two marks: {top:?} {between} {:?} {:?}", ink(&l, 0), ink(&l, 1));
|
||
let q = selection_quads(&l, 0, "§§".len());
|
||
let r = Rect::from_points(q[0][0], q[0][2]);
|
||
assert!(r.height() > r.width() && r.contains(ink(&l, 0).center()) && r.contains(ink(&l, 1).center()) && !r.contains(ink(&l, 2).center()));
|
||
// Clicking a mark finds it.
|
||
let c = ink(&l, 2).center();
|
||
assert_eq!(hit_byte(&l, c + Vec2::new(0.0, 6.0)), "§§§".len());
|
||
assert_eq!(hit_byte(&l, c - Vec2::new(0.0, 6.0)), "§§".len());
|
||
}
|
||
|
||
#[test]
|
||
fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
|
||
let mut t = vertical(&"§".repeat(12));
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 105.0).to_path(0.1)) };
|
||
let l = layout(FontDb::global(), &t);
|
||
let first = ink(&l, 0);
|
||
assert!(first.x1 <= 100.0 + 1e-6 && first.x1 > 80.0 && first.y0 >= 0.0, "top right: {first:?}");
|
||
let cols: Vec<f64> = l.glyphs.iter().map(|g| (g.outline.bounding_box().center().x * 10.0).round()).collect();
|
||
let mut distinct = cols.clone();
|
||
distinct.dedup();
|
||
assert!(distinct.len() >= 2 && distinct.windows(2).all(|w| w[1] < w[0]), "columns right to left: {distinct:?}");
|
||
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
|
||
}
|
||
|
||
/// A vertical frame too narrow for its first column is made wider from its right edge, where that
|
||
/// column is; its height stays.
|
||
#[test]
|
||
fn fit_first_line_widens_a_vertical_frame_from_its_right_edge() {
|
||
let mut t = vertical("§§");
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(90.0, 0.0, 100.0, 105.0).to_path(0.1)) };
|
||
assert!(layout(FontDb::global(), &t).lines.is_empty(), "a column doesn't fit");
|
||
assert!(fit_first_line(FontDb::global(), &mut t));
|
||
let TextKind::Area { frame } = &t.kind else { panic!("area") };
|
||
let b = frame.bounds().unwrap();
|
||
assert!((b.x1 - 100.0).abs() < 1e-6 && b.x0 < 90.0 && b.x0 > 60.0 && (b.y0, b.y1) == (0.0, 105.0), "{b:?}");
|
||
let l = layout(FontDb::global(), &t);
|
||
assert_eq!(l.lines.len(), 1);
|
||
assert!(ink(&l, 0).x1 <= 100.0 + 1e-6, "the column at the right edge: {:?}", ink(&l, 0));
|
||
// A fixed first baseline further in than the line's ascent: wide enough for it too.
|
||
let mut t = vertical("§§");
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(90.0, 0.0, 100.0, 105.0).to_path(0.1)) };
|
||
t.area.first_baseline = crate::FirstBaseline::Fixed;
|
||
t.area.first_baseline_min = 40.0;
|
||
assert!(fit_first_line(FontDb::global(), &mut t));
|
||
let TextKind::Area { frame } = &t.kind else { panic!("area") };
|
||
assert!(frame.bounds().unwrap().x0 < 60.0, "{:?}", frame.bounds());
|
||
assert_eq!(layout(FontDb::global(), &t).lines.len(), 1);
|
||
}
|
||
|
||
/// `baselines`: one per line that holds characters, along it for horizontal type and down the
|
||
/// column's centre line for vertical type (where point type's anchor is); none on a path.
|
||
#[test]
|
||
fn baselines_run_along_lines_and_down_column_centres() {
|
||
let h = layout(
|
||
FontDb::global(),
|
||
&TextObject::point(
|
||
Point::ZERO,
|
||
"§§
|
||
|
||
§",
|
||
CharStyle { size: 20.0, ..CharStyle::default() },
|
||
),
|
||
);
|
||
let hb = h.baselines();
|
||
assert_eq!(hb.len(), 2, "the empty line has none: {hb:?}");
|
||
assert!(hb[0].0 == Point::ZERO && hb[0].1.y == 0.0 && hb[0].1.x > 15.0, "the first baseline runs from the anchor: {hb:?}");
|
||
assert!(hb[1].0.y > 40.0 && hb[1].0.y == hb[1].1.y && hb[1].1.x > hb[1].0.x, "the third line's, lower: {hb:?}");
|
||
let v = layout(
|
||
FontDb::global(),
|
||
&vertical(
|
||
"§§
|
||
§",
|
||
),
|
||
);
|
||
let vb = v.baselines();
|
||
let (a, c) = (ink(&v, 0), ink(&v, 2));
|
||
assert_eq!(vb.len(), 2, "{vb:?}");
|
||
assert!(vb[0].0.x.abs() < 4.0 && vb[0].0.x == vb[0].1.x && vb[0].1.y > vb[0].0.y + 30.0, "down the first column's centre: {vb:?}");
|
||
assert!((vb[0].0.x - a.center().x).abs() < 4.0 && (vb[1].0.x - c.center().x).abs() < 4.0, "through the glyphs: {vb:?} {a:?} {c:?}");
|
||
let mut on_path = vertical("ab");
|
||
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
|
||
assert!(layout(FontDb::global(), &on_path).baselines().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn type_on_a_path_stays_horizontal() {
|
||
let mut t = vertical("ab");
|
||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
|
||
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 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;
|
||
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:?}");
|
||
}
|
||
// 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
|
||
/// 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::<Vec<_>>());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// 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::<Vec<_>>());
|
||
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§"));
|
||
let block: Vec<Rect> = (1..4).map(|i| ink(&l, i)).collect();
|
||
assert!(block.windows(2).all(|w| w[1].center().x > w[0].center().x + 2.0 && (w[1].center().y - w[0].center().y).abs() < 1.0), "{block:?}");
|
||
let span = block[0].union(block[2]);
|
||
assert!(span.width() <= 20.0 + 1e-6, "squeezed into the 20 pt em: {span:?}");
|
||
assert!(ink(&l, 4).center().y - ink(&l, 0).center().y < 45.0, "one em of the column");
|
||
}
|
||
|
||
#[test]
|
||
fn two_digits_stand_across_the_column_and_longer_numbers_lie_on_their_side() {
|
||
let l = layout(FontDb::global(), &vertical("§12§2026"));
|
||
let (one, two) = (ink(&l, 1), ink(&l, 2));
|
||
assert!((one.center().y - two.center().y).abs() < 1.0 && two.center().x > one.center().x + 3.0, "12 side by side: {one:?} {two:?}");
|
||
assert!(one.height() > one.width(), "upright digits are taller than wide: {one:?}");
|
||
let (before, after) = (ink(&l, 0), ink(&l, 3));
|
||
assert!(after.center().y - before.center().y < 45.0, "the block takes one em (20 pt) of the column");
|
||
let year: Vec<Rect> = (4..8).map(|i| ink(&l, i)).collect();
|
||
assert!(year.windows(2).all(|w| w[1].center().y > w[0].center().y + 5.0), "2026 runs down the column on its side");
|
||
}
|
||
|
||
/// Each line's text, from the layout's line ranges.
|
||
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
|
||
let text = t.plain_text();
|
||
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or("").trim_end().to_string()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_ends() {
|
||
for vertical_type in [false, true] {
|
||
// Five characters fit a line: without kinsoku "。" would start the second line.
|
||
let text = "あいうえお。かきくけ「こさしすせそ」";
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 101.0, 400.0).to_path(0.1)) };
|
||
if vertical_type {
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 101.0).to_path(0.1)) };
|
||
t.vertical = true;
|
||
}
|
||
let l = layout(FontDb::global(), &t);
|
||
let lines = line_texts(&t, &l);
|
||
assert!(lines.len() > 2, "{lines:?}");
|
||
for (i, line) in lines.iter().enumerate() {
|
||
if i > 0 {
|
||
let first = line.chars().next().unwrap_or(' ');
|
||
assert!(!crate::shape::no_line_start(first), "line {i} starts with {first}: {lines:?}");
|
||
}
|
||
let last = line.chars().last().unwrap_or(' ');
|
||
assert!(!crate::shape::no_line_end(last), "line {i} ends with {last}: {lines:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Each glyph's cell down the (single) column: from its origin over its advance. A glyph that
|
||
/// doesn't advance (the rest of a tate-chu-yoko block) shares the cell of the glyph before it.
|
||
fn column_cells(l: &TextLayout) -> Vec<(f64, f64)> {
|
||
let mut cells: Vec<(f64, f64)> = Vec::with_capacity(l.glyphs.len());
|
||
for g in &l.glyphs {
|
||
let cell = match cells.last() {
|
||
Some(&prev) if g.advance == 0.0 => prev,
|
||
_ => (g.origin.y, g.origin.y + g.advance),
|
||
};
|
||
cells.push(cell);
|
||
}
|
||
cells
|
||
}
|
||
|
||
/// One column, cells one after the other, each glyph's ink inside its own cell (within `tol`)
|
||
/// and no two glyphs' ink overlapping.
|
||
fn assert_own_cells(l: &TextLayout, text: &str, tol: f64) {
|
||
let label = |i: usize| &text[l.glyphs[i].byte..l.glyphs[i].byte + l.glyphs[i].len];
|
||
let cells = column_cells(l);
|
||
for (i, g) in l.glyphs.iter().enumerate() {
|
||
assert!((g.origin.x - l.glyphs[0].origin.x).abs() < 1e-9, "{:?} leaves the column: {g:?}", label(i));
|
||
if let Some(next) = l.glyphs.get(i + 1) {
|
||
assert!((next.origin.y - (g.origin.y + g.advance)).abs() < 1e-9, "{:?} starts where {:?} ends", label(i + 1), label(i));
|
||
}
|
||
if g.outline.elements().is_empty() {
|
||
continue;
|
||
}
|
||
let (r, (top, bottom)) = (ink(l, i), cells[i]);
|
||
assert!(r.y0 >= top - tol && r.y1 <= bottom + tol, "{:?}: ink {r:?} outside its cell {top}..{bottom}", label(i));
|
||
for j in (0..i).filter(|&j| !l.glyphs[j].outline.elements().is_empty()) {
|
||
let o = ink(l, j);
|
||
let (ox, oy) = (r.x1.min(o.x1) - r.x0.max(o.x0), r.y1.min(o.y1) - r.y0.max(o.y0));
|
||
assert!(ox <= tol || oy <= tol, "{:?} {r:?} overlaps {:?} {o:?}", label(i), label(j));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Index of the glyph of `needle`'s first byte in `text`.
|
||
fn glyph_of(l: &TextLayout, text: &str, needle: &str) -> usize {
|
||
let b = text.find(needle).unwrap();
|
||
l.glyphs.iter().position(|g| g.byte == b).unwrap()
|
||
}
|
||
|
||
/// Issue #260 with bundled glyphs (`§` stands in for the upright CJK characters).
|
||
#[test]
|
||
fn numbers_keep_their_own_cells_down_the_column() {
|
||
let text = "§§ §§§ 10§22§§ 1§ 100§ 2026§";
|
||
let l = layout(FontDb::global(), &vertical(text));
|
||
assert_own_cells(&l, text, 0.25);
|
||
// Tate-chu-yoko: one em of the column, centred on the column's centre line (x = 0), the next
|
||
// character right after it.
|
||
for block in ["10§", "22§", "100§"] {
|
||
let first = glyph_of(&l, text, block);
|
||
let digits = block.len() - '§'.len_utf8();
|
||
let after = first + digits;
|
||
let g = &l.glyphs[first];
|
||
assert!((g.advance - 20.0).abs() < 1e-9, "{block}: {g:?}");
|
||
assert!(l.glyphs[first + 1..after].iter().all(|d| d.advance == 0.0), "{block}");
|
||
assert!((l.glyphs[after].origin.y - (g.origin.y + 20.0)).abs() < 1e-9, "{block}: the next character follows the block's em");
|
||
let inks = (first..after).map(|i| ink(&l, i)).reduce(|a, b| a.union(b)).unwrap();
|
||
assert!(inks.center().x.abs() < 1.0, "{block} centred on the column: {inks:?}");
|
||
assert!((inks.center().y - (g.origin.y + 10.0)).abs() < 1.5, "{block} centred in its em: {inks:?} {g:?}");
|
||
}
|
||
// A single digit and longer numbers lie on their side, advancing by their own length.
|
||
let one = glyph_of(&l, text, "1§");
|
||
let r = ink(&l, one);
|
||
assert!(r.width() > r.height() && l.glyphs[one].advance < 20.0, "1 on its side: {r:?}");
|
||
let year = glyph_of(&l, text, "2026");
|
||
let len: f64 = l.glyphs[year..year + 4].iter().map(|g| g.advance).sum();
|
||
assert!(len > 30.0 && (l.glyphs[year + 4].origin.y - l.glyphs[year].origin.y - len).abs() < 1e-9, "2026 takes its length: {len}");
|
||
// Half-width spaces take their own (narrow) width.
|
||
for (i, g) in l.glyphs.iter().enumerate().filter(|(_, g)| text[g.byte..].starts_with(' ')) {
|
||
assert!(g.advance > 2.0 && g.advance < 10.0, "space {i}: {g:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn justified_columns_keep_tate_chu_yoko_blocks_whole_and_marks_centred() {
|
||
let text = "§10§§";
|
||
let mut t = vertical(text);
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 40.0, 200.0).to_path(0.1)) };
|
||
t.para.justify = Justify::JustifyAll;
|
||
let l = layout(FontDb::global(), &t);
|
||
assert_eq!(l.lines.len(), 1);
|
||
// 200 pt of column, four 20 pt cells: three gaps of 40 pt between them, none inside "10".
|
||
let (one, zero) = (ink(&l, 1), ink(&l, 2));
|
||
assert!((one.center().y - zero.center().y).abs() < 0.5 && zero.x0 > one.x1, "10 side by side: {one:?} {zero:?}");
|
||
let marks: Vec<Rect> = [0, 3, 4].iter().map(|&i| ink(&l, i)).collect();
|
||
assert!((marks[1].center().y - marks[0].center().y - 120.0).abs() < 1e-6, "{marks:?}");
|
||
assert!((marks[2].center().y - marks[1].center().y - 60.0).abs() < 1e-6, "{marks:?}");
|
||
assert!(marks.iter().all(|m| (m.center().x - marks[0].center().x).abs() < 1e-6), "upright marks stay on the centre line: {marks:?}");
|
||
assert!(marks[2].y1 <= 200.0 + 1e-6, "inside the frame: {marks:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() {
|
||
let plain = layout(FontDb::global(), &vertical("§§"));
|
||
let tracked = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, tracking: 200.0, ..CharStyle::default() }));
|
||
for i in 0..2 {
|
||
assert!((ink(&tracked, i).center().x - ink(&plain, i).center().x).abs() < 1e-6, "{:?} {:?}", ink(&tracked, i), ink(&plain, i));
|
||
}
|
||
let step = ink(&tracked, 1).center().y - ink(&tracked, 0).center().y;
|
||
assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}");
|
||
}
|
||
|
||
/// Vertical type keeps its tracking in thousandths of the em itself at any Horizontal Scale
|
||
/// (`spacing_scale`): its advances down the column don't all take the scale.
|
||
#[test]
|
||
fn vertical_tracking_keeps_the_em_at_any_horizontal_scale() {
|
||
let step = |h_scale: f64, tracking: f64| {
|
||
let l = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, h_scale, tracking, ..CharStyle::default() }));
|
||
ink(&l, 1).center().y - ink(&l, 0).center().y
|
||
};
|
||
for h_scale in [100.0, 50.0] {
|
||
let more = step(h_scale, 200.0) - step(h_scale, 0.0);
|
||
assert!((more - 4.0).abs() < 1e-6, "{h_scale}%: 200/1000 of the 20 pt em: {more}");
|
||
}
|
||
}
|
||
|
||
/// Baseline shift moves an upright glyph across the column, as it moves a letter lying on its
|
||
/// side, at any Character Rotation, and leaves its neighbors where they are. The glyph turns about
|
||
/// its shifted pen position (`xf * pivot`), and `xf * unturned` places the shifted glyph without
|
||
/// its rotation.
|
||
#[test]
|
||
fn baseline_shift_moves_an_upright_glyph_across_the_column() {
|
||
let at = |byte: usize, rotation: f64, shift: f64| {
|
||
let mut t = vertical("§l§");
|
||
edit::style_range(&mut t.runs, byte, byte + "§l§"[byte..].chars().next().map_or(1, char::len_utf8), |s| {
|
||
s.rotation = rotation;
|
||
s.baseline_shift = shift;
|
||
});
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let centre = |l: &TextLayout, byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box().center()).unwrap();
|
||
// The letter lying on its side moves across the column by the shift.
|
||
let across = centre(&at(2, 0.0, 10.0), 2) - centre(&at(2, 0.0, 0.0), 2);
|
||
assert!((across.x.abs() - 10.0).abs() < 1e-6 && across.y.abs() < 1e-6, "{across:?}");
|
||
for rotation in [0.0, 30.0, 90.0, -90.0] {
|
||
let (plain, shifted) = (at(3, rotation, 0.0), at(3, rotation, 10.0));
|
||
let moved = centre(&shifted, 3) - centre(&plain, 3);
|
||
assert!((moved - across).hypot() < 1e-6, "{rotation}°: {moved:?}, across {across:?}");
|
||
for byte in [0, 2] {
|
||
assert!((centre(&shifted, byte) - centre(&plain, byte)).hypot() < 1e-6, "{rotation}°: byte {byte} moved");
|
||
}
|
||
let (p, s) = (plain.glyphs.iter().find(|g| g.byte == 3).unwrap(), shifted.glyphs.iter().find(|g| g.byte == 3).unwrap());
|
||
let turn_at = s.xf * s.pivot - p.xf * p.pivot;
|
||
assert!((turn_at - across).hypot() < 1e-6, "{rotation}°: turns about a point moved by {turn_at:?}");
|
||
let unrotated = at(3, 0.0, 10.0);
|
||
let u = unrotated.glyphs.iter().find(|g| g.byte == 3).unwrap();
|
||
let (a, b) = ((s.xf * s.unturned).as_coeffs(), u.xf.as_coeffs());
|
||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{rotation}°: {a:?} vs {b:?}");
|
||
}
|
||
}
|
||
|
||
/// Character panel menu › Tate-chu-yoko: a marked run stands across one em of the column, including
|
||
/// a number longer than three digits and Latin letters. Unmarked text keeps the automatic rule
|
||
/// (two or three digits only). Horizontal type stores the mark and does not move the letters.
|
||
#[test]
|
||
fn manual_tate_chu_yoko_sets_marked_characters_across_one_em() {
|
||
let marked = CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() };
|
||
let year = layout(FontDb::global(), &vertical_styled("2026", marked.clone()));
|
||
assert_eq!(year.glyphs.len(), 4);
|
||
assert!((year.glyphs[0].advance - 20.0).abs() < 1e-6, "{:?}", year.glyphs[0].advance);
|
||
assert!(year.glyphs[1..].iter().all(|g| g.advance == 0.0));
|
||
let inks: Vec<Rect> = (0..4).map(|i| ink(&year, i)).collect();
|
||
for pair in inks.windows(2) {
|
||
assert!((pair[0].center().y - pair[1].center().y).abs() < 1.0, "one row: {inks:?}");
|
||
assert!(pair[1].center().x > pair[0].center().x, "left to right: {inks:?}");
|
||
}
|
||
let union = inks.iter().copied().reduce(|a, b| a.union(b)).unwrap();
|
||
assert!(union.width() <= 21.0, "squeezed into the em: {union:?}");
|
||
assert!(union.center().x.abs() < 1.5, "centred on the column: {union:?}");
|
||
|
||
let plain = layout(FontDb::global(), &vertical("2026"));
|
||
let down: f64 = plain.glyphs.iter().map(|g| g.advance).sum();
|
||
assert!(down > 30.0 && plain.glyphs.iter().all(|g| g.advance > 0.0), "unmarked 2026 runs down the column: {down}");
|
||
|
||
let letters = layout(FontDb::global(), &vertical_styled("AB", marked.clone()));
|
||
assert!((letters.glyphs[0].advance - 20.0).abs() < 1e-6 && letters.glyphs[1].advance == 0.0);
|
||
let (a, b) = (ink(&letters, 0), ink(&letters, 1));
|
||
assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "letters side by side: {a:?} {b:?}");
|
||
let side = layout(FontDb::global(), &vertical("AB"));
|
||
assert!(ink(&side, 1).center().y > ink(&side, 0).center().y + 5.0, "unmarked letters run down the column");
|
||
|
||
let mut horizontal = TextObject::point(Point::ZERO, "AB", marked);
|
||
horizontal.xf = Affine::IDENTITY;
|
||
let row = layout(FontDb::global(), &horizontal);
|
||
assert!(!row.vertical);
|
||
assert!(row.glyphs.iter().all(|g| g.advance > 0.0));
|
||
assert!(ink(&row, 1).center().x > ink(&row, 0).center().x + 5.0, "horizontal type stays a row");
|
||
}
|
||
|
||
/// Character panel menu › Tate-chu-yoko Settings: a marked block moves up by a positive Up/Down
|
||
/// and right by a positive Left/Right, in points, as one piece (the offsets of its first
|
||
/// character), and keeps its one em down the column. An automatic two-digit block and horizontal
|
||
/// type do not move.
|
||
#[test]
|
||
fn tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right() {
|
||
let marked = CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() };
|
||
let union = |l: &TextLayout, r: std::ops::Range<usize>| r.map(|i| ink(l, i)).reduce(|a, b| a.union(b)).unwrap();
|
||
let at = |up: f64, right: f64| {
|
||
let mut t = vertical_styled("", marked.clone());
|
||
t.runs = vec![
|
||
TextRun::new("AB", CharStyle { tcy_up_down: up, tcy_left_right: right, ..marked.clone() }),
|
||
TextRun::new("C", CharStyle { tcy_up_down: -up, tcy_left_right: -right, ..marked.clone() }),
|
||
TextRun::new("§", CharStyle { size: 20.0, ..CharStyle::default() }),
|
||
];
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let still = at(0.0, 0.0);
|
||
for (up, right) in [(5.0, 0.0), (-3.5, 0.0), (0.0, 4.0), (0.0, -2.0), (6.0, -7.0)] {
|
||
let moved = at(up, right);
|
||
let (a, b) = (union(&still, 0..3), union(&moved, 0..3));
|
||
assert!((b.y0 - (a.y0 - up)).abs() < 1e-6 && (b.y1 - (a.y1 - up)).abs() < 1e-6, "up {up}: {a:?} {b:?}");
|
||
assert!((b.x0 - (a.x0 + right)).abs() < 1e-6 && (b.x1 - (a.x1 + right)).abs() < 1e-6, "right {right}: {a:?} {b:?}");
|
||
assert!((moved.glyphs[0].advance - 20.0).abs() < 1e-6 && moved.glyphs[1..3].iter().all(|g| g.advance == 0.0), "one em");
|
||
assert_eq!(ink(&moved, 3), ink(&still, 3), "the mark after the block stays");
|
||
assert_eq!(moved.glyphs[0].origin, still.glyphs[0].origin, "the caret and hit region stay");
|
||
}
|
||
|
||
// An automatic block of two digits takes no offsets, and horizontal type does not move.
|
||
let shifted = CharStyle { size: 20.0, tcy_up_down: 5.0, tcy_left_right: 5.0, ..CharStyle::default() };
|
||
let digits = layout(FontDb::global(), &vertical_styled("10", shifted.clone()));
|
||
let plain = layout(FontDb::global(), &vertical("10"));
|
||
assert_eq!(union(&digits, 0..2), union(&plain, 0..2));
|
||
let row = |st: CharStyle| {
|
||
let mut t = TextObject::point(Point::ZERO, "AB", st);
|
||
t.xf = Affine::IDENTITY;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
assert_eq!(union(&row(CharStyle { tate_chu_yoko: true, ..shifted }), 0..2), union(&row(marked.clone()), 0..2));
|
||
}
|
||
|
||
/// Shrink Text to Fit scales a marked block's Up/Down and Left/Right offsets with the size, as it
|
||
/// scales the baseline shift.
|
||
#[test]
|
||
fn shrink_text_to_fit_scales_tate_chu_yoko_offsets() {
|
||
let marked = CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() };
|
||
let union = |l: &TextLayout, r: std::ops::Range<usize>| r.map(|i| ink(l, i)).reduce(|a, b| a.union(b)).unwrap();
|
||
let (up, right) = (6.0, -4.0);
|
||
let at = |up: f64, right: f64| {
|
||
let mut t = vertical_styled("", marked.clone());
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 60.0, 60.0).to_path(0.1)) };
|
||
t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 };
|
||
t.runs = vec![
|
||
TextRun::new("AB", CharStyle { tcy_up_down: up, tcy_left_right: right, ..marked.clone() }),
|
||
TextRun::new("§§§§§§§§§§§§", CharStyle { size: 20.0, ..CharStyle::default() }),
|
||
];
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let (still, moved) = (at(0.0, 0.0), at(up, right));
|
||
let f = moved.fit_scale;
|
||
assert!(f < 1.0 && f == still.fit_scale, "{f} {}", still.fit_scale);
|
||
let (a, b) = (union(&still, 0..2), union(&moved, 0..2));
|
||
assert!((b.y0 - (a.y0 - up * f)).abs() < 1e-6 && (b.x0 - (a.x0 + right * f)).abs() < 1e-6, "{f}: {a:?} {b:?}");
|
||
}
|
||
|
||
/// A marked run is one block; the unmarked digits beside it still follow the automatic rule.
|
||
/// A column shorter than two ems holds the marked number on one line.
|
||
#[test]
|
||
fn manual_tate_chu_yoko_leaves_unmarked_digits_and_stays_on_one_line() {
|
||
let plain = CharStyle { size: 20.0, ..CharStyle::default() };
|
||
let marked = CharStyle { tate_chu_yoko: true, ..plain.clone() };
|
||
let mut t = vertical_styled("", plain.clone());
|
||
t.runs = vec![TextRun::new("X", plain), TextRun::new("2026", marked), TextRun::new("10", CharStyle { size: 20.0, ..CharStyle::default() })];
|
||
let l = layout(FontDb::global(), &t);
|
||
assert_eq!(l.glyphs.len(), 7);
|
||
assert!(l.glyphs[0].advance > 0.0 && l.glyphs[0].advance < 20.0, "X on its side: {:?}", l.glyphs[0].advance);
|
||
assert!((l.glyphs[1].advance - 20.0).abs() < 1e-6 && l.glyphs[2].advance == 0.0 && l.glyphs[3].advance == 0.0 && l.glyphs[4].advance == 0.0);
|
||
assert!((l.glyphs[5].advance - 20.0).abs() < 1e-6 && l.glyphs[6].advance == 0.0, "unmarked 10 stays automatic");
|
||
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 30.0).to_path(0.1)) };
|
||
t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() })];
|
||
let one = layout(FontDb::global(), &t);
|
||
assert_eq!(one.lines.len(), 1, "the block is one em, so it fits a 30 pt column");
|
||
t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, ..CharStyle::default() })];
|
||
let wrapped = layout(FontDb::global(), &t);
|
||
assert!(wrapped.lines.len() > 1, "unmarked 2026 is longer than the column");
|
||
}
|
||
|
||
/// Character panel menu › Standard Vertical Roman Alignment: marked Latin letters and digits stand
|
||
/// upright one below the other, each in a cell of one em (the bundled font has no vertical
|
||
/// metrics), centred on the column. A space keeps its width, two digits stay tate-chu-yoko,
|
||
/// unmarked letters lie on their side, and horizontal type is unchanged.
|
||
#[test]
|
||
fn standard_vertical_roman_alignment_stands_half_width_characters_upright() {
|
||
let plain = CharStyle { size: 20.0, ..CharStyle::default() };
|
||
let marked = CharStyle { standard_vertical_roman: true, ..plain.clone() };
|
||
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "l", plain.clone()));
|
||
let side = layout(FontDb::global(), &vertical_styled("lA l", plain.clone()));
|
||
let up = layout(FontDb::global(), &vertical_styled("lA l", marked.clone()));
|
||
let (hl, sl, ul) = (ink(&h, 0), ink(&side, 0), ink(&up, 0));
|
||
assert!(sl.width() > sl.height(), "unmarked l lies across the column: {sl:?}");
|
||
assert!((ul.width() - hl.width()).abs() < 0.01 && (ul.height() - hl.height()).abs() < 0.01, "marked l upright: {hl:?} {ul:?}");
|
||
assert!(ul.height() > ul.width());
|
||
for i in [0, 1, 3] {
|
||
assert!((up.glyphs[i].advance - 20.0).abs() < 1e-6, "one em down the column: {:?}", up.glyphs[i].advance);
|
||
assert!(ink(&up, i).center().x.abs() < 1.5, "centred on the column: {:?}", ink(&up, i));
|
||
assert!(side.glyphs[i].advance < 20.0, "unmarked advance: {:?}", side.glyphs[i].advance);
|
||
}
|
||
assert!(ink(&up, 1).center().y > ink(&up, 0).center().y + 15.0, "one below the other");
|
||
assert!((up.glyphs[2].advance - side.glyphs[2].advance).abs() < 1e-6, "the space keeps its width");
|
||
|
||
let digits = layout(FontDb::global(), &vertical_styled("10", marked.clone()));
|
||
assert!((digits.glyphs[0].advance - 20.0).abs() < 1e-6 && digits.glyphs[1].advance == 0.0, "two digits stay tate-chu-yoko");
|
||
let (a, b) = (ink(&digits, 0), ink(&digits, 1));
|
||
assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "side by side: {a:?} {b:?}");
|
||
|
||
let mut horizontal = TextObject::point(Point::ZERO, "lA", marked);
|
||
horizontal.xf = Affine::IDENTITY;
|
||
let row = layout(FontDb::global(), &horizontal);
|
||
let plain_row = layout(FontDb::global(), &TextObject::point(Point::ZERO, "lA", plain));
|
||
assert_eq!(row.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>(), plain_row.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
|
||
assert!((ink(&row, 0).height() - hl.height()).abs() < 0.01, "horizontal type stays as it is");
|
||
}
|
||
|
||
/// A combining mark shaped as a glyph of its own turns upright with its letter and stays over it,
|
||
/// as in horizontal type, and takes no cell of its own down the column.
|
||
#[test]
|
||
fn standard_vertical_roman_alignment_keeps_combining_marks_on_their_letter() {
|
||
let plain = CharStyle { size: 20.0, ..CharStyle::default() };
|
||
let text = "q\u{301}x";
|
||
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, text, plain.clone()));
|
||
assert_eq!(h.glyphs.len(), 3, "the bundled font sets the acute as a glyph of its own");
|
||
let up = layout(FontDb::global(), &vertical_styled(text, CharStyle { standard_vertical_roman: true, ..plain }));
|
||
assert_eq!(up.glyphs.len(), 3);
|
||
let (hq, ha, q, a, x) = (ink(&h, 0), ink(&h, 1), ink(&up, 0), ink(&up, 1), ink(&up, 2));
|
||
let offset = |base: Rect, mark: Rect| (mark.center().x - base.center().x, mark.center().y - base.center().y);
|
||
let (want, got) = (offset(hq, ha), offset(q, a));
|
||
assert!((want.0 - got.0).abs() < 0.01 && (want.1 - got.1).abs() < 0.01, "the acute sits on the q as in a row: {want:?} {got:?}");
|
||
assert!((up.glyphs[0].advance - 20.0).abs() < 1e-6 && up.glyphs[1].advance == 0.0, "one cell for the cluster");
|
||
assert!(x.center().y > q.center().y + 10.0, "the next letter below: {q:?} {x:?}");
|
||
}
|
||
|
||
/// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional).
|
||
#[test]
|
||
fn japanese_numbers_keep_their_own_cells_down_the_column() {
|
||
let Some(face) = crate::craft_fonts::japanese_ui_fonts(false).into_iter().next() else {
|
||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||
return;
|
||
};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
let text = "はただ商店 秋のセール 10月22日まで";
|
||
let t = vertical_styled(text, CharStyle { size: 28.0, font_family: face.family.into(), font_style: face.style.into(), ..CharStyle::default() });
|
||
let l = layout(&db, &t);
|
||
assert!(l.glyphs.iter().filter(|g| !text[g.byte..].starts_with(' ')).all(|g| g.gid != 0), "real glyphs, no tofu");
|
||
assert_own_cells(&l, text, 0.5);
|
||
for block in ["10月", "22日"] {
|
||
let first = glyph_of(&l, text, block);
|
||
let g = &l.glyphs[first];
|
||
assert!((g.advance - 28.0).abs() < 1e-9 && l.glyphs[first + 1].advance == 0.0, "{block}: one em");
|
||
assert!((l.glyphs[first + 2].origin.y - (g.origin.y + 28.0)).abs() < 1e-9, "{block}: the next character follows the em");
|
||
let inks = ink(&l, first).union(ink(&l, first + 1));
|
||
assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}");
|
||
}
|
||
}
|
||
|
||
/// Upright glyphs take their cell from the font's vertical metrics: the advance down the column,
|
||
/// and where the glyph hangs from its vertical origin (VORG, or the glyph's top and top side
|
||
/// bearing). Fonts without them keep one em and the em box centre.
|
||
#[test]
|
||
fn upright_glyphs_follow_the_fonts_vertical_metrics() {
|
||
use crate::test_fonts::{VERTICAL_FAMILY, VERTICAL_TALL, vertical_font};
|
||
for vorg in [false, true] {
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(vertical_font(vorg).unwrap());
|
||
let text = format!("{VERTICAL_TALL}{VERTICAL_TALL}");
|
||
let mut t = vertical(&text);
|
||
t.runs[0].style.font_family = VERTICAL_FAMILY.into();
|
||
let l = layout(&db, &t);
|
||
let step = l.glyphs[1].origin.y - l.glyphs[0].origin.y;
|
||
assert!((step - 28.0).abs() < 0.01, "1.4 em down the column at 20 pt (vorg {vorg}): {step}");
|
||
// The glyph hangs from its vertical origin, 1.1 em above the baseline: its top is
|
||
// (1.1 em − its own top) below the top of its cell.
|
||
let face = db.face(VERTICAL_FAMILY, "Regular").unwrap();
|
||
let (_, origin) = face.vertical_glyph(face.glyph_for(VERTICAL_TALL)).unwrap();
|
||
assert!((origin - 1100.0).abs() < 1.0, "vorg {vorg}: {origin}");
|
||
let top = db.outline(&face, face.glyph_for(VERTICAL_TALL)).bounding_box().y0; // y-down: −yMax
|
||
let cell_top = l.glyphs[0].origin.y;
|
||
let expected = cell_top + (1.1 + top / 1000.0) * 20.0;
|
||
assert!((ink(&l, 0).y0 - expected).abs() < 0.05, "vorg {vorg}: ink top {} vs {expected}", ink(&l, 0).y0);
|
||
}
|
||
// The same glyph in the font without vertical metrics: one em, as before.
|
||
let l = layout(FontDb::global(), &vertical("§§"));
|
||
assert!((l.glyphs[1].origin.y - l.glyphs[0].origin.y - 20.0).abs() < 0.01);
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.1): with Line-end Punctuation Half Width, a closing mark ending a line is set
|
||
/// half width, a closing mark followed by punctuation loses the space after it, and an opening
|
||
/// bracket after another loses the space before it; vertical type the same way down the column.
|
||
/// Needs a font with full-width Japanese punctuation (skipped without one).
|
||
#[test]
|
||
fn mojikumi_halves_line_end_and_consecutive_punctuation() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let solid = lay("一。", Mojikumi::None, false);
|
||
if (solid.glyphs[1].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width punctuation here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let adv = |text: &str, m| lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||
// Line end: 。 half width.
|
||
assert!((adv("一。", Mojikumi::LineEndHalf)[1] - 10.0).abs() < 0.01, "vertical {vertical_type}");
|
||
assert!((adv("一。", Mojikumi::None)[1] - 20.0).abs() < 0.01);
|
||
// 」 before 「: the closing mark's space goes; 「 keeps its own.
|
||
let a = adv("一」「二", Mojikumi::LineEndHalf);
|
||
assert!((a[1] - 10.0).abs() < 0.01 && (a[2] - 20.0).abs() < 0.01, "{a:?}");
|
||
// 「「 inside a line: the second bracket loses the space before it and is drawn half an em
|
||
// earlier (at a line's start the first one would lose its own too).
|
||
let a = adv("一「「二", Mojikumi::LineEndHalf);
|
||
assert!((a[1] - 20.0).abs() < 0.01 && (a[2] - 10.0).abs() < 0.01, "{a:?}");
|
||
let (on, off) = (lay("一「「二", Mojikumi::LineEndHalf, vertical_type), lay("一「「二", Mojikumi::None, vertical_type));
|
||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||
let moved = along(off.glyphs[2].outline.bounding_box()) - along(on.glyphs[2].outline.bounding_box());
|
||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
|
||
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
|
||
let drift = across(off.glyphs[2].outline.bounding_box()) - across(on.glyphs[2].outline.bounding_box());
|
||
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
|
||
// Text without punctuation is untouched.
|
||
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
|
||
}
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.2.2): a quarter em between Japanese and Latin letters or digits, either way
|
||
/// round, horizontal and vertical; none inside a tate-chu-yoko block, none left at a line's end, and
|
||
/// none with Mojikumi None. Needs a font with full-width Japanese (skipped without one).
|
||
#[test]
|
||
fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("雅", Mojikumi::None, false).glyphs[0].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width Japanese here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let extra = |text: &str| -> Vec<f64> {
|
||
let (on, off) = (lay(text, Mojikumi::LineEndHalf, vertical_type), lay(text, Mojikumi::None, vertical_type));
|
||
on.glyphs.iter().zip(&off.glyphs).map(|(a, b)| a.advance - b.advance).collect()
|
||
};
|
||
// 雅楽 2026 年: after 楽 and after 6.
|
||
assert_eq!(extra("雅楽2026年").iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>(), [0.0, 5.0, 0.0, 0.0, 0.0, 5.0, 0.0]);
|
||
// Latin first, and nothing after the last character of the line.
|
||
assert_eq!(extra("AB雅").iter().map(|x| x.round()).collect::<Vec<_>>(), [0.0, 5.0, 0.0]);
|
||
assert_eq!(extra("雅A").iter().map(|x| x.round()).collect::<Vec<_>>(), [5.0, 0.0]);
|
||
// Punctuation takes no Japanese–Latin space (neither bracket is at the line's start or end).
|
||
assert!(extra("で「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("で「A」です"));
|
||
}
|
||
// A tate-chu-yoko block is set as a Japanese character, with no space inside or around it.
|
||
let on = lay("第10回", Mojikumi::LineEndHalf, true);
|
||
let off = lay("第10回", Mojikumi::None, true);
|
||
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
|
||
}
|
||
|
||
/// Mojikumi applies to type holding Japanese punctuation or a Japanese letter; type without either
|
||
/// lays out the same with Line-end Punctuation Half Width as with Mojikumi None, across lines and
|
||
/// list markers too.
|
||
#[test]
|
||
fn mojikumi_applies_only_to_japanese_punctuation_and_letters() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||
let styled = |text: &str, m: Mojikumi| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.mojikumi = m;
|
||
t
|
||
};
|
||
let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.advance, g.origin, g.xf)).collect::<Vec<_>>();
|
||
for text in ["", "A", "Latin words, digits 2026 and (brackets).", "first\nsecond"] {
|
||
let on = styled(text, Mojikumi::LineEndHalf);
|
||
let lay = layout(FontDb::global(), &on);
|
||
assert!(!lay.mojikumi_applies(&on.plain_text()), "{text:?}");
|
||
let off = layout(FontDb::global(), &styled(text, Mojikumi::None));
|
||
assert_eq!(placed(&lay), placed(&off), "{text:?}");
|
||
assert_eq!(lay.bounds, off.bounds, "{text:?}");
|
||
}
|
||
let mut listed = styled("first\nsecond", Mojikumi::LineEndHalf);
|
||
listed.para.list = Some(ParaList::bulleted(Bullet::Dot));
|
||
assert!(!layout(FontDb::global(), &listed).mojikumi_applies(&listed.plain_text()));
|
||
for text in ["、", "「A」", "雅", "々", "Latin 漢字"] {
|
||
let t = styled(text, Mojikumi::LineEndHalf);
|
||
assert!(layout(FontDb::global(), &t).mojikumi_applies(&t.plain_text()), "{text:?}");
|
||
}
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a
|
||
/// wrapped line is set flush with the line's start (the space before it goes), giving the line half
|
||
/// an em more room; at the start of a paragraph too, so its face starts at the first-line indent
|
||
/// (JIS X 4051's principle, Figure 71 ①). Horizontal and vertical. Needs a font with full-width
|
||
/// Japanese punctuation (skipped without one).
|
||
#[test]
|
||
fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
// 20 pt type in a frame three and a half ems across the lines.
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 70.0) } else { Rect::new(0.0, 0.0, 70.0, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("一「", Mojikumi::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width punctuation here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||
// 一二三 / 「四五六: the bracket can't end the first line, so it starts the second.
|
||
let (on, off) = (lay("一二三「四五六", Mojikumi::LineEndHalf, vertical_type), lay("一二三「四五六", Mojikumi::None, vertical_type));
|
||
let moved = along(off.glyphs[3].outline.bounding_box()) - along(on.glyphs[3].outline.bounding_box());
|
||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 is drawn half an em earlier ({moved})");
|
||
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
|
||
let drift = across(off.glyphs[3].outline.bounding_box()) - across(on.glyphs[3].outline.bounding_box());
|
||
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
|
||
assert!((on.glyphs[3].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", on.glyphs[3].advance);
|
||
// The half em it gave up lets 六 stay on the line.
|
||
assert_eq!(on.glyphs[6].line, on.glyphs[3].line, "vertical {vertical_type}");
|
||
assert_ne!(off.glyphs[6].line, off.glyphs[3].line, "vertical {vertical_type}");
|
||
// At the start of a paragraph too: half width, its face half an em earlier than with
|
||
// Mojikumi None, so it starts at the first-line indent.
|
||
let first = lay("「一」", Mojikumi::LineEndHalf, vertical_type);
|
||
assert!((first.glyphs[0].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
|
||
let solid = lay("「一」", Mojikumi::None, vertical_type);
|
||
let moved = along(solid.glyphs[0].outline.bounding_box()) - along(first.glyphs[0].outline.bounding_box());
|
||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the paragraph's 「 is drawn half an em earlier ({moved})");
|
||
}
|
||
}
|
||
|
||
/// Mojikumi: with Line-end Punctuation Half Width, a line is measured as it is set. A closing
|
||
/// bracket, comma or full stop that fits only at half width ends the line in a measure of three
|
||
/// and a half ems, with both composers, horizontal and vertical; with Mojikumi None it goes to the
|
||
/// next line with the character before it. A closing mark that already lost its half to the
|
||
/// punctuation after it takes nothing more off, and the space between Japanese and Latin text
|
||
/// counts only where the line ends. Uses a test font whose ideographs and punctuation are one em.
|
||
#[test]
|
||
fn mojikumi_line_end_half_width_counts_when_a_line_breaks() {
|
||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(full_width_font().expect("test font"));
|
||
let lay = |text: &str, m: Mojikumi, composer: Composer, burasagari: Burasagari, vertical_type: bool, measure: f64| {
|
||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
t.para.composer = composer;
|
||
t.para.burasagari = burasagari;
|
||
t.para.kinsoku = Kinsoku::Hard;
|
||
// Push Out Only lets the every-line composer take the paragraph.
|
||
if composer == Composer::EveryLine {
|
||
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
|
||
}
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(&db, &t)
|
||
};
|
||
let ends = |l: &TextLayout, text: &str| l.lines.iter().map(|line| text[line.start..line.end].to_string()).collect::<Vec<_>>();
|
||
let widest = |l: &TextLayout| {
|
||
l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>()).fold(0.0, f64::max)
|
||
};
|
||
assert!((lay("一", Mojikumi::None, Composer::SingleLine, Burasagari::None, false, 70.0).glyphs[0].advance - 20.0).abs() < 1e-9, "one em");
|
||
for vertical_type in [false, true] {
|
||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||
let at = format!("vertical {vertical_type} {composer:?}");
|
||
let marks = [("一二三」四五六", "一二三」"), ("一二三。四五六", "一二三。"), ("一二三、四五六", "一二三、")];
|
||
for (text, first) in marks {
|
||
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
|
||
assert_eq!(ends(&l, text), [first, "四五六"], "{at}: {text}");
|
||
assert!((l.glyphs[3].advance - 10.0).abs() < 1e-9, "{at}: {text}: the mark ends the line half width");
|
||
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: {text}: {}", widest(&l));
|
||
// Standard burasagari: the mark fits inside, so it doesn't hang.
|
||
let standard = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::Standard, vertical_type, 70.0);
|
||
assert_eq!(ends(&standard, text), [first, "四五六"], "{at}: {text} standard");
|
||
assert!(widest(&standard) <= 70.0 + 1e-9, "{at}: {text} standard");
|
||
// Full width it doesn't fit: kinsoku takes 三 to the next line with it.
|
||
let solid = lay(text, Mojikumi::None, composer, Burasagari::None, vertical_type, 70.0);
|
||
assert_eq!(ends(&solid, text).first().map(String::as_str), Some("一二"), "{at}: {text} none");
|
||
}
|
||
// 」 lost its half to 。 after it: the line would be four ems, so 三」。 go on.
|
||
let text = "一二三」。四五";
|
||
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
|
||
assert_eq!(ends(&l, text).first().map(String::as_str), Some("一二"), "{at}: compressed");
|
||
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: compressed");
|
||
}
|
||
}
|
||
// The quarter em between 三 and A goes where the line ends there: 一二三 fits three ems and a
|
||
// tenth, with or without the space at its end.
|
||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||
let text = "一二三ABC";
|
||
let on = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 62.0);
|
||
assert_eq!(ends(&on, text).first().map(String::as_str), Some("一二三"), "{composer:?}");
|
||
assert!((on.glyphs[2].advance - 20.0).abs() < 1e-9, "{composer:?}: no space at the line's end");
|
||
let wide = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 200.0);
|
||
assert!((wide.glyphs[2].advance - 25.0).abs() < 1e-9, "{composer:?}: the space inside the line");
|
||
}
|
||
}
|
||
|
||
/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top,
|
||
/// a line's first word is measured as the line sets it. A word ending with a closing bracket that
|
||
/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them
|
||
/// only flush with the line's start, start on the first line, with both composers. An opening
|
||
/// bracket that kinsoku keeps with the character after it, and a No Break run, are measured whole.
|
||
#[test]
|
||
fn mojikumi_narrows_a_lines_first_word_when_looking_for_room() {
|
||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||
use vectorcraft_doc::{Composer, Kinsoku, KinsokuShoriType, Mojikumi, TextWrap, WrapShape};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(full_width_font().expect("test font"));
|
||
// The top 40 pt of the frame are `top` wide, the rest 200: an L-shaped frame, or a rectangle
|
||
// with a wrap object over the top right.
|
||
let lay = |text: &str, styled: fn(&mut TextObject), composer: Composer, top: f64, wrapped: bool| {
|
||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||
t.xf = Affine::IDENTITY;
|
||
styled(&mut t);
|
||
t.para.mojikumi = Mojikumi::LineEndHalf;
|
||
t.para.composer = composer;
|
||
t.para.kinsoku = Kinsoku::Hard;
|
||
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
|
||
let frame = if wrapped {
|
||
t.wrap = vec![WrapShape {
|
||
path: PathData::from_bezpath(&Rect::new(top, -10.0, 210.0, 40.0).to_path(0.1)),
|
||
wrap: TextWrap { offset: 0.0, invert: false },
|
||
}];
|
||
Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)
|
||
} else {
|
||
let mut p = kurbo::BezPath::new();
|
||
p.move_to((0.0, 0.0));
|
||
p.line_to((top, 0.0));
|
||
p.line_to((top, 40.0));
|
||
p.line_to((200.0, 40.0));
|
||
p.line_to((200.0, 200.0));
|
||
p.line_to((0.0, 200.0));
|
||
p.close_path();
|
||
p
|
||
};
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
|
||
layout(&db, &t)
|
||
};
|
||
let plain: fn(&mut TextObject) = |_| {};
|
||
let wide_bracket: fn(&mut TextObject) = |t| edit::style_range(&mut t.runs, 0, '「'.len_utf8(), |s| s.h_scale = 250.0);
|
||
let no_break: fn(&mut TextObject) = |t| {
|
||
let n = t.plain_text().len();
|
||
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
|
||
};
|
||
let no_break_but_the_last_word: fn(&mut TextObject) = |t| {
|
||
let n = t.plain_text().rfind(' ').map_or(0, |k| k + 1);
|
||
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
|
||
};
|
||
// "ABCD」" is `full` wide inside a line; ending one, 」 is half width.
|
||
let closing = "ABCD」EFGH";
|
||
let open = lay(closing, plain, Composer::SingleLine, 200.0, false);
|
||
let full: f64 = open.glyphs[..5].iter().map(|g| g.advance).sum();
|
||
assert!((open.glyphs[4].advance - 20.0).abs() < 1e-9, "」 is one em inside a line");
|
||
for wrapped in [false, true] {
|
||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||
let at = format!("wrapped {wrapped} {composer:?}");
|
||
let top = full - 5.0;
|
||
let l = lay(closing, plain, composer, top, wrapped);
|
||
let first = l.lines.first().expect("a line");
|
||
assert_eq!(&closing[first.start..first.end], "ABCD」", "{at}");
|
||
assert!(first.baseline < 40.0, "{at}: the word starts on the narrow lines: {}", first.baseline);
|
||
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
|
||
assert!(set <= top + 1e-9, "{at}: {set} in {top}");
|
||
// 「 is 50 pt at 250%, 25 pt flush with the line's start: 「一 is 45 pt.
|
||
let opening = "「一二三四五六";
|
||
let l = lay(opening, wide_bracket, composer, 47.0, wrapped);
|
||
let first = l.lines.first().expect("a line");
|
||
assert_eq!(&opening[first.start..first.end], "「一", "{at}");
|
||
assert!(first.baseline < 40.0, "{at}: 「 starts on the narrow lines: {}", first.baseline);
|
||
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
|
||
assert!(set <= 47.0 + 1e-9, "{at}: {set}");
|
||
// Kinsoku keeps 「 with 一: in 40 pt they don't fit, and the line starts below, whole.
|
||
let l = lay(opening, wide_bracket, composer, 40.0, wrapped);
|
||
let first = l.lines.first().expect("a line");
|
||
assert!(first.baseline > 40.0 && &opening[first.start..first.end] != "「", "{at}: kinsoku: {:?}", placed_lines(&l, opening));
|
||
// A No Break run is one word, its space included: it doesn't fit the narrow lines.
|
||
let run_text = "ABCD EFGH";
|
||
let run: f64 = lay(run_text, no_break, composer, 200.0, false).glyphs.iter().map(|g| g.advance).sum();
|
||
let l = lay(run_text, no_break, composer, run - 5.0, wrapped);
|
||
let first = l.lines.first().expect("a line");
|
||
assert!(first.baseline > 40.0 && &run_text[first.start..first.end] == run_text, "{at}: no break: {:?}", placed_lines(&l, run_text));
|
||
// …but the space ending a No Break run is not set at the line's end: the run fits a
|
||
// line 2 pt wider than it is without that space.
|
||
let ending = "ABCD EFGH IJ";
|
||
let l = lay(ending, no_break_but_the_last_word, composer, run + 2.0, wrapped);
|
||
let first = l.lines.first().expect("a line");
|
||
assert!(first.baseline < 40.0 && ending[first.start..first.end].trim_end() == run_text, "{at}: {:?}", placed_lines(&l, ending));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Each line's baseline and text.
|
||
fn placed_lines(l: &TextLayout, text: &str) -> Vec<(f64, String)> {
|
||
l.lines.iter().map(|line| (line.baseline, text[line.start..line.end].to_string())).collect()
|
||
}
|
||
|
||
/// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to
|
||
/// the next line with the character before it (None) or hangs outside the line (Standard, Forced);
|
||
/// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four
|
||
/// before it fill the measure (Forced). Closing brackets don't hang. Horizontal and vertical, with
|
||
/// Line-end Punctuation Half Width (the hanging mark is half width). Needs a font with full-width
|
||
/// Japanese punctuation (skipped without one).
|
||
#[test]
|
||
fn burasagari_hangs_a_comma_or_full_stop_outside_the_line() {
|
||
use vectorcraft_doc::{Burasagari, Mojikumi};
|
||
// 20 pt type, justified, in a frame five ems along the lines.
|
||
let lay = |text: &str, b: Burasagari, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = Mojikumi::LineEndHalf;
|
||
t.para.justify = Justify::JustifyLeft;
|
||
t.para.burasagari = b;
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 100.0) } else { Rect::new(0.0, 0.0, 100.0, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("一、二", Burasagari::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width punctuation here
|
||
}
|
||
let advances = |l: &TextLayout, n: usize| l.glyphs.iter().take(n).map(|g| (g.advance * 100.0).round() / 100.0).collect::<Vec<_>>();
|
||
for vertical_type in [false, true] {
|
||
// 、 as the sixth character.
|
||
let none = lay("一二三四五、六七", Burasagari::None, vertical_type);
|
||
assert_ne!(none.glyphs[4].line, none.glyphs[0].line, "vertical {vertical_type}: 五、 go to the next line");
|
||
for b in [Burasagari::Standard, Burasagari::Forced] {
|
||
let l = lay("一二三四五、六七", b, vertical_type);
|
||
assert_eq!(l.glyphs[5].line, l.glyphs[0].line, "vertical {vertical_type} {b:?}: 、 hangs");
|
||
assert_eq!(advances(&l, 6), [20.0, 20.0, 20.0, 20.0, 20.0, 10.0], "vertical {vertical_type} {b:?}: five ems inside, half an em outside");
|
||
assert_eq!(l.glyphs[6].line, l.glyphs[0].line + 1, "vertical {vertical_type} {b:?}");
|
||
}
|
||
// 。 as the fifth character: it fits.
|
||
for b in [Burasagari::None, Burasagari::Standard] {
|
||
let l = lay("一二三四。六七", b, vertical_type);
|
||
assert_eq!(l.glyphs[5].line, l.glyphs[0].line + 1, "vertical {vertical_type} {b:?}");
|
||
// Four ems and a half: the half em left is spread between the five characters.
|
||
assert_eq!(advances(&l, 5), [22.5, 22.5, 22.5, 22.5, 10.0], "vertical {vertical_type} {b:?}: 。 inside");
|
||
}
|
||
let forced = lay("一二三四。六七", Burasagari::Forced, vertical_type);
|
||
let a = advances(&forced, 5);
|
||
assert!((a[..4].iter().sum::<f64>() - 100.0).abs() < 0.05, "vertical {vertical_type}: the four fill the measure: {a:?}");
|
||
assert_eq!(a[4], 10.0, "vertical {vertical_type}");
|
||
assert_eq!(forced.glyphs[4].line, forced.glyphs[0].line);
|
||
// A closing bracket doesn't hang: 」 as the sixth character goes on with 五.
|
||
for b in [Burasagari::Standard, Burasagari::Forced] {
|
||
let l = lay("一二三四五」六七", b, vertical_type);
|
||
assert_ne!(l.glyphs[4].line, l.glyphs[0].line, "vertical {vertical_type} {b:?}");
|
||
}
|
||
// The full-width comma and full stop hang too.
|
||
for mark in [',', '.'] {
|
||
let l = lay(&format!("一二三四五{mark}六七"), Burasagari::Standard, vertical_type);
|
||
assert_eq!(l.glyphs[5].line, l.glyphs[0].line, "vertical {vertical_type}: {mark} hangs");
|
||
}
|
||
}
|
||
// Horizontal: the hanging mark starts at the frame's edge.
|
||
let l = lay("一二三四五、六七", Burasagari::Standard, false);
|
||
assert!((l.glyphs[5].origin.x - 100.0).abs() < 0.01, "{:?}", l.glyphs[5].origin);
|
||
}
|
||
|
||
/// Kinsoku Set: in a measure of two ems, あカッ breaks before ッ with Soft (a small kana may start a
|
||
/// line) and before カ with Hard (ッ goes with the character before it); None breaks as Soft here.
|
||
/// Horizontal and vertical. Needs a font with full-width Japanese (skipped without one).
|
||
#[test]
|
||
fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
|
||
use vectorcraft_doc::Kinsoku;
|
||
let lay = |text: &str, k: Kinsoku, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.kinsoku = k;
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 40.0) } else { Rect::new(0.0, 0.0, 40.0, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("雅", Kinsoku::Hard, false).glyphs[0].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width Japanese here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let lines = |k| lay("あカッ", k, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
|
||
assert_eq!(lines(Kinsoku::Hard), [0, 1, 1], "vertical {vertical_type}");
|
||
assert_eq!(lines(Kinsoku::Soft), [0, 0, 1], "vertical {vertical_type}");
|
||
assert_eq!(lines(Kinsoku::None), [0, 0, 1], "vertical {vertical_type}");
|
||
// A full stop never starts a line, Soft or not.
|
||
let stop = |k| lay("あカ。", k, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
|
||
assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}");
|
||
}
|
||
}
|
||
|
||
/// Kinsoku Shori Type: in a measure of fifteen ems, the closing bracket after the fifteenth
|
||
/// character ends the first line with Push In First, which closes the line's gaps to keep it inside
|
||
/// the frame whatever its alignment; Push Out Only moves the fifteenth character to the next line
|
||
/// with the bracket. Horizontal and vertical. Needs a font with full-width Japanese (skipped
|
||
/// without one).
|
||
#[test]
|
||
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
|
||
use vectorcraft_doc::KinsokuShoriType;
|
||
let text = "一二三四五六七八九十一二三四五」六";
|
||
let lay = |ty: KinsokuShoriType, justify: Justify, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.kinsoku_type = ty;
|
||
t.para.justify = justify;
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 300.0) } else { Rect::new(0.0, 0.0, 300.0, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay(KinsokuShoriType::PushOutOnly, Justify::Left, false).glyphs[0].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width Japanese here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let lines = |ty| lay(ty, Justify::Left, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
|
||
let mut pushed_in = vec![0; 16];
|
||
pushed_in.push(1);
|
||
assert_eq!(lines(KinsokuShoriType::PushInFirst), pushed_in, "vertical {vertical_type}");
|
||
let mut pushed_out = vec![0; 14];
|
||
pushed_out.extend([1, 1, 1]);
|
||
assert_eq!(lines(KinsokuShoriType::PushOutOnly), pushed_out, "vertical {vertical_type}");
|
||
assert_eq!(lines(KinsokuShoriType::PushOutFirst), pushed_out, "vertical {vertical_type}: a push-out leaves one em");
|
||
}
|
||
for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] {
|
||
let l = lay(KinsokuShoriType::PushInFirst, justify, false);
|
||
let (first, bracket) = (&l.glyphs[0], &l.glyphs[15]);
|
||
assert!(first.origin.x.abs() < 0.01, "{justify:?}: the line starts at the frame's left: {:?}", first.origin);
|
||
assert!(bracket.origin.x + bracket.advance <= 300.01, "{justify:?}: 」 ends inside the frame: {:?}", bracket.origin);
|
||
assert!(l.glyphs[1].origin.x < 20.0 - 0.5, "{justify:?}: the gaps close up");
|
||
for g in &l.glyphs[..15] {
|
||
assert!(g.advance >= 18.0 - 0.01, "{justify:?}: a gap closes by at most a tenth of an em: {}", g.advance);
|
||
}
|
||
}
|
||
// A No Break run wider than the frame did not push anything in, and keeps its gaps.
|
||
let mut t = TextObject::point(Point::ZERO, "一二三四五六七八九十", CharStyle { size: 20.0, no_break: true, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.kinsoku_type = KinsokuShoriType::PushInFirst;
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 150.0, 200.0).to_path(0.1)) };
|
||
let l = layout(FontDb::global(), &t);
|
||
assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin);
|
||
}
|
||
|
||
/// Kinsoku Shori Type at 200% Vertical Scale in a column of 225 pt: the font's vertical metrics set
|
||
/// each character 40 pt long, and its five gaps close by up to 4 pt each, room for the 15 pt 」
|
||
/// overflows by, so 」 is pushed in; Push Out Only moves 五 to the next column with it. Needs
|
||
/// craft-fonts' Shippori Mincho (skipped without it).
|
||
#[test]
|
||
fn kinsoku_shori_type_closes_a_column_by_the_em_down_the_column() {
|
||
use vectorcraft_doc::KinsokuShoriType;
|
||
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||
return;
|
||
}
|
||
let lay = |ty: KinsokuShoriType| {
|
||
let mut t = vertical_styled(
|
||
"一二三四五」六",
|
||
CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), v_scale: 200.0, ..CharStyle::default() },
|
||
);
|
||
t.para.kinsoku_type = ty;
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 225.0).to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let lines = |ty| lay(ty).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
|
||
let out = lay(KinsokuShoriType::PushOutOnly);
|
||
assert!((out.glyphs[0].advance - 40.0).abs() < 0.01, "40 pt down the column: {}", out.glyphs[0].advance);
|
||
assert_eq!(lines(KinsokuShoriType::PushInFirst), [0, 0, 0, 0, 0, 0, 1]);
|
||
let bracket = &lay(KinsokuShoriType::PushInFirst).glyphs[5];
|
||
assert!(bracket.origin.y + bracket.advance <= 225.01, "」 ends inside the column: {:?} +{}", bracket.origin, bracket.advance);
|
||
assert_eq!(lines(KinsokuShoriType::PushOutOnly), [0, 0, 0, 0, 1, 1, 1]);
|
||
}
|
||
|
||
/// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional
|
||
/// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in
|
||
/// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`
|
||
/// re-spaced, and trims the punctuation it leaves full height (〘〙 in Shippori Mincho) as before.
|
||
/// Off, the column is as before, and a font without vertical metrics (the bundled Source Sans 3)
|
||
/// keeps today's cells. Needs craft-fonts' Shippori Mincho (skipped without it).
|
||
#[test]
|
||
fn proportional_metrics_set_upright_glyphs_on_their_proportional_heights() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
let lay = |text: &str, font: &str, on: bool, m: Mojikumi| {
|
||
let mut t = vertical_styled(text, CharStyle { size: 20.0, font_family: font.into(), proportional_metrics: on, ..CharStyle::default() });
|
||
t.para.mojikumi = m;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||
let y0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().y0).collect::<Vec<_>>();
|
||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||
// Without vertical metrics the cells stay as they are.
|
||
let (on, off) = (lay("§§", "Source Sans 3", true, Mojikumi::None), lay("§§", "Source Sans 3", false, Mojikumi::None));
|
||
assert!(near(&adv(&on), &adv(&off)) && near(&y0(&on), &y0(&off)), "{:?} {:?}", adv(&on), adv(&off));
|
||
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||
return;
|
||
}
|
||
let text = "「あ」、ッ漢。";
|
||
let off = lay(text, "Shippori Mincho", false, Mojikumi::None);
|
||
assert!(off.glyphs.iter().all(|g| (g.advance - 20.0).abs() < 0.01), "off: an em each, as before: {:?}", adv(&off));
|
||
let on = lay(text, "Shippori Mincho", true, Mojikumi::None);
|
||
assert!(on.glyphs[0].advance < 12.0 && on.glyphs[4].advance < 16.0, "「 and ッ shorter: {:?}", adv(&on));
|
||
assert!((on.glyphs[5].advance - 20.0).abs() < 0.01, "the kanji keeps its em: {:?}", adv(&on));
|
||
// Each glyph's ink stays in its own cell, so none runs into the next.
|
||
for g in &on.glyphs {
|
||
let b = g.outline.bounding_box();
|
||
assert!(b.y0 > g.origin.y - 0.5 && b.y1 < g.origin.y + g.advance + 0.5, "byte {}: ink {b:?} in [{}, +{}]", g.byte, g.origin.y, g.advance);
|
||
}
|
||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing.
|
||
let half = lay(text, "Shippori Mincho", true, Mojikumi::LineEndHalf);
|
||
assert!(near(&adv(&half), &adv(&on)) && near(&y0(&half), &y0(&on)), "{:?} {:?}", adv(&half), adv(&on));
|
||
// Punctuation `vpal` leaves full height: set flush at the paragraph's start, as before.
|
||
let (half, none) = (lay("〘あ〙", "Shippori Mincho", true, Mojikumi::LineEndHalf), lay("〘あ〙", "Shippori Mincho", true, Mojikumi::None));
|
||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||
}
|
||
|
||
/// Proportional Metrics turns on the kerning that goes with it (#966): `vkrn` and `vapk` in
|
||
/// vertical type with `vpal`, `apkn` with `palt`, only while the style's kerning is automatic.
|
||
/// Without Proportional Metrics vertical type isn't kerned, as before. Needs craft-fonts' Noto Sans
|
||
/// CJK SC, which has `vpal` and `vkrn` (skipped without it; it isn't a document font, so it's loaded
|
||
/// here).
|
||
#[test]
|
||
fn proportional_metrics_kern_the_glyphs_they_respace() {
|
||
let tags = |v: Vec<harfrust::Feature>| v.iter().map(|f| (f.tag.to_be_bytes(), f.value)).collect::<Vec<_>>();
|
||
let st = |pm: bool, kerning: Option<f64>| CharStyle { size: 20.0, proportional_metrics: pm, kerning, ..CharStyle::default() };
|
||
let h = crate::OtFeatures::default();
|
||
assert!(tags(h.resolve(&st(true, None))).contains(&(*b"apkn", 1)));
|
||
assert!(tags(h.resolve(&st(true, Some(0.0)))).contains(&(*b"apkn", 0)), "manual kerning: none");
|
||
assert!(!tags(h.resolve_fixed_width(&st(true, None))).iter().any(|(t, _)| t == b"apkn" || t == b"palt"));
|
||
let v = crate::OtFeatures { vertical: true, ..Default::default() };
|
||
let with = tags(v.resolve_vertical(&st(true, None), true, true));
|
||
assert!(with.contains(&(*b"vpal", 1)) && with.contains(&(*b"vkrn", 1)) && with.contains(&(*b"vapk", 1)), "{with:?}");
|
||
assert!(tags(v.resolve_vertical(&st(true, Some(0.0)), true, true)).contains(&(*b"vkrn", 0)), "manual kerning: none");
|
||
assert!(tags(v.resolve_vertical(&st(true, None), false, false)).contains(&(*b"vkrn", 0)));
|
||
|
||
let Some(noto) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Noto Sans CJK SC") else {
|
||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||
return;
|
||
};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(noto.bytes.to_vec());
|
||
let lay = |pm: bool, kerning: Option<f64>| {
|
||
let s = CharStyle { font_family: "Noto Sans CJK SC".into(), ..st(pm, kerning) };
|
||
layout(&db, &vertical_styled("「カタカナ」とトップ、ヤッホー。", s)).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
|
||
};
|
||
let (auto, manual) = (lay(true, None), lay(true, Some(0.0)));
|
||
assert!(auto.iter().zip(&manual).any(|(a, b)| (a - b).abs() > 0.01), "kerned: {auto:?} {manual:?}");
|
||
let optical = {
|
||
let s = CharStyle { font_family: "Noto Sans CJK SC".into(), optical_kerning: true, ..st(true, None) };
|
||
layout(&db, &vertical_styled("「カタカナ」とトップ、ヤッホー。", s)).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
|
||
};
|
||
assert!(auto.iter().zip(&optical).all(|(a, b)| (a - b).abs() < 0.01), "vertical Optical uses font metrics: {auto:?} {optical:?}");
|
||
assert!(lay(false, None).iter().all(|a| (a - 20.0).abs() < 0.01), "off: an em each, as before");
|
||
}
|
||
|
||
#[test]
|
||
fn vertical_optical_to_auto_keeps_the_fonts_pair() {
|
||
use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(pair_kerning_font().unwrap());
|
||
let style = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() };
|
||
let positions =
|
||
|t: &TextObject| layout(&db, t).glyphs.iter().map(|g| (g.advance, g.origin, g.xf.as_coeffs()[4], g.xf.as_coeffs()[5])).collect::<Vec<_>>();
|
||
let auto = vertical_styled("AV", style.clone());
|
||
let mut mixed = auto.clone();
|
||
edit::style_range(&mut mixed.runs, 0, 1, |s| s.optical_kerning = true);
|
||
let mut unkerned = auto.clone();
|
||
edit::style_range(&mut unkerned.runs, 0, 1, |s| s.kerning = Some(0.0));
|
||
let expected = positions(&auto);
|
||
assert_eq!(expected.len(), 2);
|
||
assert_ne!(expected, positions(&unkerned), "the font's A-V pair changes vertical positions");
|
||
assert_eq!(positions(&mixed), expected, "vertical Optical keeps the A-V font pair across the style boundary");
|
||
}
|