Files
vectorcraft/crates/text/src/tests.rs
T
bflatastic 1cfeaabb2d Docs and format: one row per area after the batch, rustfmt
Rows two PRs of the batch both changed came out twice in localization-parity, target-app-parity and type-parity; the newest of each is kept. prefs_dialog.rs is formatted.
2026-10-21 23:38:01 +07:00

1321 lines
69 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.
use super::*;
use kurbo::{Affine, Shape};
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun};
use vectorcraft_geom::PathData;
/// The bundled fonts only: what these tests measure doesn't change with the fonts installed.
fn db() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(vec![]))
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
fn serif(size: f64) -> CharStyle {
CharStyle { font_family: "Source Serif 4".into(), ..style(size) }
}
fn point(text: &str, st: CharStyle) -> TextObject {
TextObject::point(Point::ZERO, text, st)
}
fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject {
let mut t = point(text, st);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
fn width(t: &TextObject) -> f64 {
let l = layout(db(), t);
l.glyphs.iter().map(|g| g.advance).sum()
}
const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
#[test]
fn bundled_families_and_styles() {
let f = db().families();
for fam in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
assert!(f.iter().any(|x| x == fam), "{fam} missing from {f:?}");
}
let s = db().styles("Source Sans 3");
assert_eq!(s.first().map(String::as_str), Some("Regular"), "{s:?}");
assert!(s.iter().any(|x| x == "Bold") && s.iter().any(|x| x == "Italic") && s.iter().any(|x| x == "Semibold"), "{s:?}");
}
#[test]
fn style_lookup_and_fallback() {
let f = db().face("Inter", "Semibold").unwrap();
assert_eq!((f.family.as_str(), f.style.as_str()), ("Inter", "SemiBold"));
let f = db().face("inter", "Bold").unwrap();
assert_eq!(f.style, "SemiBold", "closest weight");
let f = db().face("No Such Font", "Regular").unwrap();
assert_eq!((f.family.as_str(), f.style.as_str()), ("Source Sans 3", "Regular"));
let f = db().face("No Such Font", "Bold").unwrap();
assert_eq!(f.style, "Bold");
assert_eq!(db().face("Source Sans 3", "Italic").unwrap().style, "Italic");
}
#[test]
fn add_font_rejects_garbage_and_duplicates() {
assert_eq!(db().add_font(vec![1, 2, 3]), 0);
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
assert_eq!(db().add_font(bytes), 0, "already bundled");
}
/// Character panel menu › Fractional Widths, off: each character that takes space advances by a
/// whole number of points, at least one. On (the default) keeps the font's fractional advances,
/// including tracking. A tate-chu-yoko continuation stays at zero.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[test]
fn fractional_widths_off_rounds_positive_advances_to_whole_points() {
let mut on = point("Hi", style(13.0));
on.runs[0].style.tracking = 50.0;
assert!(on.fractional_widths);
let fractional: Vec<f64> = layout(db(), &on).glyphs.iter().map(|g| g.advance).collect();
assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}");
let mut off = on.clone();
off.fractional_widths = false;
let whole: Vec<f64> = layout(db(), &off).glyphs.iter().map(|g| g.advance).collect();
assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}");
assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}");
// Smaller than half a point: rounding alone would be 0, which would run the type together.
let mut tiny = point("i", style(0.4));
tiny.fractional_widths = false;
let laid = layout(db(), &tiny);
assert!(laid.glyphs.iter().any(|g| (g.advance - 1.0).abs() < 1e-9), "{:?}", laid.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
let mut tcy = point("10", style(20.0));
tcy.vertical = true;
tcy.fractional_widths = false;
let adv: Vec<f64> = layout(db(), &tcy).glyphs.iter().map(|g| g.advance).collect();
assert!(adv.contains(&0.0), "a tate-chu-yoko continuation stays at zero: {adv:?}");
assert!(adv.iter().all(|a| *a == 0.0 || (a - a.round()).abs() < 1e-9), "{adv:?}");
}
#[test]
fn glyph_count_and_advances() {
let l = layout(db(), &point("Hello", style(24.0)));
assert_eq!(l.glyphs.len(), 5);
assert_eq!(l.lines.len(), 1);
for (i, g) in l.glyphs.iter().enumerate() {
assert!(g.advance > 0.0);
assert_eq!(g.byte, i);
assert!(!g.outline.elements().is_empty());
}
// Glyphs sit on the baseline (y = 0), ascenders above it (y < 0, y-down).
let h = l.glyphs[0].outline.bounding_box();
assert!(h.y0 < -10.0 && h.y1.abs() < 1.0, "{h:?}");
assert!(l.bounds.width() > 30.0);
}
#[test]
fn ligatures_and_kerning() {
// "fi" forms a ligature in Source Serif 4: one glyph covering two bytes.
let l = layout(db(), &point("fi", serif(20.0)));
assert_eq!(l.glyphs.len(), 1);
assert_eq!(l.glyphs[0].len, 2);
// Metrics kerning tightens "AV"; manual kerning 0 disables it.
let auto = width(&point("AV", style(100.0)));
let off = width(&point("AV", CharStyle { kerning: Some(0.0), ..style(100.0) }));
assert!(auto < off - 1.0, "auto {auto} off {off}");
}
#[test]
fn point_text_lines_and_auto_leading() {
let l = layout(db(), &point("one\ntwo\n", style(10.0)));
assert_eq!(l.lines.len(), 3);
assert_eq!(l.lines[0].baseline, 0.0);
assert!((l.lines[1].baseline - 12.0).abs() < 1e-9);
assert!((l.lines[2].baseline - 24.0).abs() < 1e-9);
assert_eq!((l.lines[0].start, l.lines[0].end), (0, 3));
assert_eq!((l.lines[1].start, l.lines[1].end), (4, 7));
assert_eq!((l.lines[2].start, l.lines[2].end), (8, 8));
}
#[test]
fn explicit_leading() {
let l = layout(db(), &point("a\nb", CharStyle { leading: Some(30.0), ..style(10.0) }));
assert!((l.lines[1].baseline - 30.0).abs() < 1e-9);
}
#[test]
fn point_alignment() {
let mut t = point("Centered", style(20.0));
let w = width(&t);
t.para.justify = Justify::Center;
let l = layout(db(), &t);
assert!((l.lines[0].x0 + w / 2.0).abs() < 1e-6 && (l.lines[0].x1 - w / 2.0).abs() < 1e-6);
t.para.justify = Justify::Right;
let l = layout(db(), &t);
assert!(l.lines[0].x1.abs() < 1e-6);
assert!(l.glyphs[0].origin.x < -w + 1e-6);
}
#[test]
fn tracking_and_scale() {
let base = width(&point("abcd", style(10.0)));
let tracked = width(&point("abcd", CharStyle { tracking: 100.0, ..style(10.0) }));
assert!((tracked - base - 4.0 * 1.0).abs() < 1e-6, "{base} {tracked}");
let wide = width(&point("abcd", CharStyle { h_scale: 200.0, ..style(10.0) }));
assert!((wide - 2.0 * base).abs() < 0.05);
let l = layout(db(), &point("x", CharStyle { baseline_shift: 5.0, ..style(10.0) }));
let b = l.glyphs[0].outline.bounding_box();
assert!(b.y1 < -4.0, "shifted up: {b:?}");
}
#[test]
fn underline_and_strikethrough_bars() {
let mut t = point("ab", style(20.0));
t.runs = vec![
TextRun { text: "ab".into(), style: style(20.0), inline: None },
TextRun { text: "cd".into(), style: CharStyle { underline: true, strikethrough: true, ..style(20.0) }, inline: None },
];
let l = layout(db(), &t);
let bars = decorations(&l, db(), &t);
assert_eq!(bars.len(), 2, "one underline and one strikethrough: {bars:?}");
assert!(bars.iter().all(|(run, _)| *run == 1), "only the styled run");
let (under, strike) = (bars[0].1.bounding_box(), bars[1].1.bounding_box());
// The bars span the run's glyphs: from "c" to the end of "d".
let (c, d) = (&l.glyphs[2], &l.glyphs[3]);
assert!((under.x0 - c.origin.x).abs() < 1e-6 && (under.x1 - d.origin.x - d.advance).abs() < 1e-6, "{under:?}");
// y runs down: the underline sits below the baseline, the strikethrough above it, both thin.
assert!(under.y0 > 0.0 && under.y1 < 5.0, "{under:?}");
assert!(strike.y1 < 0.0 && strike.y0 > -10.0, "{strike:?}");
assert!(under.height() < 3.0 && strike.height() < 3.0);
// No bars for plain text.
assert!(decorations(&layout(db(), &point("ab", style(20.0))), db(), &point("ab", style(20.0))).is_empty());
}
#[test]
fn all_caps() {
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));
let b = width(&point("ABC", style(10.0)));
assert!((a - b).abs() < 1e-9);
let l = layout(db(), &point("\u{df}", CharStyle { all_caps: true, ..style(10.0) }));
assert_eq!(l.glyphs.len(), 2, "ß -> SS");
}
#[test]
fn area_line_breaking_within_frame() {
let frame = Rect::new(10.0, 10.0, 110.0, 400.0);
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Left));
assert!(l.lines.len() > 3);
assert!(!l.overflow);
for line in &l.lines {
assert!(line.x0 >= frame.x0 - 1e-6 && line.x1 <= frame.x1 + 1e-6, "{line:?}");
}
// Lines tile the text.
for w in l.lines.windows(2) {
assert_eq!(w[0].end, w[1].start);
}
assert_eq!(l.lines.last().unwrap().end, LOREM.len());
// First baseline = frame top + ascent; subsequent lines advance by the leading.
assert!((l.lines[0].baseline - (frame.y0 + l.lines[0].ascent)).abs() < 1e-6);
assert!((l.lines[1].baseline - l.lines[0].baseline - 14.4).abs() < 1e-6);
// Lines start at word boundaries.
for line in &l.lines[1..] {
assert_eq!(&LOREM[line.start - 1..line.start], " ");
}
}
#[test]
fn area_overflow() {
let small = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
assert!(small.overflow);
assert!(!small.lines.is_empty() && small.lines.last().unwrap().end < LOREM.len());
let big = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 1000.0, 400.0), Justify::Left));
assert!(!big.overflow);
assert_eq!(big.glyphs.len(), LOREM.chars().count());
}
#[test]
fn area_alignment_and_justify() {
let frame = Rect::new(0.0, 0.0, 150.0, 400.0);
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Right));
for line in &l.lines {
assert!((line.x1 - frame.x1).abs() < 1e-6);
}
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::JustifyLeft));
let n = l.lines.len();
for line in &l.lines[..n - 1] {
assert!((line.x0 - frame.x0).abs() < 1e-6 && (line.x1 - frame.x1).abs() < 1e-6, "{line:?}");
}
assert!(l.lines[n - 1].x1 < frame.x1 - 1.0, "last line not justified");
let l = layout(db(), &area("Wide", style(12.0), frame, Justify::JustifyAll));
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6);
}
#[test]
fn indents_and_paragraph_spacing() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
let mut t = area("para one\npara two", style(10.0), frame, Justify::Left);
t.para.left_indent = 10.0;
t.para.first_line_indent = 5.0;
t.para.space_before = 6.0;
let l = layout(db(), &t);
assert!((l.lines[0].x0 - 15.0).abs() < 1e-9);
assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9);
}
#[test]
fn each_paragraph_has_its_own_attributes() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
let mut t = area("left\ncentred\nright", style(10.0), frame, Justify::Left);
let centred = vectorcraft_doc::ParaStyle { justify: Justify::Center, space_before: 20.0, ..Default::default() };
let right = vectorcraft_doc::ParaStyle { justify: Justify::Right, left_indent: 7.0, ..Default::default() };
t.set_paragraph_styles(vec![t.para.clone(), centred, right]);
let l = layout(db(), &t);
assert_eq!(l.lines.len(), 3);
assert!(l.lines[0].x0.abs() < 1e-9, "the first paragraph stays left");
let mid = (l.lines[1].x0 + l.lines[1].x1) / 2.0;
assert!((mid - 100.0).abs() < 1e-6, "the second is centred: {mid}");
assert!((l.lines[2].x1 - 200.0).abs() < 1e-6, "the third is right-aligned");
// Space before applies to the second paragraph only (leading 12 + 20).
assert!((l.lines[1].baseline - l.lines[0].baseline - 32.0).abs() < 1e-9);
assert!((l.lines[2].baseline - l.lines[1].baseline - 12.0).abs() < 1e-9);
// Paragraph 0's style is `para` (what readers that predate per-paragraph styles see).
assert_eq!(t.paras.len(), 3);
assert_eq!(t.para_at(0), &t.para);
}
#[test]
fn non_rect_frame_narrows_lines() {
// Triangle pointing up: lines get wider towards the bottom.
let tri = PathData::from_bezpath(&{
let mut p = BezPath::new();
p.move_to((100.0, 0.0));
p.line_to((200.0, 200.0));
p.line_to((0.0, 200.0));
p.close_path();
p
});
let mut t = point(&LOREM.repeat(2), style(10.0));
t.kind = TextKind::Area { frame: tri };
let l = layout(db(), &t);
assert!(l.lines.len() > 5);
let w0 = l.lines[0].x1 - l.lines[0].x0;
let wl = l.lines[l.lines.len() - 2].x1 - l.lines[l.lines.len() - 2].x0;
assert!(w0 < wl, "{w0} {wl}");
for line in &l.lines {
let y = line.baseline - line.ascent;
let half = y / 2.0; // half-width of the triangle at y
assert!(line.x0 >= 100.0 - half - 1e-3 && line.x1 <= 100.0 + half + 1e-3, "{line:?}");
}
}
#[test]
fn caret_mapping_round_trip() {
let l = layout(db(), &point("Hi there\nnext", style(20.0)));
let (top, bot) = caret_position(&l, 0);
assert!(top.x.abs() < 1e-9 && top.y < 0.0 && bot.y > 0.0);
let (end_top, _) = caret_position(&l, 8);
assert!((end_top.x - l.lines[0].x1).abs() < 1e-6);
for b in [0, 1, 3, 8, 9, 11, 13] {
let (t, bo) = caret_position(&l, b);
let mid = t.midpoint(bo);
assert_eq!(hit_byte(&l, mid + Vec2::new(0.1, 0.0)), b, "byte {b}");
}
// Second line.
let (t2, b2) = caret_position(&l, 10);
assert!(t2.y > 0.0 && b2.y > 24.0);
assert_eq!(hit_byte(&l, Point::new(1000.0, 24.0)), 13);
assert_eq!(hit_byte(&l, Point::new(-50.0, -100.0)), 0);
}
#[test]
fn caret_inside_ligature_and_empty_text() {
let l = layout(db(), &point("fi", serif(20.0)));
let (a, _) = caret_position(&l, 1);
let g = &l.glyphs[0];
assert!((a.x - g.advance / 2.0).abs() < 1e-6);
let e = layout(db(), &point("", style(20.0)));
assert_eq!(e.lines.len(), 1);
let (t, b) = caret_position(&e, 0);
assert!(t.y < 0.0 && b.y > 0.0);
}
#[test]
fn on_path_placement() {
let mut line = BezPath::new();
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
let mut t = point("Path", style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None };
let l = layout(db(), &t);
assert!(l.on_path && !l.overflow);
assert_eq!(l.glyphs.len(), 4);
assert!((l.glyphs[0].origin.x - 50.0).abs() < 1e-6);
for g in &l.glyphs {
assert!((g.origin.y - 50.0).abs() < 1e-6 && g.angle.abs() < 1e-6);
}
// Vertical path downward: glyphs rotated 90°.
let mut v = BezPath::new();
v.move_to((0.0, 0.0));
v.line_to((0.0, 300.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0, end: None };
let l = layout(db(), &t);
for g in &l.glyphs {
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
assert!(g.origin.x.abs() < 1e-6);
}
let (top, bot) = caret_position(&l, 0);
assert!(top.x > 0.0 && bot.x < 0.0, "caret perpendicular to path: {top:?} {bot:?}");
let mid = l.glyphs[1].origin + Vec2::new(0.0, 0.2);
assert_eq!(hit_byte(&l, mid), 1);
}
#[test]
fn on_path_effects_orient_glyphs() {
use vectorcraft_doc::PathEffect;
let mut diag = BezPath::new();
diag.move_to((0.0, 0.0));
diag.line_to((400.0, 400.0));
let mut t = point("H", style(40.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1, end: None };
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
let rainbow = bbox(&t);
t.path_effect = PathEffect::StairStep;
let stair = bbox(&t);
// Unrotated: as tall as the cap height, narrower than the rotated glyph's box.
assert!(stair.height() < rainbow.height() && stair.height() > 20.0, "{stair:?} {rainbow:?}");
t.path_effect = PathEffect::Skew;
let skew = bbox(&t);
// Vertical stems stay vertical: the box spans the cap height plus the slant of the baseline.
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
t.path_effect = PathEffect::Rainbow;
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
t.path_effect = PathEffect::Gravity;
let b = layout(db(), &t).glyphs[0].outline.bounding_box();
assert!((a.center() - b.center()).hypot() < 2.0, "{a:?} {b:?}");
assert_eq!(PathEffect::parse("3D Ribbon"), Some(PathEffect::Ribbon3d));
assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep));
}
#[test]
fn on_path_circle_and_overflow() {
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
let mut t = point("Around the circle", style(14.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
let l = layout(db(), &t);
assert!(!l.overflow);
for g in &l.glyphs {
let r = g.origin.to_vec2().hypot();
assert!((r - 100.0).abs() < 2.0, "{r}");
}
let mut short = BezPath::new();
short.move_to((0.0, 0.0));
short.line_to((30.0, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0, end: None };
let l = layout(db(), &t);
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
}
#[test]
fn fallback_font_per_character() {
let primary = db().face("Source Sans 3", "Regular").unwrap();
// Find a character the primary lacks but another bundled font has.
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
.into_iter()
.find(|&c| !primary.covers(c) && db().fallback_for(c, primary.id()).is_some())
.expect("some symbol only covered by a non-primary bundled font");
let text = format!("a{c}b");
let l = layout(db(), &point(&text, style(12.0)));
assert_eq!(l.glyphs.len(), 3);
assert_eq!(l.glyphs[0].font_id, primary.id());
assert_ne!(l.glyphs[1].font_id, primary.id());
assert!(!l.glyphs[1].outline.elements().is_empty());
assert_eq!(l.glyphs[2].font_id, primary.id());
}
/// Characters drawn from a fallback face keep the weight and slant asked for (#2491): bold text
/// takes a bold face of the fallback family when it has one.
#[test]
fn fallback_glyphs_keep_the_weight_of_the_text() {
let primary = db().face("Source Sans 3", "Bold").unwrap();
let regular = db().face("Source Sans 3", "Regular").unwrap();
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
.into_iter()
.find(|&c| {
!primary.covers(c)
&& db()
.fallback_for(c, regular.id())
.is_some_and(|f| db().fallback_for_style(c, regular.id(), 700.0, false).is_some_and(|b| b.weight > f.weight))
})
.expect("some symbol covered by a family of the bundled fonts with more than one weight");
let mut st = style(12.0);
st.font_style = "Bold".into();
let l = layout(db(), &point(&format!("a{c}b"), st));
assert_eq!(l.glyphs[0].font_id, primary.id());
let drawn = db().face_by_id(l.glyphs[1].font_id).unwrap();
assert!(drawn.weight >= 600.0, "{} {} for a bold run", drawn.family, drawn.style);
}
/// A missing family's Bold Italic is drawn bold when the fallback family has Bold and Italic but no
/// Bold Italic, not as plain Italic (#2491).
#[test]
fn bold_italic_of_a_missing_family_keeps_the_bold() {
let face = db().face("No Such Family", "Bold Italic").unwrap();
assert_eq!(face.family, FALLBACK_FAMILY);
assert!(face.weight >= 600.0, "{} {}", face.family, face.style);
let italic = db().face("No Such Family", "Italic").unwrap();
assert!(italic.italic && italic.weight < 600.0);
}
/// A space the primary font doesn't map goes to the fallback face like any other character
/// (#1844): an ideographic space in a face without U+3000 keeps its em, so an indent made with it
/// isn't lost. A tab, which the layout handles itself, stays with the primary face. Needs a bundled
/// face with U+3000 and あ (craft-fonts; skipped without it).
#[test]
fn whitespace_the_primary_font_lacks_takes_the_fallback_face() {
let primary = db().face("Source Sans 3", "Regular").unwrap();
assert!(!primary.covers('\u{3000}'));
if ['\u{3000}', 'あ'].iter().any(|&c| db().fallback_for(c, primary.id()).is_none()) {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let l = layout(db(), &point("あ\u{3000}あ", style(20.0)));
let width: f64 = l.glyphs.iter().map(|g| g.advance).sum();
assert!((width - 60.0).abs() < 0.01, "あ あ sets at 3 em: {:?}", l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
assert_ne!(l.glyphs[1].font_id, primary.id());
let l = layout(db(), &point("a\tb", style(20.0)));
assert!(l.glyphs.iter().all(|g| g.font_id == primary.id()));
}
#[test]
fn multiple_runs_and_styles() {
let mut t = point("", style(10.0));
t.runs = vec![
TextRun { text: "Big".into(), style: style(40.0), inline: None },
TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) }, inline: None },
];
let l = layout(db(), &t);
assert_eq!(l.glyphs.len(), 8);
assert_eq!(l.glyphs[3].run, 1);
assert!(l.lines[0].ascent > 30.0);
assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id);
}
#[test]
fn layout_is_fast() {
let text: String = LOREM.chars().cycle().take(1000).collect();
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 2000.0), Justify::JustifyLeft);
let _ = layout(db(), &t); // warm caches
let n = 20;
let start = std::time::Instant::now();
for _ in 0..n {
let l = layout(db(), &t);
assert_eq!(l.glyphs.len(), 1000);
}
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
eprintln!("layout of 1000 chars: {per:.3} ms");
let budget = if cfg!(debug_assertions) { 100.0 } else { 5.0 };
assert!(per < budget, "{per} ms");
}
#[test]
fn tab_stops_position_text() {
use vectorcraft_doc::{TabAlign, TabStop};
let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' };
let x_of = |t: &TextObject, byte: usize| layout(db(), t).glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap();
// Default stops every 36 pt.
let mut t = point("a\tb", style(12.0));
assert!((x_of(&t, 2) - 36.0).abs() < 1e-6);
// A left stop at 100.
t.para.tabs = vec![stop(100.0, TabAlign::Left)];
assert!((x_of(&t, 2) - 100.0).abs() < 1e-6);
// A right stop: the text after the tab ends at 200.
let mut r = point("a\t12345", style(12.0));
r.para.tabs = vec![stop(200.0, TabAlign::Right)];
let lay = layout(db(), &r);
let last = lay.glyphs.last().unwrap();
assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6);
// A decimal stop: the decimal points of two lines line up.
let mut d = point("x\t12.5\ny\t1234.75", style(12.0));
d.para.tabs = vec![stop(150.0, TabAlign::Decimal)];
let lay = layout(db(), &d);
let dots: Vec<f64> = lay.glyphs.iter().filter(|g| d.plain_text()[g.byte..].starts_with('.')).map(|g| g.origin.x).collect();
assert_eq!(dots.len(), 2);
assert!((dots[0] - 150.0).abs() < 1e-6 && (dots[1] - 150.0).abs() < 1e-6, "{dots:?}");
// Past the last explicit stop, default stops resume.
let mut p = point("a\tb\tc", style(12.0));
p.para.tabs = vec![stop(50.0, TabAlign::Left)];
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
#[test]
fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() {
// A tab at the start of area type is 36 pt wide: the line breaks and justifies around that,
// so no line reaches past the frame's right edge (#2457).
for justify in [Justify::Left, Justify::JustifyLeft] {
let t =
area("\tSample copy flows here while the layout takes shape. Swap these words", style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify);
let lay = layout(db(), &t);
let right = lay.glyphs.iter().filter(|g| !t.plain_text()[g.byte..].starts_with(' ')).map(|g| g.origin.x + g.advance).fold(0.0, f64::max);
assert!(right <= 200.0 + 1e-6, "{justify:?}: the type reaches {right}");
let first = lay.glyphs.iter().find(|g| g.byte == 1).map(|g| g.origin.x).unwrap();
assert!((first - 36.0).abs() < 1e-6, "{justify:?}: the text after the tab starts at {first}");
}
}
#[test]
fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() {
use vectorcraft_doc::{TabAlign, TabStop};
// "A<tab>B" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line
// moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457).
for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] {
let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify);
t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let lay = layout(db(), &t);
let at = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap();
assert!((at(2) - at(0) - 100.0).abs() < 1e-6, "{justify:?}: A at {}, B at {}", at(0), at(2));
if matches!(justify, Justify::Right | Justify::JustifyRight) {
let b = lay.glyphs.iter().find(|g| g.byte == 2).unwrap();
assert!((b.origin.x + b.advance - 200.0).abs() < 1e-6, "{justify:?}: the line ends at {}", b.origin.x + b.advance);
}
}
}
#[test]
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
use vectorcraft_doc::{TabAlign, TabStop};
let mut t = point("A\t1\nAAAAA\t2", style(12.0));
t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }];
let lay = layout(db(), &t);
// The left edges of the dots of each line's tab.
let dots = |byte: usize| -> Vec<f64> {
let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
let mut xs: Vec<f64> = vec![];
for el in tab.outline.elements() {
if let kurbo::PathEl::MoveTo(p) = el {
xs.push(p.x);
}
}
xs.sort_by(f64::total_cmp);
xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
xs
};
let (short, long) = (dots(1), dots(9));
assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}");
// The longer line's dots are the shorter line's last ones, at the same x.
let tail = &short[short.len() - long.len()..];
assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}");
assert!(short.last().is_some_and(|&x| x < 120.0));
}
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
fn japanese_fonts() -> bool {
let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese());
if !built_in {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
built_in
}
#[test]
fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() {
if !japanese_fonts() {
return;
}
let l = layout(db(), &point("日本語の文字", style(20.0)));
assert_eq!(l.glyphs.len(), 6);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu");
let face = db().face_covering('日').unwrap();
assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family);
assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family));
// Latin keeps the bundled fallback family.
assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY);
}
#[test]
fn all_supplied_craft_font_families_are_registered_without_system_fonts() {
if CRAFT_FONTS.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
return;
}
let db = FontDb::with_font_dirs(vec![]);
for font in CRAFT_FONTS {
let face = db.face(font.family, font.style).unwrap();
assert_eq!(face.family, font.family, "{} {}", font.family, font.style);
}
}
#[test]
fn japanese_text_lays_out_without_the_craft_fonts() {
// Built either way, Japanese text lays out (as missing glyphs when no font has them).
let l = layout(db(), &point("日本語の文字 abc", style(20.0)));
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.iter().all(|g| g.advance.is_finite()));
if CRAFT_FONTS.is_empty() {
assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled");
}
}
#[test]
fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() {
if !japanese_fonts() {
return;
}
let mut t = point("日本語\n縦書き", style(30.0));
t.vertical = true;
let l = layout(db(), &t);
assert!(l.vertical);
assert_eq!(l.lines.len(), 2);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()));
assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x);
assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y);
for g in &l.glyphs {
let p = g.origin + dir(g.angle) * (g.advance * 0.1);
assert_eq!(hit_byte(&l, p), g.byte);
let (a, b) = caret_position(&l, g.byte);
assert!((a.y - b.y).abs() < 1e-6);
assert!(a.x > b.x);
}
let selection = selection_quads(&l, 0, "日本語".len());
assert_eq!(selection.len(), 1);
}
#[test]
fn vertical_area_type_wraps_into_columns_inside_the_frame() {
let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left);
t.vertical = true;
let l = layout(db(), &t);
assert!(l.lines.len() > 1);
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
}
/// Justified Japanese lines (no word spaces) spread the leftover room over the gaps between their
/// characters (JLREQ 3.8), not inside a Latin word; the last line of a paragraph stays set solid.
#[test]
fn justified_japanese_lines_spread_between_characters() {
// The ideographs' advance in the fonts at hand, measured; five fit a line, 10 pt left over.
let measure = layout(db(), &point("一", style(20.0)));
let a = measure.glyphs[0].advance;
let width = 5.0 * a + 10.0;
let t = area("一二三四五六七", style(20.0), Rect::new(0.0, 0.0, width, 400.0), Justify::JustifyLeft);
let l = layout(db(), &t);
let first = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
assert_eq!(first.len(), 5);
for w in first.windows(2) {
assert!((w[1].origin.x - w[0].origin.x - (a + 2.5)).abs() < 0.01, "10 pt over 4 gaps");
}
let end = first.last().map(|g| g.origin.x + a).unwrap();
assert!((end - width).abs() < 0.01, "the line reaches the frame's edge: {end} vs {width}");
// The last line keeps its natural spacing.
let last = &l.glyphs[l.lines[1].glyph_start..l.lines[1].glyph_end];
assert!((last[1].origin.x - last[0].origin.x - a).abs() < 0.01);
// A Latin word inside a Japanese line keeps its letters together; the gaps around it share.
let t = area("一二ABC三四五六七八九十一二三", style(20.0), Rect::new(0.0, 0.0, 8.0 * a + 10.0, 400.0), Justify::JustifyLeft);
let l = layout(db(), &t);
let g = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
let at = g.iter().position(|g| g.byte == "一二".len()).unwrap();
let solid =
|k: usize| (g[k + 1].origin.x - g[k].origin.x - layout(db(), &point(&"ABC"[k - at..=k - at], style(20.0))).glyphs[0].advance).abs() < 0.01;
assert!(solid(at) && solid(at + 1), "A–B–C set solid");
assert!(g[at].origin.x - g[at - 1].origin.x > a + 0.1, "二–A takes its share");
}
/// Leading measured from em box top to em box top (Japanese layout's model): area type's first line
/// touches the frame's top; lines of one size are as far apart as with baseline-to-baseline leading;
/// with sizes mixed, a line's leading is the space below it (baseline leading: above it).
#[test]
fn em_box_top_leading_hangs_lines_from_the_line_above() {
use vectorcraft_doc::{LeadingModel, TextRun};
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
let lay = |t: &TextObject, m: LeadingModel| {
let mut t = t.clone();
t.para.leading_model = m;
layout(db(), &t)
};
// The em box top: 0.88 em above the baseline (fonts without vertical metrics).
let top = |size: f64| 0.88 * size;
let one_size = area("一行目\n二行目\n三行目", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left);
let (roman, em) = (lay(&one_size, LeadingModel::RomanBaseline), lay(&one_size, LeadingModel::EmBoxTop));
assert!((em.lines[0].baseline - top(20.0)).abs() < 0.01, "the first line's em box touches the top: {}", em.lines[0].baseline);
assert!((roman.lines[0].baseline - em.lines[0].baseline).abs() > 0.5, "baseline leading keeps Area Type Options' first baseline");
for l in [&roman, &em] {
assert!((l.lines[1].baseline - l.lines[0].baseline - 30.0).abs() < 0.01 && (l.lines[2].baseline - l.lines[1].baseline - 30.0).abs() < 0.01);
}
// 40 pt over 20 pt (leading 60 and 30).
let mut mixed = point("", st(40.0));
mixed.runs = vec![TextRun { text: "大\n".into(), style: st(40.0), inline: None }, TextRun { text: "小".into(), style: st(20.0), inline: None }];
let roman = lay(&mixed, LeadingModel::RomanBaseline);
assert!((roman.lines[1].baseline - roman.lines[0].baseline - 30.0).abs() < 0.01, "the small line's leading, above it");
let em = lay(&mixed, LeadingModel::EmBoxTop);
let want = -top(40.0) + 60.0 + top(20.0);
assert!(
(em.lines[1].baseline - em.lines[0].baseline - want).abs() < 0.01,
"the big line's leading, below it: {} vs {want}",
em.lines[1].baseline - em.lines[0].baseline
);
}
/// The leading model is a paragraph attribute: in "大 / 小 / 大 / 小" (40 pt over 20 pt) with
/// only the second paragraph top-to-top, its line hangs from the big line's em box (the big
/// line's leading, below it) while the fourth, baseline to baseline, takes its own leading above
/// it; in area type a top-to-top first paragraph puts its em box on the frame's top.
#[test]
fn leading_model_is_per_paragraph() {
use vectorcraft_doc::{LeadingModel, ParaStyle, TextRun};
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
let model = |m: LeadingModel| ParaStyle { leading_model: m, ..ParaStyle::default() };
let top = |size: f64| 0.88 * size;
let mut t = point("", st(40.0));
t.runs = vec![
TextRun { text: "大\n".into(), style: st(40.0), inline: None },
TextRun { text: "小\n".into(), style: st(20.0), inline: None },
TextRun { text: "大\n".into(), style: st(40.0), inline: None },
TextRun { text: "小".into(), style: st(20.0), inline: None },
];
t.set_paragraph_styles(vec![
model(LeadingModel::RomanBaseline),
model(LeadingModel::EmBoxTop),
model(LeadingModel::RomanBaseline),
model(LeadingModel::RomanBaseline),
]);
assert_eq!(t.paras.len(), 4);
let l = layout(db(), &t);
let gap = |i: usize| l.lines[i].baseline - l.lines[i - 1].baseline;
let em = -top(40.0) + 60.0 + top(20.0);
assert!((gap(1) - em).abs() < 0.01, "top to top: {} vs {em}", gap(1));
assert!((gap(3) - 30.0).abs() < 0.01, "baseline to baseline: {}", gap(3));
// Area type: only a top-to-top first paragraph moves the first line up to the frame's top.
let mut a = area("一\n二", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left);
a.set_paragraph_styles(vec![model(LeadingModel::RomanBaseline), model(LeadingModel::EmBoxTop)]);
let roman_first = layout(db(), &a).lines[0].baseline;
a.set_paragraph_styles(vec![model(LeadingModel::EmBoxTop), model(LeadingModel::RomanBaseline)]);
let em_first = layout(db(), &a);
assert!((em_first.lines[0].baseline - top(20.0)).abs() < 0.01, "{}", em_first.lines[0].baseline);
assert!((roman_first - em_first.lines[0].baseline).abs() > 0.5);
assert!((em_first.lines[1].baseline - em_first.lines[0].baseline - 30.0).abs() < 0.01);
}
/// Character Alignment: a 20 pt character next to a 40 pt one lines its em box top, centre or
/// bottom up with the big one's (the em box from 0.12 em below the baseline to 0.88 em above it,
/// in fonts without vertical metrics), or stays on the baseline; in vertical type the same across
/// the column (top → right).
#[test]
fn character_alignment_lines_small_characters_up_with_the_largest_em_box() {
use vectorcraft_doc::{CharAlign, TextRun};
for vertical_type in [false, true] {
let place = |a: CharAlign| {
let mut t = point("", style(40.0));
t.vertical = vertical_type;
t.runs = vec![
TextRun { text: "大".into(), style: style(40.0), inline: None },
TextRun { text: "小".into(), style: CharStyle { char_align: a, ..style(20.0) }, inline: None },
];
let l = layout(db(), &t);
// How far the small character's origin sits above the big one's (to the right, vertical).
let (big, small) = (l.glyphs[0].origin, l.glyphs[1].origin);
if vertical_type { small.x - big.x } else { big.y - small.y }
};
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
assert!(near(place(CharAlign::RomanBaseline), 0.0), "vertical {vertical_type}");
assert!(near(place(CharAlign::EmBoxTop), 0.88 * 20.0), "top: {}", place(CharAlign::EmBoxTop));
assert!(near(place(CharAlign::EmBoxCenter), 0.38 * 20.0), "centre: {}", place(CharAlign::EmBoxCenter));
assert!(near(place(CharAlign::EmBoxBottom), -0.12 * 20.0), "bottom: {}", place(CharAlign::EmBoxBottom));
}
}
/// Character Alignment on the ICF: a 20 pt character next to a 40 pt one lines its ideographic
/// character face's top (right, vertical) or bottom (left) up with the big one's, which lies inside
/// the em box by the face's ICF margins. A face without ideographs has none (the ICF is the em
/// box). The Japanese faces need craft-fonts (skipped without them).
#[test]
fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_face() {
use vectorcraft_doc::{CharAlign, TextRun};
let place = |family: &str, a: CharAlign, vertical_type: bool| {
let st = |size: f64| CharStyle { font_family: family.into(), ..style(size) };
let mut t = point("", st(40.0));
t.vertical = vertical_type;
t.runs = vec![
TextRun { text: "大".into(), style: st(40.0), inline: None },
TextRun { text: "小".into(), style: CharStyle { char_align: a, ..st(20.0) }, inline: None },
];
let l = layout(db(), &t);
let (big, small) = (l.glyphs[0].origin, l.glyphs[1].origin);
if vertical_type { small.x - big.x } else { big.y - small.y }
};
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
// No ideographs: the ICF is the em box.
assert_eq!(db().face("Source Sans 3", "Regular").unwrap().icf_margins(), crate::IcfMargins::default());
let Some(face) = db().face("Shippori Mincho", "Regular").filter(|f| f.family == "Shippori Mincho") else {
return; // no Japanese font here
};
let m = face.icf_margins();
assert!([m.top, m.bottom, m.right, m.left].iter().all(|x| *x > 0.01 && *x < 0.2), "{m:?}");
for vertical_type in [false, true] {
let (top, bottom) = if vertical_type { (m.right, m.left) } else { (m.top, m.bottom) };
// The big character's ICF top is `top` ems below its em box top: 20 pt of difference.
let em_top = place("Shippori Mincho", CharAlign::EmBoxTop, vertical_type);
let icf_top = place("Shippori Mincho", CharAlign::IcfTop, vertical_type);
assert!(near(em_top - icf_top, top * 20.0), "vertical {vertical_type}: {em_top} {icf_top} {m:?}");
let em_bottom = place("Shippori Mincho", CharAlign::EmBoxBottom, vertical_type);
let icf_bottom = place("Shippori Mincho", CharAlign::IcfBottom, vertical_type);
assert!(near(icf_bottom - em_bottom, bottom * 20.0), "vertical {vertical_type}: {em_bottom} {icf_bottom} {m:?}");
}
}
/// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of
/// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a
/// Latin line ending in a full stop isn't shortened by Forced.
#[test]
fn burasagari_leaves_latin_commas_and_full_stops_inside_the_line() {
use vectorcraft_doc::Burasagari;
let measure = width(&point("abcd", style(20.0)));
let lay = |text: &str, b: Burasagari| {
let mut t = area(text, style(20.0), Rect::new(0.0, 0.0, measure + 0.01, 400.0), Justify::JustifyLeft);
t.para.burasagari = b;
layout(db(), &t).glyphs.iter().map(|g| (g.line, (g.origin.x * 100.0).round())).collect::<Vec<_>>()
};
for text in ["abcd. ef", "a b. efgh", "ab, cd, efgh"] {
let none = lay(text, Burasagari::None);
for b in [Burasagari::Standard, Burasagari::Forced] {
assert_eq!(lay(text, b), none, "{text} {b:?}");
}
}
}
/// Optical Margin Alignment hangs the glyphs at a line's ends outside its margins: wholly for a
/// quotation mark, hyphen, full stop or comma, by half for an em dash, by a tenth of the advance
/// for an A, at the left of a left-aligned line, at the right of a right-aligned line, and at
/// both ends of centered and justified lines. A full stop and the closing quotation mark after it
/// hang together. Off, the lines are placed as before.
#[test]
fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margins() {
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
let lay = |text: &str, justify: Justify, on: bool| {
let mut t = area(text, style(20.0), frame, justify);
t.optical_margin = on;
layout(db(), &t).glyphs
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
// Left aligned: the opening quotation mark hangs wholly, so the H starts at the edge.
let (on, off) = (lay("“Hello” there", Justify::Left, true), lay("“Hello” there", Justify::Left, false));
assert!(near(off[0].origin.x, 0.0) && near(on[1].origin.x, 0.0), "{} {}", off[0].origin.x, on[1].origin.x);
assert!(near(on[0].origin.x, -on[0].advance) && on[0].advance > 1.0);
// An em dash hangs by half its advance.
let dash = lay("—ab", Justify::Left, true);
assert!(near(dash[0].origin.x, -0.5 * dash[0].advance) && dash[0].advance > 1.0, "{}", dash[0].origin.x);
// An A hangs by a tenth of its advance; a letter of no class doesn't hang.
let a = lay("AVA", Justify::Left, true);
assert!(near(a[0].origin.x, -0.1 * a[0].advance), "{}", a[0].origin.x);
let v = lay("van", Justify::Left, true);
assert!(near(v[0].origin.x, -0.1 * v[0].advance), "{}", v[0].origin.x);
assert!(near(lay("Hello", Justify::Left, true)[0].origin.x, 0.0));
assert!(near(lay("and", Justify::Left, true)[0].origin.x, 0.0));
// A line of one glyph doesn't hang.
assert!(near(lay(".", Justify::Left, true)[0].origin.x, 0.0));
// Right aligned: the full stop hangs wholly at the right; with a closing quotation mark after
// it, both do.
let (on, off) = (lay("Hello there.", Justify::Right, true), lay("Hello there.", Justify::Right, false));
let n = on.len();
assert!(near(end(&off[n - 1]), 300.0) && near(on[n - 1].origin.x, 300.0), "{} {}", end(&off[n - 1]), on[n - 1].origin.x);
let on = lay("Say “hi.”", Justify::Right, true);
let n = on.len();
assert!(near(on[n - 2].origin.x, 300.0) && near(end(&on[n - 3]), 300.0), "{}", on[n - 2].origin.x);
// At a line's start, a single quotation mark followed by a double one hangs with it.
assert!(near(lay("‘“Hi", Justify::Left, true)[2].origin.x, 0.0));
// A right-aligned line doesn't hang at its left, nor a left-aligned one at its right.
assert!(near(lay("“Hello", Justify::Right, true).last().map_or(0.0, end), 300.0));
assert!(near(lay("Hello.", Justify::Left, true)[0].origin.x, 0.0));
// Centered: the letters between the hanging hyphens are centered in the frame.
let on = lay("-ab-", Justify::Center, true);
assert!(near((on[1].origin.x + end(&on[2])) * 0.5, 150.0), "{on:?}");
let off = lay("-ab-", Justify::Center, false);
assert!(near((off[0].origin.x + end(&off[3])) * 0.5, 150.0));
// Justified: both hyphens hang and the words fill the frame between them.
let on = lay("-ab cd-", Justify::JustifyAll, true);
let n = on.len();
assert!(near(on[1].origin.x, 0.0) && near(on[n - 1].origin.x, 300.0), "{} {}", on[1].origin.x, on[n - 1].origin.x);
let off = lay("-ab cd-", Justify::JustifyAll, false);
assert!(near(off[0].origin.x, 0.0) && near(end(&off[n - 1]), 300.0));
// A line without spaces is justified between its letters, not next to the hanging marks.
for text in ["“Hi”", "“Hi.”", "“漢字”"] {
let on = lay(text, Justify::JustifyAll, true);
let n = on.len();
// The glyphs that hang at the right: the closing mark, and the full stop before it.
let right = if text.contains('.') { 2 } else { 1 };
assert!(near(on[1].origin.x, 0.0) && near(end(&on[n - 1 - right]), 300.0), "{text}: {on:?}");
}
// Letters that hang by part of their width keep their gaps: the line still fills the frame.
let a = lay("AVA", Justify::JustifyAll, true);
assert!(near(end(&a[2]), 300.0 + 0.1 * a[2].advance), "{}", end(&a[2]));
// Point type hangs at its anchor.
let mut t = point("“Hi", style(20.0));
t.optical_margin = true;
let l = layout(db(), &t);
assert!(near(l.glyphs[1].origin.x, 0.0) && l.glyphs[0].origin.x < -1.0, "{:?}", l.glyphs[0].origin);
// Vertical type: the letter after the quotation mark takes the mark's place at the column's top.
let vertical = |on: bool| {
let mut t = area("“Hello", style(20.0), frame, Justify::Left);
(t.vertical, t.optical_margin) = (true, on);
layout(db(), &t).glyphs
};
let (on, off) = (vertical(true), vertical(false));
assert!((on[1].origin - off[0].origin).hypot() < 1e-6, "{:?} {:?}", on[1].origin, off[0].origin);
}
/// Right-to-left paragraphs hang at the margins their alignment places their lines against: Auto
/// and right-aligned lines hang at the right, left-aligned lines hang at the left, and justified
/// lines hang at both. The line is read from right to left, so `."` ending it hangs both glyphs at its left, and `".`
/// starting it hangs only the quotation mark at its right. Roman Hanging Punctuation hangs them
/// the same way. In vertical type the characters run from the top down, and the pair is read
/// from the top.
#[test]
fn optical_margin_alignment_hangs_right_to_left_paragraphs() {
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
let object = |text: &str, justify: Justify, on: bool| {
let mut t = area(text, style(20.0), frame, justify);
t.optical_margin = on;
t
};
let lay = |text: &str, justify: Justify, on: bool| layout(db(), &object(text, justify, on)).glyphs;
let at = |glyphs: &[PositionedGlyph], b: usize| glyphs.iter().find(|g| g.byte == b).cloned().unwrap();
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
let alef = 'א'.len_utf8();
// Auto aligns the line at the right: the opening quotation mark (logically first, drawn at the
// right end) hangs wholly past the right edge, and the letter after it ends at the edge.
let text = "\"שלום\" עולם";
let (on, off) = (lay(text, Justify::Auto, true), lay(text, Justify::Auto, false));
assert!(off.iter().all(|g| g.rtl) && near(end(&at(&off, 0)), 300.0), "{off:?}");
assert!(near(at(&on, 0).origin.x, 300.0) && at(&on, 0).advance > 1.0, "{:?}", at(&on, 0).origin);
assert!(near(end(&at(&on, 1)), 300.0), "{}", end(&at(&on, 1)));
// Left aligned: the full stop ending the line is drawn at its left and hangs there.
let text = "שלום, עולם.";
let stop = text.len() - 1;
let (on, off) = (lay(text, Justify::Left, true), lay(text, Justify::Left, false));
assert!(near(off[0].origin.x, 0.0) && near(at(&on, stop).origin.x, -at(&on, stop).advance), "{:?}", at(&on, stop).origin);
// Justified: `."` ending the line hangs both glyphs at the left; the right end doesn't hang.
let text = "שלום עולם.\"";
let (quote, stop) = (text.len() - 1, text.len() - 2);
let on = lay(text, Justify::JustifyAll, true);
let (q, s) = (at(&on, quote), at(&on, stop));
assert!(near(q.origin.x, -q.advance - s.advance) && near(s.origin.x, -s.advance), "{:?} {:?}", q.origin, s.origin);
assert!(near(end(&at(&on, 0)), 300.0), "{}", end(&at(&on, 0)));
// A justified line of one word, spread between its letters: the opening quotation mark hangs
// wholly past the right edge, the letter after it ends at the edge, and the last letter starts
// at the left edge.
let on = lay("\"שלום", Justify::JustifyAll, true);
let mem = 1 + 3 * alef;
assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin);
assert!(near(at(&on, mem).origin.x, 0.0), "{:?}", at(&on, mem).origin);
// `".` starting a right-aligned line: the quotation mark hangs and the full stop doesn't.
let on = lay("\".שלום", Justify::Right, true);
assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin);
// A wrapped left-aligned line: the comma ending it hangs, and the space after it stays to its left.
let first = width(&point("אבגד, ", style(20.0)));
let mut t = area("אבגד, הוזח", style(20.0), Rect::new(0.0, 0.0, first + 1.0, 400.0), Justify::Left);
t.optical_margin = true;
let l = layout(db(), &t);
let (comma, space) = (at(&l.glyphs, 4 * alef), at(&l.glyphs, 4 * alef + 1));
assert!(l.lines.len() == 2 && near(comma.origin.x, -comma.advance), "{:?} {:?}", l.lines, comma.origin);
assert!(near(end(&space), comma.origin.x), "{:?} {:?}", space.origin, comma.origin);
// Point type: Auto grows the line left from its anchor, and the opening quotation mark hangs
// to the right of the anchor.
let mut t = point("\"שלום", style(20.0));
(t.para.justify, t.optical_margin) = (Justify::Auto, true);
let l = layout(db(), &t);
assert!(near(at(&l.glyphs, 0).origin.x, 0.0) && near(end(&at(&l.glyphs, 1)), 0.0), "{:?}", at(&l.glyphs, 0).origin);
// Roman Hanging Punctuation hangs the full stop the same way.
let mut t = area("שלום, עולם.", style(20.0), frame, Justify::Left);
t.para.roman_hanging = true;
let l = layout(db(), &t);
let stop = "שלום, עולם".len();
assert!(near(at(&l.glyphs, stop).origin.x, -at(&l.glyphs, stop).advance), "{:?}", at(&l.glyphs, stop).origin);
// Vertical type: the letter after the quotation mark takes the mark's place at the column's top.
let vertical = |text: &str, justify: Justify, on: bool| {
let mut t = object(text, justify, on);
t.vertical = true;
layout(db(), &t).glyphs
};
let (on, off) = (vertical("\"שלום", Justify::Left, true), vertical("\"שלום", Justify::Left, false));
assert!((at(&on, 1).origin - at(&off, 0).origin).hypot() < 1e-6, "{:?} {:?}", at(&on, 1).origin, at(&off, 0).origin);
// …and `."` ending a line at the column's bottom hangs both glyphs there.
let text = "שלום.\"";
let (on, off) = (vertical(text, Justify::Right, true), vertical(text, Justify::Right, false));
let (stop, mem) = (text.len() - 2, text.len() - 2 - alef);
let shift = at(&off, stop).advance + at(&off, stop + 1).advance;
assert!(near((at(&on, mem).origin - at(&off, mem).origin).hypot(), shift), "{:?} {:?}", at(&on, mem).origin, at(&off, mem).origin);
}
/// Roman Hanging Punctuation (a paragraph attribute) hangs the punctuation Optical Margin
/// Alignment hangs, by the same amounts, and no letters; only the paragraphs that have it hang.
#[test]
fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() {
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
let lay = |text: &str, justify: Justify, on: bool| {
let mut t = area(text, style(20.0), frame, justify);
t.para.roman_hanging = on;
layout(db(), &t).glyphs
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
// Left aligned: the opening quotation mark hangs wholly.
let on = lay("“Hello” there", Justify::Left, true);
assert!(near(on[1].origin.x, 0.0) && near(on[0].origin.x, -on[0].advance) && on[0].advance > 1.0, "{:?}", on[0].origin);
// An em dash hangs by half its advance.
let dash = lay("—ab", Justify::Left, true);
assert!(near(dash[0].origin.x, -0.5 * dash[0].advance), "{}", dash[0].origin.x);
// Letters don't hang, where Optical Margin Alignment hangs an A by a tenth.
assert!(near(lay("AVA", Justify::Left, true)[0].origin.x, 0.0));
// Right aligned: the full stop and the closing quotation mark after it hang at the right.
let r = lay("“Hi.”", Justify::Right, true);
assert!(near(end(&r[2]), 300.0), "{}", end(&r[2]));
// Justified: a hyphen at each end hangs, the words fill the frame between them.
let j = lay("-ab cd-", Justify::JustifyAll, true);
let n = j.len();
assert!(near(j[1].origin.x, 0.0) && near(j[n - 1].origin.x, 300.0), "{} {}", j[1].origin.x, j[n - 1].origin.x);
// Off: nothing hangs.
assert!(near(lay("“Hello” there", Justify::Left, false)[0].origin.x, 0.0));
// Per paragraph: the second paragraph, without it, doesn't hang.
let mut t = area("“One\n“Two", style(20.0), frame, Justify::Left);
let mut first = t.para.clone();
first.roman_hanging = true;
t.set_paragraph_styles(vec![first, t.para.clone()]);
let l = layout(db(), &t);
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
assert!(at(0) < -1.0, "first paragraph hangs: {}", at(0));
assert!(near(at("“One\n".len()), 0.0), "second doesn't: {}", at("“One\n".len()));
}
/// Type on a path hangs at its brackets, measured along the path: a left-aligned line at the
/// start bracket, a right-aligned line at the end bracket, a centered line at both, and the
/// justified alignments where they place the line. A mark hanging past the end bracket doesn't
/// overflow. A hang never takes a glyph off an open path's ends, nor past the other bracket of a
/// closed path. Right-to-left type hangs at the bracket Auto places it at and is read from right to
/// left. Roman Hanging Punctuation hangs path type the same way, without the letters.
#[test]
fn optical_margin_alignment_hangs_type_on_a_path_at_its_brackets() {
// A straight path along y = 50 from x = 0 to 500: distance along it is x.
let mut line = BezPath::new();
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
let path = PathData::from_bezpath(&line);
let on_path = |text: &str, justify: Justify, start: f64, end: Option<f64>, on: bool| {
let mut t = point(text, style(20.0));
t.kind = TextKind::OnPath { path: path.clone(), start, end };
t.para.justify = justify;
t.optical_margin = on;
t
};
let lay = |text: &str, justify: Justify, on: bool| layout(db(), &on_path(text, justify, 0.1, Some(0.9), on));
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
// Left aligned: the opening quotation mark hangs wholly before the start bracket (x = 50).
let (on, off) = (lay("“Hello", Justify::Left, true), lay("“Hello", Justify::Left, false));
assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[1].origin.x, 50.0), "{:?}", on.glyphs[1].origin);
assert!(near(on.glyphs[0].origin.x, 50.0 - on.glyphs[0].advance) && on.glyphs[0].advance > 1.0);
// Auto, Justify All and Justify with Last Line Aligned Left place the line at the start bracket
// and hang there too.
for justify in [Justify::Auto, Justify::JustifyAll, Justify::JustifyLeft] {
assert!(near(lay("“Hello", justify, true).glyphs[1].origin.x, 50.0), "{justify:?}");
}
// Right aligned: the full stop hangs past the end bracket (x = 450) without overflowing.
for justify in [Justify::Right, Justify::JustifyRight] {
let (on, off) = (lay("Hello there.", justify, true), lay("Hello there.", justify, false));
let n = on.glyphs.len();
assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{justify:?}: {:?}", on.glyphs[n - 1].origin);
assert!(!on.overflow);
}
// A right-aligned line doesn't hang at its start, nor a left-aligned one at its end.
assert!(near(lay("“Hello", Justify::Right, true).glyphs.last().map_or(0.0, end), 450.0));
assert!(near(lay("Hello.", Justify::Left, true).glyphs[0].origin.x, 50.0));
// Centered: the letters between the hanging hyphens are centered between the brackets.
for justify in [Justify::Center, Justify::JustifyCenter] {
let on = lay("-ab-", justify, true).glyphs;
assert!(near((on[1].origin.x + end(&on[2])) * 0.5, 250.0), "{justify:?}: {on:?}");
}
// The hanging full stop is room: a line that overflows by part of its full stop fits with it.
let wide = lay("Hello.", Justify::Left, false).glyphs;
let w: f64 = wide.iter().map(|g| g.advance).sum();
let tight = |on: bool| layout(db(), &on_path("Hello.", Justify::Left, 0.1, Some((50.0 + w - 0.5 * wide[5].advance) / 500.0), on));
let (on, off) = (tight(true), tight(false));
assert!(off.overflow && off.glyphs.len() == 5, "{} {}", off.overflow, off.glyphs.len());
assert!(!on.overflow && on.glyphs.len() == 6, "{} {}", on.overflow, on.glyphs.len());
// Only the glyphs that hang take that room: a letter ending past the end bracket by less than
// the overset story's last full stop still overflows.
let story = lay("Hello there.", Justify::Left, false).glyphs;
let past = (end(&story[3]) - 0.5 * story[11].advance) / 500.0;
let overset = |on: bool| layout(db(), &on_path("Hello there.", Justify::Left, 0.1, Some(past), on));
assert!(overset(true).overflow && overset(true).glyphs.len() == 3, "{}", overset(true).glyphs.len());
assert_eq!(overset(false).glyphs.len(), 3);
// A hang stops at an open path's first point: no glyph is held there.
let at_start = |on: bool| layout(db(), &on_path("“Hello", Justify::Left, 0.0, None, on)).glyphs;
assert!(near(at_start(true)[0].origin.x, 0.0) && near(at_start(true)[1].origin.x, at_start(false)[1].origin.x));
let quote = at_start(false)[0].advance;
let part = layout(db(), &on_path("“Hello", Justify::Left, 0.5 * quote / 500.0, None, true)).glyphs;
assert!(near(part[0].origin.x, 0.0), "cut to the room before the bracket: {:?}", part[0].origin);
// Vertical type on the path: the letter after the quotation mark takes the mark's place.
let vertical = |on: bool| {
let mut t = on_path("“Hello", Justify::Left, 0.1, Some(0.9), on);
t.vertical = true;
layout(db(), &t).glyphs
};
let (on, off) = (vertical(true), vertical(false));
assert!((on[1].origin - off[0].origin).hypot() < 1e-6 && (on[0].origin - off[0].origin).hypot() > 1.0, "{:?} {:?}", on[1].origin, off[0].origin);
// With Spacing on a curve, the letter after the hanging quotation mark still starts at the
// bracket: the rest of the line is drawn as the same text without the mark.
let mut curve = BezPath::new();
curve.move_to((20.0, 150.0));
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
let spaced = |text: &str, on: bool| {
let mut t = point(text, style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.2, end: None };
(t.path_spacing, t.optical_margin) = (12.0, on);
layout(db(), &t).glyphs.iter().map(|g| (g.origin, g.angle)).collect::<Vec<_>>()
};
let (on, off) = (spaced("“Hello", true), spaced("Hello", false));
assert!(on.len() == 6 && on[1..].iter().zip(&off).all(|(a, b)| (a.0 - b.0).hypot() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{on:?} {off:?}");
// A closed path whose brackets take all of it leaves no room to hang.
let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1));
let round = |on: bool| {
let mut t = point("“Around the circle”", style(14.0));
t.kind = TextKind::OnPath { path: circle.clone(), start: 0.0, end: None };
t.optical_margin = on;
layout(db(), &t).glyphs.iter().map(|g| g.origin).collect::<Vec<_>>()
};
assert_eq!(round(true), round(false));
// On a closed path whose brackets leave a stretch narrower than a quotation mark, a
// right-aligned line hangs its closing mark into that stretch; its opening mark doesn't take it.
let mut square = BezPath::new();
square.move_to((0.0, 0.0));
square.line_to((500.0, 0.0));
square.line_to((500.0, 10.0));
square.line_to((0.0, 10.0));
square.close_path();
let square = PathData::from_bezpath(&square);
let gap = 0.5 * quote;
let closed = |justify: Justify, on: bool| {
// 1,020 pt round; the brackets are at 102 and at 102 - gap on the top side.
let mut t = on_path("“Hi”", justify, 0.1, None, on);
t.kind = TextKind::OnPath { path: square.clone(), start: 0.1, end: Some((102.0 - gap) / 1020.0) };
layout(db(), &t).glyphs
};
let (on, off) = (closed(Justify::Right, true), closed(Justify::Right, false));
assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}");
// Centered, each mark hangs into half of it, and the line stays where it was.
let origins = |on: bool| closed(Justify::Center, on).iter().map(|g| g.origin).collect::<Vec<_>>();
let (on, off) = (origins(true), origins(false));
assert!(on.iter().zip(&off).all(|(a, b)| (*a - *b).hypot() < 1e-6), "{on:?} {off:?}");
// Right-to-left type on a path hangs too. Auto places it at the end bracket, where its opening
// quotation mark, drawn last, hangs wholly past the bracket.
let (on, off) = (lay("\"שלום\" עולם", Justify::Auto, true), lay("\"שלום\" עולם", Justify::Auto, false));
let n = on.glyphs.len();
assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{:?}", on.glyphs[n - 1].origin);
assert!(!on.overflow);
// The line is read from right to left: left aligned, `."` ending it hangs both glyphs before the
// start bracket.
let (on, off) = (lay("עולם.\"", Justify::Left, true), lay("עולם.\"", Justify::Left, false));
assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[2].origin.x, 50.0), "{:?}", on.glyphs[2].origin);
// On a closed path, Auto right-to-left type takes the stretch past the end bracket first, as
// right-aligned type does.
let closed_rtl = |on: bool| {
let mut t = on_path("\"שלום\"", Justify::Auto, 0.1, None, on);
t.kind = TextKind::OnPath { path: square.clone(), start: 0.1, end: Some((102.0 - gap) / 1020.0) };
layout(db(), &t).glyphs
};
let (on, off) = (closed_rtl(true), closed_rtl(false));
assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}");
// Vertical type on a path whose paragraph runs right to left reads from the top: the letter
// after the quotation mark takes the mark's place.
let vertical_rtl = |on: bool| {
let mut t = on_path("\"שלום", Justify::Left, 0.1, Some(0.9), on);
t.vertical = true;
layout(db(), &t).glyphs
};
let (on, off) = (vertical_rtl(true), vertical_rtl(false));
assert!((on[1].origin - off[0].origin).hypot() < 1e-6 && (on[0].origin - off[0].origin).hypot() > 1.0, "{:?} {:?}", on[1].origin, off[0].origin);
// Roman Hanging Punctuation hangs the quotation mark and no letters.
let roman = |text: &str| {
let mut t = on_path(text, Justify::Left, 0.1, Some(0.9), false);
t.para.roman_hanging = true;
layout(db(), &t).glyphs
};
assert!(near(roman("“Hello")[1].origin.x, 50.0) && near(roman("AVA")[0].origin.x, 50.0));
assert!(near(lay("AVA", Justify::Left, true).glyphs[0].origin.x, 50.0 - 0.1 * lay("AVA", Justify::Left, true).glyphs[0].advance));
}
/// With craft-fonts and no installed fonts (the web), Thai is set in Noto Sans Thai, not as empty
/// boxes. Skipped when built without craft-fonts or with a checkout that has no Thai font.
#[test]
fn thai_without_installed_fonts_takes_the_craft_fonts_thai_font() {
if !crate::CRAFT_FONTS.iter().any(|f| f.is_thai()) {
eprintln!("skipping: no Thai font in craft-fonts (set CRAFT_FONTS_DIR to a checkout that has one)");
return;
}
let l = layout(db(), &point("ภาษาไทย", style(20.0)));
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.gid != 0), "no .notdef");
let families: Vec<String> = l.glyphs.iter().filter_map(|g| db().face_by_id(g.font_id)).map(|f| f.family.clone()).collect();
assert!(families.iter().all(|f| f == "Noto Sans Thai"), "{families:?}");
}
/// Thai is written without spaces between words: area type breaks its lines between words (as a
/// model of Thai words finds them), never inside a word nor before a vowel or tone mark.
#[test]
fn thai_lines_break_between_words() {
let text = "สวัสดีครับ ภาษาไทยง่ายนิดเดียว";
// Narrower than the shortest words: each line holds one word.
let l = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 70.0, 2000.0), Justify::Left));
let lines: Vec<&str> = l.lines.iter().map(|ln| &text[ln.start..ln.end]).collect();
assert!(lines.len() >= 3, "{lines:?}");
// Its words: สวัสดี ครับ, ภาษา ไทย ง่าย นิดเดียว ("hello", "the Thai language is very easy").
let words = ["สวัสดี", "ครับ ", "ภาษา", "ไทย", "ง่าย", "นิดเดียว"];
let ends: Vec<usize> = words
.iter()
.scan(0, |at, w| {
*at += w.len();
Some(*at)
})
.collect();
assert_eq!(ends.last(), Some(&text.len()));
for ln in &l.lines {
assert!(ends.contains(&ln.end), "a line ends inside a word: {lines:?}");
}
for w in l.lines.windows(2) {
assert_eq!(w[0].end, w[1].start, "lines tile the text");
}
let mark = |c: char| matches!(c, '\u{0E31}' | '\u{0E34}'..='\u{0E3A}' | '\u{0E47}'..='\u{0E4E}');
assert!(lines.iter().all(|s| s.chars().next().is_some_and(|c| !mark(c))), "a line starts with a mark: {lines:?}");
}
/// The desktop's dictionaries keep loanwords whole that a smaller model splits: โปรแกรม
/// ("program").
#[test]
fn thai_loanwords_stay_whole() {
let text = "ยินดีต้อนรับสู่โปรแกรมวาดภาพเวกเตอร์";
let l = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 70.0, 2000.0), Justify::Left));
let lines: Vec<&str> = l.lines.iter().map(|ln| &text[ln.start..ln.end]).collect();
// No line ends inside the word, whatever the Thai font's widths put beside it.
let word = text.find("โปรแกรม").unwrap();
assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}");
}
/// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps
/// at the space without the mark.
#[test]
fn no_break_keeps_marked_words_on_one_line() {
let plain = style(20.0);
let marked = CharStyle { no_break: true, ..plain.clone() };
let hello: f64 = layout(db(), &point("hello ", plain.clone())).glyphs.iter().map(|g| g.advance).sum();
let whole: f64 = layout(db(), &point("hello world", plain.clone())).glyphs.iter().map(|g| g.advance).sum();
assert!(whole > hello + 1.0, "the second word takes room");
let frame = Rect::new(0.0, 0.0, hello + 1.0, 200.0);
let wrapped = layout(db(), &area("hello world", plain, frame, Justify::Left));
assert!(wrapped.lines.len() > 1, "it wraps at the space");
let kept = layout(db(), &area("hello world", marked, frame, Justify::Left));
assert_eq!(kept.lines.len(), 1, "the marked words stay together");
assert_eq!(kept.lines[0].end, "hello world".len());
}