Files
vectorcraft/crates/text/src/tests_typo.rs
T
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:40:14 +07:00

1342 lines
67 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.
//! Typography tests: rich-text editing, caret navigation, selection, area flow, composer,
//! hyphenation, OpenType features, area type options.
use super::*;
use kurbo::{Affine, Circle, Shape};
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun};
use vectorcraft_geom::PathData;
fn db() -> &'static FontDb {
FontDb::global()
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
fn run(text: &str, size: f64) -> TextRun {
TextRun { text: text.into(), style: style(size), inline: None }
}
fn area_path(text: &str, st: CharStyle, frame: &BezPath, justify: Justify) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, st);
t.kind = TextKind::Area { frame: PathData::from_bezpath(frame) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject {
area_path(text, st, &frame.to_path(0.1), justify)
}
const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \
The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing, and adjusting the space \
between pairs of letters. Designers balance hyphenation against justification to produce even texture across the paragraph.";
// ---------- run editing ----------
#[test]
fn range_style_splits_runs() {
let mut runs = vec![run("Hello brave world", 12.0)];
edit::style_range(&mut runs, 6, 11, |s| s.size = 24.0);
assert_eq!(runs.len(), 3);
assert_eq!((runs[0].text.as_str(), runs[1].text.as_str(), runs[2].text.as_str()), ("Hello ", "brave", " world"));
assert_eq!((runs[0].style.size, runs[1].style.size, runs[2].style.size), (12.0, 24.0, 12.0));
// Restyling back merges the runs again.
edit::style_range(&mut runs, 6, 11, |s| s.size = 12.0);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hello brave world");
}
#[test]
fn range_style_across_run_boundaries() {
let mut runs = vec![run("aaa", 10.0), run("bbb", 20.0), run("ccc", 30.0)];
edit::style_range(&mut runs, 2, 7, |s| s.tracking = 50.0);
let sizes: Vec<(String, f64, f64)> = runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.tracking)).collect();
assert_eq!(
sizes,
vec![("aa".into(), 10.0, 0.0), ("a".into(), 10.0, 50.0), ("bbb".into(), 20.0, 50.0), ("c".into(), 30.0, 50.0), ("cc".into(), 30.0, 0.0)]
);
// Reversed and out-of-range offsets are clamped.
edit::style_range(&mut runs, 100, 0, |s| s.tracking = 0.0);
assert!(runs.iter().all(|r| r.style.tracking == 0.0));
assert_eq!(runs.len(), 3);
}
#[test]
fn replace_range_takes_replaced_style_and_normalizes() {
let mut runs = vec![run("Hello ", 12.0), run("brave", 24.0), run(" world", 12.0)];
// Typing over the big word keeps its size.
let caret = edit::replace_range(&mut runs, 6, 11, "bold");
assert_eq!(caret, 10);
assert_eq!(runs[1].text, "bold");
assert_eq!(runs[1].style.size, 24.0);
// Inserting at a run end continues the preceding run.
let caret = edit::replace_range(&mut runs, 10, 10, "er");
assert_eq!((caret, runs[1].text.as_str()), (12, "bolder"));
// Deleting a whole run merges its neighbours.
edit::replace_range(&mut runs, 6, 12, "");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hello world");
// Deleting everything keeps one empty run with the style.
edit::replace_range(&mut runs, 0, 100, "");
assert_eq!(runs.len(), 1);
assert!(runs[0].text.is_empty());
assert_eq!(runs[0].style.size, 12.0);
}
/// #2119: deleting all of the text with no runs to insert (Backspace over a full selection, an IME
/// clearing its marked text) keeps the first replaced character's style, not the default one.
#[test]
fn replace_range_styled_with_nothing_keeps_the_style_of_emptied_text() {
let mut runs = vec![run("Chair", 36.0), run(" 123", 48.0)];
let caret = edit::replace_range_styled(&mut runs, 0, 9, &[]);
assert_eq!(caret, 0);
assert_eq!(runs.len(), 1);
assert!(runs[0].text.is_empty());
assert_eq!(runs[0].style.size, 36.0);
}
#[test]
fn replace_respects_char_boundaries_and_slices() {
let mut runs = vec![run("héllo", 12.0)];
// Offset 2 is inside 'é' (2 bytes): clamped back to the boundary.
edit::replace_range(&mut runs, 2, 3, "E");
assert_eq!(runs[0].text, "hEllo");
let mut runs = vec![run("ab", 10.0), run("cd", 20.0)];
let s = edit::slice_runs(&runs, 1, 3);
assert_eq!(s.len(), 2);
assert_eq!((s[0].text.as_str(), s[1].text.as_str()), ("b", "c"));
let caret = edit::replace_range_styled(&mut runs, 4, 4, &s);
assert_eq!(caret, 6);
assert_eq!(runs.iter().map(|r| r.text.as_str()).collect::<String>(), "abcdbc");
}
#[test]
fn word_and_paragraph_navigation() {
let s = "Hello, big world\nNext line";
assert_eq!(edit::next_word(s, 0), 5);
assert_eq!(edit::next_word(s, 5), 10);
assert_eq!(edit::prev_word(s, 10), 7);
assert_eq!(edit::prev_word(s, 7), 0);
assert_eq!(edit::word_at(s, 8), 7..10);
assert_eq!(edit::word_at(s, 10), 7..10, "at a word end");
assert_eq!(edit::word_at(s, 5), 0..5, "word end wins over punctuation");
assert_eq!(edit::word_at("x ,y", 2), 2..3, "punctuation alone");
assert_eq!(edit::word_at(s, 6), 6..7, "spaces");
assert_eq!(edit::paragraph_at(s, 3), 0..16);
assert_eq!(edit::paragraph_at(s, 20), 17..26);
assert_eq!(edit::next_char(s, 0), 1);
assert_eq!(edit::prev_char("é", 2), 0);
assert_eq!(edit::next_word(s, s.len()), s.len());
}
// ---------- caret navigation / selection ----------
#[test]
fn caret_up_down_keeps_column() {
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 200.0, 600.0), Justify::Left);
let l = layout(db(), &t);
assert!(l.lines.len() > 5);
let b = l.lines[2].start + 6;
let (top, _) = caret_position(&l, b);
let down = caret_vertical(&l, b, 1, top.x);
assert_eq!(l.line_of(down), 3);
let (t2, _) = caret_position(&l, down);
assert!((t2.x - top.x).abs() < 12.0, "{} vs {}", t2.x, top.x);
let up = caret_vertical(&l, down, -1, top.x);
assert_eq!(up, b);
assert_eq!(caret_vertical(&l, b, -10, top.x), 0);
assert_eq!(caret_vertical(&l, b, 100, top.x), COPY.len());
}
#[test]
fn home_end_on_wrapped_lines() {
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 200.0, 600.0), Justify::Left);
let l = layout(db(), &t);
let b = l.lines[1].start + 3;
assert_eq!(line_home(&l, b), l.lines[1].start);
let e = line_end_of(&l, b);
// Before the trailing space: the caret stays on line 1.
assert_eq!(l.line_of(e), 1);
assert_eq!(&COPY[e..e + 1], " ");
}
#[test]
fn selection_quads_cover_lines() {
let t = TextObject::point(Point::ZERO, "One two\nThree four", style(20.0));
let l = layout(db(), &t);
let q = selection_quads(&l, 4, 7);
assert_eq!(q.len(), 1);
let (a, _) = caret_position(&l, 4);
let (b, _) = caret_position(&l, 7);
assert!((q[0][0].x - a.x).abs() < 1e-6 && (q[0][1].x - b.x).abs() < 1e-6);
// Across the paragraph break: two quads.
let q = selection_quads(&l, 4, 12);
assert_eq!(q.len(), 2);
assert!(q[1][0].y > q[0][0].y);
assert!(selection_quads(&l, 3, 3).is_empty());
}
#[test]
fn hit_testing_picks_the_right_column() {
let text: String = COPY.repeat(3);
let opts = LayoutOptions { columns: 2, gutter: 20.0, ..Default::default() };
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 420.0, 200.0), Justify::Left);
let l = layout_with(db(), &t, &opts);
assert_eq!(l.frames.len(), 2);
let right: Vec<&LineInfo> = l.lines.iter().filter(|li| li.x0 >= 220.0 - 1e-6).collect();
assert!(!right.is_empty(), "text flowed into the second column");
let r0 = right[0];
let b = hit_byte(&l, Point::new(r0.x0 + 1.0, r0.baseline));
assert!(b >= r0.start && b <= r0.end, "{b} not in {}..{}", r0.start, r0.end);
for li in &l.lines {
assert!((li.x0 >= -1e-6 && li.x1 <= 200.0 + 1e-6) || (li.x0 >= 220.0 - 1e-6 && li.x1 <= 420.0 + 1e-6), "{li:?}");
}
}
// ---------- area flow ----------
#[test]
fn area_flow_inside_circle_keeps_glyphs_inside() {
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
let t = area_path(COPY, style(11.0), &circle, Justify::Left);
let l = layout(db(), &t);
assert!(l.lines.len() > 8);
let inside = |p: Point| (p - Point::new(150.0, 150.0)).hypot() <= 120.0 + 0.5;
for g in &l.glyphs {
let li = &l.lines[g.line];
for p in [
Point::new(g.origin.x, li.baseline - li.ascent),
Point::new(g.origin.x + g.advance, li.baseline - li.ascent),
Point::new(g.origin.x, li.baseline + li.descent),
Point::new(g.origin.x + g.advance, li.baseline + li.descent),
] {
if g.outline.elements().is_empty() {
continue;
}
assert!(inside(p), "glyph box corner {p:?} outside the circle (line {})", g.line);
}
}
// Lines near the middle are wider than lines near the top.
let w = |i: usize| l.lines[i].avail.1 - l.lines[i].avail.0;
assert!(w(l.lines.len() / 2) > w(0));
}
/// In a frame that is narrow at the top, a word that doesn't fit the top lines moves down to the
/// first line wide enough for it (the lines above stay empty), with either composer. Only a word
/// wider than every line is broken.
#[test]
fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() {
let triangle = |base: f64, height: f64| {
let mut p = BezPath::new();
p.move_to((base / 2.0, 0.0));
p.line_to((base, height));
p.line_to((0.0, height));
p.close_path();
p
};
let text = "Wonderful extraordinary typesetting";
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = area_path(text, style(12.0), &triangle(200.0, 200.0), Justify::Left);
t.para.composer = composer;
let l = layout(db(), &t);
assert!(!l.overflow, "{composer:?}");
let lines: Vec<&str> = l.lines.iter().map(|li| text[li.start..li.end].trim_end()).collect();
// The top lines are wide enough for "Wo" but not for "Wonderful": it starts lower.
assert_eq!(lines.first(), Some(&"Wonderful"), "{composer:?}: {lines:?}");
for li in &lines {
assert!(li.split(' ').all(|w| text.split(' ').any(|word| word == w)), "{composer:?}: {lines:?}");
}
}
// No line of this triangle is as wide as the word: it is still broken, and every letter is set.
let word = "Supercalifragilisticexpialidocious";
let l = layout(db(), &area_path(word, style(12.0), &triangle(60.0, 400.0), Justify::Left));
assert!(!l.overflow && l.lines.len() > 1, "{:?}", line_places(&l));
}
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
/// takes more than 20 s.
fn layout_soon(t: &TextObject) -> TextLayout {
let (tx, rx) = std::sync::mpsc::channel();
let (t, text) = (t.clone(), t.plain_text());
std::thread::spawn(move || tx.send(layout(db(), &t)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}"))
}
/// Each line's baseline, left and right edges, and byte range.
fn line_places(l: &TextLayout) -> Vec<(f64, f64, f64, usize, usize)> {
l.lines.iter().map(|li| (li.baseline, li.x0, li.x1, li.start, li.end)).collect()
}
/// A frame or text wrap shape with a coordinate beyond a billion points either way, or one that
/// isn't finite, is left out: the frame holds no text, and the wrap shape is ignored.
#[test]
fn frames_and_wrap_shapes_beyond_a_billion_points_are_left_out() {
// A box from x0 to x1 and from 0 to `height` whose top edge has a control point at x = `far`,
// on the edge's own line.
let boxed = |x0: f64, x1: f64, height: f64, far: f64| {
let mut p = BezPath::new();
p.move_to((x0, 0.0));
p.curve_to((x0, 0.0), (far, 0.0), (x1, 0.0));
p.line_to((x1, height));
p.line_to((x0, height));
p.close_path();
p
};
let rectangle = layout(db(), &area("Hi there", style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
let within = layout_soon(&area_path("Hi there", style(12.0), &boxed(0.0, 100.0, 40.0, 1e9), Justify::Left));
assert_eq!(line_places(&within), line_places(&rectangle));
let mut beyond = [8e115, 2e9, f64::INFINITY].map(|far| boxed(0.0, 100.0, 40.0, far)).to_vec();
beyond.push(Rect::new(0.0, 0.0, 100.0, f64::NAN).to_path(0.1));
for frame in &beyond {
let l = layout_soon(&area_path("Hi there", style(12.0), frame, Justify::Left));
assert!(l.lines.is_empty() && l.glyphs.is_empty() && l.overflow, "{frame:?}");
}
// Text wrapped around the right half of a 300 by 200 frame.
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
let unwrapped = layout(db(), &t);
let mut wrapped = |path: BezPath| {
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&path), wrap: Default::default() }];
layout_soon(&t)
};
let half = wrapped(Rect::new(150.0, 0.0, 300.0, 200.0).to_path(0.1));
assert_ne!(line_places(&half), line_places(&unwrapped));
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, 1e9))), line_places(&half));
for far in [8e115, 2e9, f64::INFINITY] {
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, far))), line_places(&unwrapped), "{far}");
}
assert_eq!(line_places(&wrapped(Rect::new(150.0, 0.0, 300.0, f64::NAN).to_path(0.1))), line_places(&unwrapped));
}
/// Layout steps a line down a frame (or a row or column of one) at most 100,000 times to find room
/// for it: in a frame taller than 100,000 leads, a step is a 100,000th of the frame's height.
#[test]
fn tall_frames_are_searched_for_room_in_at_most_100000_steps() {
// A triangle from y = -1e9 to 1e9, `base` wide at the bottom.
let triangle = |base: f64| {
let mut p = BezPath::new();
p.move_to((0.0, -1e9));
p.line_to((base / 2.0, 1e9));
p.line_to((-base / 2.0, 1e9));
p.close_path();
p
};
// No line fits in this triangle: it is at most 0.5 pt wide, and a line needs a span wider than
// 1 pt or its ascent, whichever is more. A lead of 0.5 pt type counts as 1 pt.
let none = layout_soon(&area_path("Hi", style(0.5), &triangle(0.5), Justify::Left));
assert!(none.lines.is_empty() && none.overflow);
// This one is wide enough for 12 pt type from about y = -7.6e8 down.
let some = layout_soon(&area_path("Hi", style(12.0), &triangle(100.0), Justify::Left));
assert!(!some.overflow && some.lines.len() == 1);
// At the height of the line's top, the line's left and right edges lie inside the triangle.
let line = &some.lines[0];
let half_width = (line.baseline - line.ascent + 1e9) / 2e9 * 50.0;
assert!(line.x0 >= -half_width - 1e-6 && line.x1 <= half_width + 1e-6, "{line:?}");
}
/// A paragraph whose Space Before puts it 1e300 pt above a triangle frame that comes to a point at
/// its top overflows. At that height a step of one lead leaves the baseline where it is, and
/// layout takes at most 100,000 such steps.
#[test]
fn a_paragraph_spaced_far_above_its_frame_overflows() {
let mut triangle = BezPath::new();
triangle.move_to((0.0, 0.0));
triangle.line_to((500.0, 1000.0));
triangle.line_to((-500.0, 1000.0));
triangle.close_path();
let mut t = area_path("Hi\nthere", style(12.0), &triangle, Justify::Left);
let far = vectorcraft_doc::ParaStyle { space_before: -1e300, ..Default::default() };
t.set_paragraph_styles(vec![t.para.clone(), far]);
let l = layout_soon(&t);
assert!(l.overflow && l.lines.len() == 1, "{:?}", line_places(&l));
}
/// Type on a path whose path has a coordinate beyond a billion points either way, or one that isn't
/// finite, holds no text.
#[test]
fn type_on_a_path_beyond_a_billion_points_holds_no_text() {
// An S-shaped curve from (0, 0) to (3s, 0) with control points (s, s) and (2s, -s).
let on = |s: f64| {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.curve_to((s, s), (2.0 * s, -s), (3.0 * s, 0.0));
let mut t = TextObject::point(Point::ZERO, "On a path", style(8.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&p), start: 0.0, end: None };
t
};
let within = layout_soon(&on(1e9 / 3.0));
assert!(!within.overflow && within.glyphs.len() == 9);
for s in [1e115, 1e9, f64::INFINITY] {
let l = layout_soon(&on(s));
assert!(l.glyphs.is_empty() && l.overflow, "{s}");
}
}
#[test]
fn justify_all_line_widths_equal() {
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 240.0, 800.0), Justify::JustifyAll);
let l = layout(db(), &t);
assert!(l.lines.len() > 4);
for li in &l.lines {
assert!((li.x1 - li.x0 - 240.0).abs() < 1e-6, "{li:?}");
}
}
#[test]
fn composer_lines_never_exceed_frame() {
for (hy, composer) in [(false, Composer::EveryLine), (true, Composer::EveryLine), (false, Composer::SingleLine), (true, Composer::SingleLine)] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 180.0, 2000.0), Justify::JustifyLeft);
t.para.hyphenate = hy;
let l = layout_with(db(), &t, &LayoutOptions { composer: Some(composer), ..Default::default() });
assert!(l.lines.len() > 10);
for (i, li) in l.lines.iter().enumerate() {
assert!(li.x1 - li.x0 <= 180.0 + 1e-6, "{composer:?} hy={hy} line {i} too wide: {li:?}");
assert!(li.x0 >= -1e-6);
if i + 1 < l.lines.len() {
assert!((li.x1 - li.x0 - 180.0).abs() < 1e-6, "{composer:?} hy={hy} justified line {i}: {li:?}");
}
}
let clusters: std::collections::BTreeSet<(usize, usize)> = l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len)).collect();
assert_eq!(clusters.iter().map(|c| c.1).sum::<usize>(), COPY.len() * 2, "every character placed");
}
}
/// Every-line composition spreads the spacing more evenly than greedy breaking.
#[test]
fn every_line_composer_is_more_even() {
let t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 190.0, 2000.0), Justify::JustifyLeft);
let spread = |c: Composer| {
let l = layout_with(db(), &t, &LayoutOptions { composer: Some(c), ..Default::default() });
// Worst word-space stretch (space glyph advance) over all justified lines.
let mut worst: f64 = 0.0;
for li in &l.lines[..l.lines.len() - 1] {
for g in &l.glyphs[li.glyph_start..li.glyph_end] {
if COPY.as_bytes().get(g.byte % COPY.len()) == Some(&b' ') {
worst = worst.max(g.advance);
}
}
}
worst
};
let (kp, greedy) = (spread(Composer::EveryLine), spread(Composer::SingleLine));
assert!(kp <= greedy + 1e-6, "every-line worst space {kp} vs greedy {greedy}");
}
/// Burasagari (new type's Standard) leaves Latin paragraphs to the every-line composer: only a
/// paragraph with a Japanese comma or full stop is composed line by line.
#[test]
fn burasagari_keeps_the_every_line_composer_for_latin_text() {
// A measure where the two composers break the copy differently.
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 300.0, 4000.0), Justify::JustifyLeft);
let ends = |t: &TextObject, c: Composer| {
let l = layout_with(db(), t, &LayoutOptions { composer: Some(c), ..Default::default() });
l.lines.iter().map(|li| li.glyph_end).collect::<Vec<_>>()
};
let every_line = ends(&t, Composer::EveryLine);
assert_ne!(every_line, ends(&t, Composer::SingleLine));
t.para.burasagari = vectorcraft_doc::Burasagari::Standard;
assert_eq!(ends(&t, Composer::EveryLine), every_line);
}
/// Width of `s` set on one line.
fn natural_width(s: &str, size: f64) -> f64 {
let l = layout(db(), &TextObject::point(Point::ZERO, s, style(size)));
l.lines[0].x1 - l.lines[0].x0
}
/// The text of each line.
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
let text = t.plain_text();
l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect()
}
/// Ragged text: Every-line breaks before a word that would fit, to avoid leaving a lone short
/// word on the last line (single-line keeps filling the first line). Auto alignment is ragged
/// too, in either paragraph direction: set right to left, the lines break the same and stay
/// flush right.
#[test]
fn every_line_composer_balances_ragged_lines() {
use vectorcraft_doc::ParaDirection;
const S: &str = "Destroy all creatures with toughness X or less.";
let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0;
assert!(w < natural_width(S, 12.0), "the sentence needs two lines");
let cases = [
(Justify::Left, None),
(Justify::Center, None),
(Justify::Right, None),
(Justify::Auto, None),
(Justify::Auto, Some(ParaDirection::RightToLeft)),
(Justify::Left, Some(ParaDirection::RightToLeft)),
];
for (justify, direction) in cases {
let mut t = area(S, style(12.0), Rect::new(0.0, 0.0, w, 400.0), justify);
t.para.direction = direction;
t.para.composer = Composer::SingleLine;
let greedy = line_texts(&t, &layout(db(), &t));
assert_eq!(greedy, ["Destroy all creatures with toughness X or", "less."], "{justify:?} {direction:?}");
t.para.composer = Composer::EveryLine;
let every = line_texts(&t, &layout(db(), &t));
assert_eq!(every, ["Destroy all creatures with toughness X", "or less."], "{justify:?} {direction:?}");
// Burasagari (new type's Standard) leaves Latin text to the every-line composer.
t.para.burasagari = vectorcraft_doc::Burasagari::Standard;
assert_eq!(line_texts(&t, &layout(db(), &t)), every, "{justify:?} {direction:?} with burasagari");
if (justify, direction) == (Justify::Auto, Some(ParaDirection::RightToLeft)) {
let l = layout(db(), &t);
assert!(l.lines.iter().all(|li| li.rtl && (li.x1 - w).abs() < 1e-6), "right to left Auto is flush right: {:?}", l.lines);
}
// The options override wins over the object's setting.
let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() });
assert_eq!(line_texts(&t, &forced), greedy);
}
}
/// Every-line evens the rag of a long ragged paragraph: the spread of the line ends is no worse
/// than greedy breaking, every line fits, and nothing is lost.
#[test]
fn every_line_composer_evens_the_rag() {
for (width, hy) in [(150.0, false), (190.0, false), (240.0, true), (310.0, false)] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), Justify::Left);
t.para.hyphenate = hy;
let rag = |c: Composer| {
let mut t = t.clone();
t.para.composer = c;
let l = layout(db(), &t);
assert!(!l.overflow);
for li in &l.lines {
assert!(li.x1 - li.x0 <= width + 1e-6, "{c:?} {width}: line too wide {li:?}");
}
let clusters: std::collections::BTreeSet<(usize, usize)> = l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len)).collect();
assert_eq!(clusters.iter().map(|c| c.1).sum::<usize>(), COPY.len() * 2, "every character placed");
// Leftover space of all lines but the last: its sum of squares (how ragged) and the
// sum of squared differences between neighbours (how jagged).
let slack: Vec<f64> = l.lines[..l.lines.len() - 1].iter().map(|li| width - (li.x1 - li.x0)).collect();
let jag: f64 = slack.windows(2).map(|p| (p[0] - p[1]).powi(2)).sum();
(slack.iter().map(|s| s * s).sum::<f64>(), jag, l.lines.len())
};
let ((kp, kp_jag, kp_lines), (greedy, greedy_jag, greedy_lines)) = (rag(Composer::EveryLine), rag(Composer::SingleLine));
assert!(kp <= greedy + 1e-6, "{width}: every-line rag {kp} vs greedy {greedy}");
assert!(kp_jag <= greedy_jag + 1e-6, "{width}: every-line jag {kp_jag} vs greedy {greedy_jag}");
assert!(kp_lines <= greedy_lines + 1, "{width}: {kp_lines} lines vs {greedy_lines}");
}
}
/// Single-line breaking is the greedy breaker: each line takes as many words as fit.
#[test]
fn single_line_composer_is_greedy() {
let width = 190.0;
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, width, 2000.0), Justify::Left);
t.para.composer = Composer::SingleLine;
let l = layout(db(), &t);
let lines = line_texts(&t, &l);
for pair in lines.windows(2) {
let next_word = pair[1].split(' ').next().unwrap();
assert!(natural_width(&format!("{} {next_word}", pair[0]), 12.0) > width - 1e-6, "{pair:?}");
}
}
#[test]
fn hyphenation_rules() {
assert_eq!(hyphen::hyphenate_word("typography"), "typo-gra-phy");
assert_eq!(hyphen::hyphenate_word("happen"), "hap-pen");
assert_eq!(hyphen::hyphenate_word("hyphenation"), "hyphe-na-tion");
assert!(hyphen::hyphen_points("short").is_empty());
assert!(hyphen::hyphen_points("NASAJPL").is_empty(), "all caps stay whole");
assert!(hyphen::hyphen_points("abc123def").is_empty());
for w in ["justification", "arrangement", "paragraph", "legible"] {
let h = hyphen::hyphenate_word(w);
for part in h.split('-') {
assert!(part.chars().count() >= 2, "{h}");
}
let pts = hyphen::hyphen_points(w);
assert!(pts.iter().all(|&p| p >= hyphen::MIN_BEFORE && p <= w.chars().count() - hyphen::MIN_AFTER), "{w}: {pts:?}");
}
}
#[test]
fn hyphenated_layout_adds_hyphens() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left);
let plain = layout(db(), &t);
t.para.hyphenate = true;
let hy = layout(db(), &t);
let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count();
assert_eq!(hyphens(&plain), 0);
assert!(hyphens(&hy) > 0, "some words hyphenated");
for li in &hy.lines {
assert!(li.x1 - li.x0 <= 120.0 + 1e-6, "{li:?}");
}
// Hyphenation fills lines better: no more lines than without it.
assert!(hy.lines.len() <= plain.lines.len());
// The hyphen glyph is the last glyph of its line and has an outline.
for g in hy.glyphs.iter().filter(|g| g.len == 0) {
let li = &hy.lines[g.line];
assert_eq!(li.glyph_end - 1, hy.glyphs.iter().position(|x| std::ptr::eq(x, g)).unwrap());
assert!(!g.outline.elements().is_empty());
}
}
/// Preferences › Hyphenation › Exceptions (#394): listing a word stops the layout from breaking it.
#[test]
fn hyphenation_exceptions_keep_listed_words_whole_in_layout() {
// Same copy as `hyphenated_layout_adds_hyphens` (known to produce soft hyphens at 120pt width).
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left);
t.para.hyphenate = true;
let with_breaks = layout(db(), &t);
let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count();
assert!(hyphens(&with_breaks) > 0, "pattern produces soft hyphens");
hyphen::with_hyphenation_exceptions_for_test(
"extraordinarily, comprehensive, typographical, considerations, notwithstanding, everything",
|| {
let blocked = layout(db(), &t);
assert_eq!(hyphens(&blocked), 0, "every long word listed: no soft hyphens");
assert!(blocked.lines.len() >= with_breaks.lines.len());
},
);
}
/// The width of the frame [`the_language`] makes: "The lan-" and "language" fit, and "The
/// language" does not.
fn the_language_width() -> f64 {
natural_width("The lan-", 12.0).max(natural_width("Language", 12.0)) + 1.0
}
/// "The language" (or "The Language") in a frame that holds "The lan-" and not the whole of it,
/// hyphenated.
fn the_language(word: &str, composer: Composer) -> TextObject {
let text = format!("The {word}");
let w = the_language_width();
assert!(w < natural_width(&text, 12.0));
let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, w, 400.0), Justify::Left);
t.para.hyphenate = true;
t.para.composer = composer;
t
}
/// Paragraph panel menu › Hyphenation… › Words Longer Than: an 8-letter word that breaks with
/// the default (longer than 5 letters) stays whole when only words longer than 10 letters may
/// break.
#[test]
fn hyphenation_words_longer_than_keeps_shorter_words_whole() {
let mut t = the_language("language", Composer::SingleLine);
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
t.para.hyphenation.words_longer_than = 10;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
let mut t = the_language("language", Composer::EveryLine);
t.para.hyphenation.words_longer_than = 10;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
}
/// Hyphenation… › After First and Before Last: the fewest letters on each side of the hyphen.
#[test]
fn hyphenation_after_first_and_before_last_limit_the_break() {
let mut t = the_language("language", Composer::SingleLine);
t.para.hyphenation.after_first = 4;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
t.para.hyphenation.after_first = 3;
t.para.hyphenation.before_last = 6;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
}
/// Hyphenation… › Hyphenate Capitalized Words: when it is off, a word that starts with a capital stays whole.
#[test]
fn hyphenation_leaves_capitalized_words_whole_when_off() {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = the_language("Language", composer);
if composer == Composer::SingleLine {
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The Lan", "guage"]);
}
t.para.hyphenation.capitalized = false;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "Language"], "{composer:?}");
}
}
/// Hyphenation… › Hyphenation Zone: with the Single-line Composer, a line whose text before the
/// next word already ends within the zone breaks before that word; the Every-line Composer
/// ignores the zone.
#[test]
fn hyphenation_zone_breaks_before_the_word_with_the_single_line_composer() {
let mut t = the_language("language", Composer::SingleLine);
let gap = the_language_width() - natural_width("The", 12.0);
t.para.hyphenation.zone = gap - 1.0;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
t.para.hyphenation.zone = gap + 1.0;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
let mut every = the_language("language", Composer::EveryLine);
let without = line_texts(&every, &layout(db(), &every));
every.para.hyphenation.zone = gap + 1.0;
assert_eq!(line_texts(&every, &layout(db(), &every)), without);
}
/// The default Hyphenation options keep the line breaks of hyphenated text: the copy twice over
/// at three widths, ragged and justified, with either composer. The constant is an FNV-1a hash of
/// each line's end and whether it ends with a hyphen. It changed once since: justified lines
/// follow the default Justification options (word spacing up to 133%, where the composer used
/// to stretch spaces to 150%), and the every-line composer no longer gives up on loose lines.
#[test]
fn default_hyphenation_options_keep_the_line_breaks() {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
for b in v.to_le_bytes() {
hash = (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
};
for width in [150.0, 190.0, 240.0] {
for justify in [Justify::Left, Justify::JustifyLeft] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let l = layout(db(), &t);
for li in &l.lines {
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
mix(li.end as u64 * 2 + u64::from(hyphenated));
}
}
}
}
assert_eq!(hash, 4_188_642_633_634_327_633);
}
/// The longest run of consecutive lines of `l` that end with a hyphen.
fn hyphen_run(l: &TextLayout) -> usize {
let mut run = 0;
let mut longest = 0;
for li in &l.lines {
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
run = if hyphenated { run + 1 } else { 0 };
longest = longest.max(run);
}
longest
}
/// Hyphenation… › Hyphen Limit: at most that many lines in a row end with a hyphen, with either
/// composer; 0 sets no limit.
#[test]
fn hyphen_limit_caps_consecutive_hyphenated_lines() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything. ".repeat(3);
for composer in [Composer::SingleLine, Composer::EveryLine] {
for justify in [Justify::Left, Justify::JustifyLeft] {
let mut t = area(text.trim_end(), style(14.0), Rect::new(0.0, 0.0, 120.0, 2000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let free = layout(db(), &t);
assert!(hyphen_run(&free) >= 2, "{composer:?} {justify:?}: lines in a row end with hyphens without a limit");
for limit in [1, 2] {
t.para.hyphenation.hyphen_limit = limit;
let l = layout(db(), &t);
assert!(!l.overflow);
assert!(hyphen_run(&l) >= 1, "{composer:?} {justify:?} {limit}: still hyphenated");
assert!(hyphen_run(&l) <= limit as usize, "{composer:?} {justify:?} {limit}: {}", hyphen_run(&l));
}
}
}
}
#[test]
fn soft_hyphen_is_invisible_mid_line() {
let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0)));
let sh = l.glyphs.iter().find(|g| g.byte == 2).unwrap();
assert!(sh.outline.elements().is_empty());
assert_eq!(sh.advance, 0.0);
}
// ---------- area type options ----------
#[test]
fn inset_and_first_baseline_options() {
let frame = Rect::new(0.0, 0.0, 300.0, 300.0);
let t = area(COPY, style(12.0), frame, Justify::Left);
let base = layout(db(), &t);
let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, ..Default::default() });
for li in &inset.lines {
assert!(li.x0 >= 10.0 - 1e-6 && li.x1 <= 290.0 + 1e-6);
}
assert!((inset.lines[0].baseline - base.lines[0].baseline - 10.0).abs() < 1e-6);
let lead = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::Leading, ..Default::default() });
assert!((lead.lines[0].baseline - 14.4).abs() < 1e-6, "{}", lead.lines[0].baseline);
let fixed = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::Fixed, first_baseline_min: 30.0, ..Default::default() });
assert!((fixed.lines[0].baseline - 30.0).abs() < 1e-6);
let cap = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::CapHeight, ..Default::default() });
assert!(cap.lines[0].baseline < base.lines[0].baseline && cap.lines[0].baseline > 5.0);
}
/// `LineInfo::first_baseline`: each line that starts a column has its baseline that far below the
/// column's top inside the inset, for every first baseline option, with and without a minimum and
/// an inset, in both leading models, and for lines of several sizes (each line in one size).
#[test]
fn lines_starting_a_column_sit_at_their_first_baseline() {
let paras = [
("A heading in large type\n", 24.0),
("Body copy in a smaller size that runs over two or three lines of a column.\n", 12.0),
("A middle size\n", 18.0),
];
let runs: Vec<TextRun> = paras.repeat(4).into_iter().map(|(text, size)| run(text, size)).collect();
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
let mut other_sizes = 0;
for model in [vectorcraft_doc::LeadingModel::RomanBaseline, vectorcraft_doc::LeadingModel::EmBoxTop] {
for first_baseline in [FirstBaseline::Ascent, FirstBaseline::CapHeight, FirstBaseline::XHeight, FirstBaseline::Leading, FirstBaseline::Fixed]
{
for (first_baseline_min, inset) in [(0.0, 0.0), (18.0, 6.0)] {
for height in [100.0, 120.0, 140.0] {
let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, 420.0, height), Justify::Left);
t.runs = runs.clone();
t.para.leading_model = model;
let opts = LayoutOptions { columns: 3, gutter: 10.0, first_baseline, first_baseline_min, inset, ..Default::default() };
let l = layout_with(db(), &t, &opts);
let starts: Vec<&LineInfo> =
l.lines.iter().enumerate().filter(|(i, li)| *i == 0 || l.lines[i - 1].region != li.region).map(|(_, li)| li).collect();
assert_eq!(starts.len(), 3, "{model:?} {first_baseline:?} {height}");
other_sizes += starts.iter().filter(|li| li.ascent != starts[0].ascent).count();
for li in starts {
assert!(
(li.baseline - (inset + li.first_baseline)).abs() < 1e-9,
"{model:?} {first_baseline:?} {first_baseline_min} {height}: {li:?}"
);
}
}
}
}
}
assert!(other_sizes >= 20, "lines of another size than the first start {other_sizes} columns");
}
#[test]
fn rows_and_columns_flow_in_order() {
let text = COPY.repeat(2);
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 400.0, 300.0), Justify::Left);
let l = layout_with(db(), &t, &LayoutOptions { rows: 2, columns: 2, gutter: 10.0, ..Default::default() });
assert_eq!(l.frames.len(), 4);
// Text order: column 1 (top then bottom cell), then column 2.
let first_col2 = l.lines.iter().position(|li| li.x0 > 200.0).expect("reaches column 2");
assert!(l.lines[..first_col2].iter().any(|li| li.baseline > 155.0), "fills the lower cell of column 1 first");
for w in l.lines.windows(2) {
assert!(w[1].start >= w[0].start);
}
}
/// Layout clamps area type's rows and columns to 100 each: 2^60 rows or columns with 100 of the
/// other, 2^32 of each, and `usize::MAX` of each lay out as 100 rows and 100 columns.
#[test]
fn area_type_lays_out_in_at_most_100_rows_and_100_columns() {
// At 100 by 100, a cell is about 98 by 28 pt.
let with = |rows: usize, columns: usize| {
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 10_000.0, 3_000.0), Justify::Left);
t.area.rows = rows;
t.area.columns = columns;
t.area.gutter = 2.0;
layout_soon(&t)
};
let most = with(100, 100);
assert_eq!(most.frames.len(), 10_000);
assert!(!most.overflow && most.lines.len() > 10, "{:?}", line_places(&most));
for (rows, columns) in [(1 << 60, 100), (100, 1 << 60), (1 << 32, 1 << 32), (usize::MAX, usize::MAX)] {
let l = with(rows, columns);
assert_eq!((&l.frames, line_places(&l)), (&most.frames, line_places(&most)), "{rows} by {columns}");
}
}
// ---------- vertical alignment ----------
fn valign(a: VerticalAlign) -> LayoutOptions {
LayoutOptions { vertical_align: a, ..Default::default() }
}
/// (space above the first line's ascent, space below the last line's descent) in `0..h`.
fn block_space(l: &TextLayout, h: f64) -> (f64, f64) {
let (first, last) = (l.lines.first().unwrap(), l.lines.last().unwrap());
(first.baseline - first.ascent, h - (last.baseline + last.descent))
}
#[test]
fn vertical_align_center_bottom_and_justify_in_a_rectangle() {
let t = area("First line\nSecond line\nThird line", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
let top = layout(db(), &t);
let (above, below) = block_space(&top, 300.0);
assert!(above.abs() < 1e-6 && below > 200.0);
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
let (a, b) = block_space(&center, 300.0);
assert!((a - b).abs() < 1e-6 && a > 100.0, "{a} {b}");
// The model field reaches layout().
let mut tc = t.clone();
tc.area.vertical_align = VerticalAlign::Center;
assert!((layout(db(), &tc).lines[0].baseline - center.lines[0].baseline).abs() < 1e-9);
// Lines, glyph origins, outlines and transforms move together.
let d = center.lines[0].baseline - top.lines[0].baseline;
for (g, h) in center.glyphs.iter().zip(&top.glyphs) {
assert!((g.origin.y - h.origin.y - d).abs() < 1e-9);
assert!((g.xf.translation().y - h.xf.translation().y - d).abs() < 1e-9);
if !h.outline.elements().is_empty() {
assert!((g.outline.bounding_box().y0 - h.outline.bounding_box().y0 - d).abs() < 1e-6);
}
}
assert!(center.bounds.y0 > top.bounds.y0 + 100.0);
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
let (a, b) = block_space(&bottom, 300.0);
assert!(b.abs() < 1e-6 && a > 200.0);
// Justify: the first line stays, the last one reaches the bottom, gaps are equal.
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
let (a, b) = block_space(&just, 300.0);
assert!(a.abs() < 1e-6 && b.abs() < 1e-6, "{a} {b}");
let gaps: Vec<f64> = just.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect();
assert!(gaps.len() == 2 && (gaps[0] - gaps[1]).abs() < 1e-6 && gaps[0] > 100.0);
// A single line justifies to the top.
let one = area("Alone", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
assert_eq!(layout_with(db(), &one, &valign(VerticalAlign::Justify)).lines[0].baseline, layout(db(), &one).lines[0].baseline);
// Inset: the space is measured inside it.
let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, vertical_align: VerticalAlign::Bottom, ..Default::default() });
assert!(block_space(&inset, 290.0).1.abs() < 1e-6);
}
#[test]
fn vertical_align_leaves_overflowing_text_where_it_is() {
let text = COPY.repeat(4);
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
let top = layout(db(), &t);
assert!(top.overflow);
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
let l = layout_with(db(), &t, &valign(a));
assert!(l.overflow);
assert_eq!(l.lines.len(), top.lines.len());
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
// A full cell has less than a line of space left: the lines barely move.
assert!(l.lines[0].baseline - top.lines[0].baseline < top.lines[0].ascent + top.lines[0].descent);
assert!(block_space(&l, 100.0).1 >= -1e-6);
}
}
#[test]
fn vertical_align_acts_on_each_column_on_its_own() {
// Enough text for column 1 and a few lines of column 2.
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 420.0, 120.0), Justify::Left);
let opts = |a| LayoutOptions { columns: 2, gutter: 20.0, vertical_align: a, ..Default::default() };
let top = layout_with(db(), &t, &opts(VerticalAlign::Top));
let col = |l: &TextLayout, c: usize| l.lines.iter().filter(|li| li.region == c).cloned().collect::<Vec<_>>();
assert!(!top.overflow && !col(&top, 0).is_empty() && !col(&top, 1).is_empty());
assert!(col(&top, 1).iter().all(|li| li.x0 >= 220.0 - 1e-6));
let bottom = layout_with(db(), &t, &opts(VerticalAlign::Bottom));
for c in 0..2 {
let last = col(&bottom, c).last().cloned().unwrap();
assert!((last.baseline + last.descent - 120.0).abs() < 1e-6, "column {c} ends at the bottom");
}
let shift = |c: usize| col(&bottom, c)[0].baseline - col(&top, c)[0].baseline;
assert!(shift(1) > shift(0) + 20.0, "the short column moves further: {} {}", shift(0), shift(1));
}
#[test]
fn vertical_align_in_a_circle_reflows_and_stays_inside() {
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
let t = area_path("Centred text in a round frame flows again at its new height.", style(12.0), &circle, Justify::Left);
let top = layout(db(), &t);
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
let l = layout_with(db(), &t, &valign(a));
assert!(!l.overflow, "{a:?}");
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
let moved = if a == VerticalAlign::Justify {
l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline
} else {
l.lines[0].baseline - top.lines[0].baseline
};
assert!(moved > 20.0, "{a:?} {moved}");
for g in l.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) {
let b = g.outline.bounding_box();
for p in [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)] {
assert!((p - Point::new(150.0, 150.0)).hypot() <= 121.0, "{a:?} {p:?}");
}
}
}
let c = layout_with(db(), &t, &valign(VerticalAlign::Center));
let (above, below) = block_space(&c, 270.0);
assert!((above - 30.0 - below).abs() < 10.0, "roughly centred: {above} {below}");
}
#[test]
fn vertical_align_with_text_wrap_flows_again_around_the_object() {
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 400.0), Justify::Left);
let obstacle = Rect::new(100.0, 250.0, 200.0, 330.0).to_path(0.1);
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&obstacle), wrap: Default::default() }];
let top = layout(db(), &t);
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
assert!(!bottom.overflow);
assert_eq!(bottom.lines.last().unwrap().end, top.lines.last().unwrap().end);
let last = bottom.lines.last().unwrap();
assert!(400.0 - (last.baseline + last.descent) < 15.0, "near the bottom: {}", last.baseline);
// Lines beside the object leave room for it (offset 6 pt).
for li in bottom.lines.iter().filter(|li| li.baseline + li.descent > 244.0 && li.baseline - li.ascent < 336.0) {
assert!(li.x1 <= 94.0 + 1e-6 || li.x0 >= 206.0 - 1e-6, "{li:?}");
}
}
#[test]
fn vertical_align_on_vertical_type_moves_columns_along_the_block_axis() {
// Vertical type: the block axis runs right to left, so "bottom" is the frame's left edge.
let mut t = area("Tate\nYoko", style(20.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
t.vertical = true;
let top = layout(db(), &t);
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
assert!(top.bounds.x1 > 190.0 && top.bounds.x0 > 100.0, "{:?}", top.bounds);
assert!(bottom.bounds.x0.abs() < 0.5, "{:?}", bottom.bounds);
assert!((center.bounds.x0 - (200.0 - center.bounds.x1)).abs() < 0.5, "{:?}", center.bounds);
// Glyphs stay inside the frame; lines keep line-space coordinates.
for g in &bottom.glyphs {
assert!(g.origin.x >= -1e-6 && g.origin.x <= 200.0 + 1e-6);
}
}
/// Top-to-Top leading (em box top to em box top): the first line hangs from the frame's top and a
/// line's leading is the space below it. Vertical alignment measures the space from the lines that
/// model placed, keeps their spacing when it shifts them, and justify adds its share on top of it.
#[test]
fn vertical_align_with_top_to_top_leading() {
use vectorcraft_doc::LeadingModel;
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
let mut t = area("", st(40.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0), inline: None }, TextRun { text: "小\n中".into(), style: st(20.0), inline: None }];
t.para.leading_model = LeadingModel::EmBoxTop;
let top = layout(db(), &t);
assert_eq!(top.lines.len(), 3);
// The em box top sits 0.88 em above the baseline in the craft-fonts CJK faces; a system
// fallback (no craft-fonts) has its own metrics, which the relative checks below don't need.
if !crate::CRAFT_FONTS.is_empty() {
assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline);
}
let gaps = |l: &TextLayout| l.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect::<Vec<_>>();
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
assert!(block_space(&bottom, 300.0).1.abs() < 1e-6, "{:?}", block_space(&bottom, 300.0));
for (a, b) in gaps(&bottom).iter().zip(gaps(&top)) {
assert!((a - b).abs() < 1e-6, "spacing kept: {a} {b}");
}
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
let d = center.lines[0].baseline - top.lines[0].baseline;
assert!((d - block_space(&top, 300.0).1 * 0.5).abs() < 1e-6, "{d}");
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
assert!((just.lines[0].baseline - top.lines[0].baseline).abs() < 1e-9, "the first line stays");
assert!(block_space(&just, 300.0).1.abs() < 1e-6);
let extra: Vec<f64> = gaps(&just).iter().zip(gaps(&top)).map(|(a, b)| a - b).collect();
assert!(extra.len() == 2 && (extra[0] - extra[1]).abs() < 1e-6 && extra[0] > 50.0, "{extra:?}");
// A round frame reflows with Top-to-Top leading too, without losing text.
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
let mut round = area_path("", st(20.0), &circle, Justify::Left);
round.runs = t.runs.clone();
round.para.leading_model = LeadingModel::EmBoxTop;
let rtop = layout(db(), &round);
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
let l = layout_with(db(), &round, &valign(a));
assert!(!l.overflow, "{a:?}");
assert_eq!(l.lines.last().unwrap().end, rtop.lines.last().unwrap().end);
// The 40 pt line starts low in the circle already, so centring moves it a little.
let want = if a == VerticalAlign::Center { 1.0 } else { 20.0 };
assert!(l.lines.last().unwrap().baseline > rtop.lines.last().unwrap().baseline + want, "{a:?}");
}
}
// ---------- OpenType ----------
#[test]
fn opentype_ligature_switches() {
let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(20.0) };
let t = TextObject::point(Point::ZERO, "fi", serif.clone());
assert_eq!(layout(db(), &t).glyphs.len(), 1, "fi ligature by default");
let off = LayoutOptions { features: OtFeatures { ligatures: false, ..Default::default() }, ..Default::default() };
assert_eq!(layout_with(db(), &t, &off).glyphs.len(), 2);
let fi = |tracking: f64, features: &[&str]| {
let st = CharStyle { tracking, features: features.iter().map(|s| s.to_string()).collect(), ..serif.clone() };
layout(db(), &TextObject::point(Point::ZERO, "fi", st)).glyphs.len()
};
// Small tracking adjustments (fitting a line) keep the ligature.
assert_eq!(fi(-5.0, &[]), 1, "tracking -5 keeps fi");
assert_eq!(fi(20.0, &[]), 1, "tracking +20 keeps fi");
let (tight, loose) = LIGATURE_TRACKING_LIMITS;
assert_eq!((fi(tight, &[]), fi(loose, &[])), (1, 1), "the limits themselves keep fi");
// Real letterspacing suppresses ligatures (as letterspaced type should), condensing sooner...
assert_eq!(fi(100.0, &[]), 2, "tracking 100 drops fi");
assert_eq!(fi(-30.0, &[]), 2, "tracking -30 drops fi");
assert_eq!(fi(-100.0, &[]), 2, "tracking -100 drops fi");
// ...unless the character turns them on explicitly.
assert_eq!(fi(100.0, &["liga"]), 1, "explicit liga wins over tracking");
assert_eq!(fi(100.0, &["+liga"]), 1);
assert_eq!(fi(100.0, &["liga", "-liga"]), 2, "the last tag wins");
assert_eq!(fi(0.0, &["-liga"]), 2, "explicit off still turns it off");
let f = OtFeatures::from_tags(["dlig", "-liga", "smcp", "onum", "bogus"]);
assert!(f.discretionary_ligatures && !f.ligatures && f.small_caps && f.oldstyle_figures && !f.fractions);
}
#[test]
fn face_charmap_for_glyphs_panel() {
let f = db().face("Source Sans 3", "Regular").unwrap();
let chars = f.chars();
assert!(chars.len() > 200, "{}", chars.len());
assert!(chars.windows(2).all(|w| w[0].0 < w[1].0));
let a = f.glyph_for('A');
assert!(a != 0 && chars.iter().any(|&(c, g)| c == 'A' && g == a));
assert!(f.advance(a) > 0.0);
assert!(!db().outline(&f, a).elements().is_empty());
assert_eq!(f.units_per_em(), 1000.0);
}
#[test]
fn uncovered_characters_do_not_break_layout() {
// Private-use code point: no font covers it (system fallback is tried at most once and
// remembered); layout still places a .notdef-width glyph.
let l = layout(db(), &TextObject::point(Point::ZERO, "a\u{F8FF}\u{E000}b", style(12.0)));
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.len() >= 3);
}
// ---------- kerning across style runs ----------
/// 40 pt "AVAV" in Source Sans 3, which kerns A and V, with the character at `byte` restyled by
/// `restyle`.
fn avav(byte: usize, restyle: impl FnMut(&mut CharStyle)) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "AVAV", style(40.0));
edit::style_range(&mut t.runs, byte, byte + 1, restyle);
t
}
/// Each glyph's source byte, pen position along the line and advance.
fn pens(t: &TextObject) -> Vec<(usize, f64, f64)> {
layout(db(), t).glyphs.iter().map(|g| (g.byte, g.origin.x, g.advance)).collect()
}
#[test]
fn restyling_one_character_keeps_its_kerning_with_its_neighbors() {
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
assert!((plain[0].2 - unkerned[0].2).abs() > 0.1, "the font kerns A and V: {plain:?} {unkerned:?}");
type Restyle = fn(&mut CharStyle);
let restyles: [(&str, Restyle); 10] = [
("fill", |s| s.fill = Default::default()),
("stroke", |s| {
s.stroke = s.fill.clone();
s.stroke_width = 2.0;
}),
("underline", |s| s.underline = true),
("strikethrough", |s| s.strikethrough = true),
("overprint", |s| s.overprint_fill = true),
("character style", |s| s.style_name = Some("Accent".into())),
("leading", |s| s.leading = Some(60.0)),
("baseline shift", |s| s.baseline_shift = 5.0),
("rotation", |s| s.rotation = 15.0),
("character alignment", |s| s.char_align = vectorcraft_doc::CharAlign::EmBoxCenter),
];
for byte in 0..4 {
for (what, restyle) in restyles {
let t = avav(byte, restyle);
assert_eq!(t.runs.len(), if byte == 0 || byte == 3 { 2 } else { 3 }, "{what} on byte {byte}");
assert_eq!(pens(&t), plain, "{what} on byte {byte} moves no glyph");
let l = layout(db(), &t);
for g in &l.glyphs {
assert_eq!(g.run, usize::from(byte > 0 && g.byte >= byte) + usize::from(g.byte > byte), "{what} on byte {byte}: glyph {g:?}");
let x = g.origin.x + g.advance * 0.25;
assert_eq!(hit_byte(&l, Point::new(x, l.lines[0].baseline)), g.byte, "{what} on byte {byte}: a click before glyph {}", g.byte);
}
}
}
// The V keeps its own baseline shift and rotation.
let l = layout(db(), &avav(1, |s| s.baseline_shift = 5.0));
let y: Vec<f64> = l.glyphs.iter().map(|g| g.xf.as_coeffs()[5]).collect();
assert!((y[0] - y[1] - 5.0).abs() < 1e-9 && y[0] == y[2] && y[0] == y[3], "{y:?}");
let l = layout(db(), &avav(1, |s| s.rotation = 90.0));
assert!(l.glyphs[1].xf.as_coeffs()[1].abs() > l.glyphs[1].xf.as_coeffs()[0].abs(), "the V stands rotated: {:?}", l.glyphs[1].xf);
assert_eq!(l.glyphs[0].xf.as_coeffs()[1], 0.0, "the A does not");
// A V with 60 pt leading on the second line sets that line 60 pt below the first.
let mut t = TextObject::point(Point::ZERO, "AVAV\nAVAV", style(40.0));
edit::style_range(&mut t.runs, 6, 7, |s| s.leading = Some(60.0));
let l = layout(db(), &t);
assert!((l.lines[1].baseline - l.lines[0].baseline - 60.0).abs() < 1e-9, "{:?}", l.lines);
}
#[test]
fn tracking_or_kerning_on_one_character_keeps_the_kerning_before_it() {
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
// Tracking of 50/1000 em on the V adds 2 pt after it and leaves the font's kerning on both sides.
let tracked = pens(&avav(1, |s| s.tracking = 50.0));
assert_eq!(tracked[0], plain[0], "the A keeps its kerning with the V");
assert!((tracked[1].2 - (plain[1].2 + 2.0)).abs() < 1e-9, "{tracked:?} {plain:?}");
// Manual kerning of 100/1000 em on the V sets the space between the V and the A after it, in
// place of the font's; the A before the V keeps the font's kerning with it.
let kerned = pens(&avav(1, |s| s.kerning = Some(100.0)));
assert_eq!(kerned[0], plain[0], "the A keeps its kerning with the V");
assert!((kerned[1].2 - (unkerned[1].2 + 4.0)).abs() < 1e-9, "{kerned:?} {unkerned:?}");
assert!((kerned[2].1 - (plain[1].1 + unkerned[1].2 + 4.0)).abs() < 1e-9, "{kerned:?}");
assert_eq!((kerned[2].2, kerned[3].2), (plain[2].2, plain[3].2), "the A and V after it keep their kerning");
}
#[test]
fn manual_kerning_after_a_small_caps_break_replaces_the_fonts_kerning() {
// Small Caps shapes the lowercase a apart from "AVA", which has manual kerning while the a has
// the font's: "AVA" is set as it is when all of the text has manual kerning.
let caps = CharStyle { small_caps: Some(70.0), ..style(40.0) };
let mut mixed = TextObject::point(Point::ZERO, "aAVA", caps.clone());
edit::style_range(&mut mixed.runs, 1, 4, |s| s.kerning = Some(0.0));
let manual = TextObject::point(Point::ZERO, "aAVA", CharStyle { kerning: Some(0.0), ..caps });
let advances = |t: &TextObject| pens(t).into_iter().filter(|g| g.0 > 0).map(|g| g.2).collect::<Vec<_>>();
assert_eq!(advances(&mixed), advances(&manual));
}
#[test]
fn manual_kerning_replaces_both_halves_of_the_fonts_pair_after_it() {
use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(pair_kerning_font().unwrap());
// 100 pt in a 1000-unit font: the pair takes 6 pt off the A's advance and moves the V 10 pt left.
let st = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), ..style(100.0) };
// Each A's advance and how far the V after it is drawn from its pen position, in `text` with
// manual kerning on the bytes `manual`.
let set = |text: &str, manual: std::ops::Range<usize>| {
let mut t = TextObject::point(Point::ZERO, text, st.clone());
edit::style_range(&mut t.runs, manual.start, manual.end, |s| s.kerning = Some(0.0));
let l = layout(&db, &t);
let g = |c: char| l.glyphs.iter().filter(move |g| text[g.byte..].starts_with(c));
g('A').zip(g('V')).map(|(a, v)| (a.advance, v.xf.as_coeffs()[4] - v.origin.x)).collect::<Vec<_>>()
};
let (kerned, unkerned) = (set("AV", 0..0)[0], set("AV", 0..2)[0]);
assert!((unkerned.0 - kerned.0 - 6.0).abs() < 1e-9 && (unkerned.1 - kerned.1 - 10.0).abs() < 1e-9, "{kerned:?} {unkerned:?}");
assert_eq!(set("AV", 0..1), [unkerned], "manual kerning on the A turns off the whole A-V pair");
assert_eq!(set("AV", 1..2), [kerned], "manual kerning on the V keeps the A-V pair");
assert_eq!(set("HAV", 0..1), [kerned], "manual kerning on the H before them keeps the A-V pair");
assert_eq!(set("AVAV", 1..4), [kerned, unkerned], "manual kerning from the first V on: the second pair is off");
assert_eq!(set("AV\u{301}", 1..4), [kerned], "manual kerning on a V with a combining accent keeps the A-V pair");
// An ß in All Caps is set as SS: with manual kerning on it, the font's S-S pair is off.
let caps = |manual: std::ops::Range<usize>| {
let mut t = TextObject::point(Point::ZERO, "Aß", CharStyle { all_caps: true, ..st.clone() });
edit::style_range(&mut t.runs, manual.start, manual.end, |s| s.kerning = Some(0.0));
let l = layout(&db, &t);
l.glyphs.iter().filter(|g| g.byte == 1).map(|g| (g.advance, g.xf.as_coeffs()[4] - g.origin.x)).collect::<Vec<_>>()
};
let (auto, manual) = (caps(0..0), caps(0..3));
assert_eq!(auto.len(), 2);
assert_ne!(auto, manual, "the font kerns S-S");
assert_eq!(caps(1..3), manual, "manual kerning on the ß turns off the S-S pair");
}
#[test]
fn a_size_change_still_shapes_the_character_apart() {
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
let larger = pens(&avav(1, |s| s.size = 41.0));
assert_eq!(larger[0], unkerned[0], "a V of another size is shaped on its own: {larger:?}");
assert_ne!(larger[0], plain[0]);
}
#[test]
fn a_ligature_does_not_span_a_change_of_paint() {
let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(20.0) };
let glyphs = |byte: Option<usize>| {
let mut t = TextObject::point(Point::ZERO, "ofit", serif.clone());
if let Some(byte) = byte {
edit::style_range(&mut t.runs, byte, byte + 1, |s| s.fill = Default::default());
}
layout(db(), &t).glyphs.iter().map(|g| (g.byte, g.len)).collect::<Vec<_>>()
};
let fi = vec![(0, 1), (1, 2), (3, 1)];
assert_eq!(glyphs(None), fi, "fi ligature in unstyled text");
assert_eq!(glyphs(Some(0)), fi, "a restyled o leaves the fi ligature");
assert_eq!(glyphs(Some(3)), fi, "a restyled t leaves the fi ligature");
assert_eq!(glyphs(Some(2)), vec![(0, 1), (1, 1), (2, 1), (3, 1)], "an i in another paint is a glyph of its own");
}
// ---------- performance ----------
#[test]
fn layout_10k_area_text_is_fast() {
let text: String = COPY.chars().cycle().take(10_000).collect();
for (justify, composer) in
[(Justify::JustifyLeft, Composer::EveryLine), (Justify::Left, Composer::EveryLine), (Justify::Left, Composer::SingleLine)]
{
let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let _ = layout(db(), &t);
let n = 5;
let start = std::time::Instant::now();
for _ in 0..n {
let l = layout(db(), &t);
assert!(!l.overflow);
}
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
eprintln!("layout of 10k chars ({justify:?}, {composer:?}, hyphenated): {per:.3} ms");
let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 };
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
}
}
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
/// line's end); punctuation it leaves full width is trimmed as before (vertical type: see
/// `tests_vertical`). Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
/// 〘〙 (skipped without it).
#[test]
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
use vectorcraft_doc::Mojikumi;
if db().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 st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
let lay = |text: &str, on: bool, m: Mojikumi| {
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
t.para.mojikumi = m;
layout(db(), &t)
};
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
let off = adv(&lay("「あ漢」", false, Mojikumi::None));
let on = adv(&lay("「あ漢」", true, Mojikumi::None));
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
// start (「), between punctuation (」、) or at the line's end (」).
for text in ["「あ」、漢", "漢「あ」"] {
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf), lay(text, true, Mojikumi::None));
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
}
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf), lay("〘あ〙", true, Mojikumi::None));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
}
/// The arrows pass a Thai consonant with its vowel and tone marks whole (น้ำ: no, mai tho, sara am),
/// as an accented letter written with a combining mark; plain text moves a character at a time.
#[test]
fn caret_moves_over_whole_clusters() {
let s = "กน\u{0E49}\u{0E33}e\u{0301}x";
let stops: Vec<usize> = std::iter::successors(Some(0), |&i| (i < s.len()).then(|| edit::next_cluster(s, i))).collect();
assert_eq!(stops, [0, 3, 12, 15, 16]);
let back: Vec<usize> = std::iter::successors(Some(s.len()), |&i| (i > 0).then(|| edit::prev_cluster(s, i))).collect();
assert_eq!(back, [16, 15, 12, 3, 0]);
// Inside a cluster (a caret left there by a click), the arrows go to its edges.
assert_eq!((edit::prev_cluster(s, 6), edit::next_cluster(s, 6)), (3, 12));
assert_eq!((edit::next_cluster("", 0), edit::prev_cluster("ab", 99)), (0, 1));
}
/// Word steps and double-click stop between Thai words, which no space separates: ภาษา|ไทย|ง่าย.
#[test]
fn thai_words_are_words_to_the_caret() {
let s = "ภาษาไทยง่าย ok";
let (thai, easy) = ("ภาษา".len(), "ภาษาไทย".len());
let end = "ภาษาไทยง่าย".len();
assert_eq!(edit::next_word(s, 0), thai);
assert_eq!(edit::next_word(s, thai), easy);
assert_eq!(edit::next_word(s, easy), end);
assert_eq!(edit::prev_word(s, end), easy);
assert_eq!(edit::prev_word(s, easy), thai);
assert_eq!(edit::prev_word(s, thai), 0);
assert_eq!(edit::word_at(s, thai + 3), thai..easy, "double-click inside ไทย");
assert_eq!(edit::word_at(s, 0), 0..thai);
// Latin keeps its words.
assert_eq!(edit::word_at(s, s.len() - 1), end + 1..s.len());
assert_eq!(edit::next_word("hello world", 0), 5);
}