vectorcraft/crates/text/src/tests_vertical.rs

585 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 super::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, Justify, TextKind};
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");
}
#[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");
}
/// `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);
}
#[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}");
}
/// 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 (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})");
}
}
/// 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}");
}
}
/// 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));
}