use super::*;
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
use deckcraft_model::{LayoutType, Presentation, Xfrm};
fn setup(text: &str, w: f64, h: f64) -> (Presentation, Shape) {
let mut p = Presentation::default();
let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, lay);
let mut body = s.shapes.remove(1);
body.xfrm = Some(Xfrm::new(0.0, 0.0, w, h));
body.text = Some(TextBody::from_text(text));
s.shapes = vec![body.clone()];
p.slides = vec![std::sync::Arc::new(s)];
(p, body)
}
fn lay(p: &Presentation, sh: &Shape, w: f64, h: f64) -> TextLayout {
let ctx = Ctx::for_slide(p, &p.slides[0]).unwrap();
layout(
&ctx,
sh,
sh.text.as_ref().unwrap(),
&Opts {
rect: Rect::new(0.0, 0.0, w, h),
fields: &NoFields,
prompt_color: None,
no_shrink: false,
},
)
}
#[test]
fn wraps_long_text_into_lines() {
let text = "The quick brown fox jumps over the lazy dog and keeps on running far away";
let (p, sh) = setup(text, 200.0, 400.0);
let l = lay(&p, &sh, 200.0, 400.0);
assert!(l.lines.len() >= 3, "{} lines", l.lines.len());
for li in &l.lines {
assert!(
li.caret_x.last().unwrap() <= &(200.0 + 0.5),
"line too wide: {:?}",
li.caret_x.last()
);
assert_eq!(li.caret_x.len(), li.end - li.start + 1);
}
assert_eq!(l.lines[0].start, 0);
assert!(!l.runs.is_empty());
}
#[test]
fn bullets_are_drawn_for_body_text() {
let (p, sh) = setup("One\nTwo", 600.0, 300.0);
let l = lay(&p, &sh, 600.0, 300.0);
assert!(l.runs.len() >= 4, "{:?}", l.runs);
assert_eq!(l.para_lines.len(), 2);
}
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
let (p, mut sh) = setup(text, 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
for r in &mut para.runs {
r.props.font = Some(family.into());
r.props.size = Some(40.0);
r.props.kern = kern;
}
let l = lay(&p, &sh, 2000.0, 300.0);
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
}
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
let db = FontDb::global();
families
.iter()
.filter(|f| db.has_family(f))
.map(|f| db.face(f, "Regular"))
.find(|f| pick(f))
.map(|f| f.family.clone())
}
const LATIN_FAMILIES: &[&str] = &[
"Carlito",
"Calibri",
"DejaVu Sans",
"Liberation Sans",
"Inter",
"Arial",
"Cambria",
"Times New Roman",
];
#[test]
fn latin_text_draws_no_ligatures() {
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
eprintln!("skipped: no font with an ffi ligature");
return;
};
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
assert_eq!(n, 12, "{family}: one glyph per character");
}
#[test]
fn pairs_are_kerned_only_from_the_kern_size() {
let kerns = |f: &FontFace| {
let w = |feat: &[deckcraft_fonts::Feature]| {
deckcraft_fonts::shape(f, "AVATAR", feat, |c| c)
.iter()
.map(|g| g.x_advance)
.sum::<i32>()
};
w(&[])
!= w(&deckcraft_fonts::feature("-kern")
.into_iter()
.collect::<Vec<_>>())
};
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
eprintln!("skipped: no font that kerns AVATAR");
return;
};
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
assert!(
(at - on).abs() < 0.01,
"kerned at exactly the threshold size"
);
assert!(
(above - off).abs() < 0.01,
"40 pt is below a 48 pt threshold"
);
}
#[test]
fn ligatures_stay_for_scripts_that_need_them() {
let has = |f: &[deckcraft_fonts::Feature], tag: &str| {
f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag))
};
let cells = |s: &str| {
s.chars()
.map(|ch| Cell {
ch,
style: 0,
face: 0,
level: 0,
glyphs: vec![],
adv: 0.0,
head: 0,
boundary: true,
})
.collect::<Vec<_>>()
};
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
assert!(!has(
&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")),
"-liga"
));
assert!(!has(
&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")),
"-liga"
));
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
}
#[test]
fn only_the_first_character_of_a_bullet_is_drawn() {
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
let (p, mut sh) = setup("One", 600.0, 300.0);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet =
Some(Bullet::Char { char: char.into() });
let l = lay(&p, &sh, 600.0, 300.0);
let bullet = l
.runs
.iter()
.find(|r| r.chars == (0, 0))
.expect("bullet run");
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
}
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
}
#[test]
fn shrink_on_overflow() {
let text = (0..30)
.map(|i| format!("Line number {i}"))
.collect::<Vec<_>>()
.join("\n");
let (p, sh) = setup(&text, 600.0, 200.0);
let l = lay(&p, &sh, 600.0, 200.0);
assert!(l.font_scale < 1.0, "scale {}", l.font_scale);
assert!(!l.overflow || l.font_scale <= 0.11);
}
#[test]
fn caret_and_hit_roundtrip() {
let (p, sh) = setup("Hello world", 600.0, 100.0);
let l = lay(&p, &sh, 600.0, 100.0);
for ch in 0..=11 {
let pos = Pos { para: 0, ch };
let (x, top, bot) = l.caret(pos).unwrap();
let back = l.hit(Point::new(x + 0.1, (top + bot) / 2.0));
assert_eq!(back, pos, "at {ch}");
}
let r = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 1);
}
#[test]
fn composition_is_underlined_per_line() {
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
let l = lay(&p, &sh, 600.0, 100.0);
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
let u = l.composition_underlines(a, b);
assert_eq!(u.len(), 1, "{u:?}");
let (x0, x1, y) = u[0];
let (cx0, ..) = l.caret(a).unwrap();
let (cx1, ..) = l.caret(b).unwrap();
assert!(
(x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01,
"{u:?} vs carets {cx0} {cx1}"
);
let line = &l.lines[0];
assert!(
y > line.baseline && y <= line.bottom,
"under the baseline: {y} ({} – {})",
line.baseline,
line.bottom
);
assert_eq!(l.composition_underlines(b, a), u);
assert!(l.composition_underlines(a, a).is_empty());
let (p, sh) = setup(
"가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사",
120.0,
400.0,
);
let l = lay(&p, &sh, 120.0, 400.0);
assert!(l.lines.len() >= 2);
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
assert!(u.len() >= 2, "{u:?}");
assert!(
u.windows(2).all(|w| w[1].2 > w[0].2),
"each lower than the last: {u:?}"
);
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
}
#[test]
fn alignment_center_and_right() {
let (p, mut sh) = setup("Hi", 400.0, 100.0);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Center);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
let c = lay(&p, &sh, 400.0, 100.0);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Right);
let r = lay(&p, &sh, 400.0, 100.0);
let cx = c.lines[0].caret_x[0];
let rx = r.lines[0].caret_x[0];
assert!(rx > cx && cx > 100.0, "center {cx} right {rx}");
}
#[test]
fn empty_and_hostile_bodies_never_panic() {
let (p, mut sh) = setup("", 10.0, 10.0);
let _ = lay(&p, &sh, 10.0, 10.0);
let _ = lay(&p, &sh, 0.0, 0.0);
let t = sh.text.as_mut().unwrap();
t.paragraphs = vec![Paragraph {
runs: vec![
Run::new("\t\u{b}\u{b}🙂 ﷽ 日本語"),
Run {
text: String::new(),
props: Default::default(),
kind: deckcraft_model::text::RunKind::Break,
},
],
..Default::default()
}];
t.paragraphs[0].props.margin_left = Some(-50.0);
t.paragraphs[0].props.indent = Some(f64::NAN);
t.paragraphs[0].props.line_spacing = Some(Spacing::Pct(-3.0));
t.body.columns = Some(1000);
let l = lay(&p, &sh, 50.0, 50.0);
let _ = l.hit(Point::new(-100.0, 1e9));
let _ = l.caret(Pos { para: 99, ch: 99 });
let _ = l.selection_rects(Pos { para: 5, ch: 0 }, Pos { para: 0, ch: 3 });
}
#[test]
fn autonum_formats() {
assert_eq!(autonum("arabicPeriod", 3), "3.");
assert_eq!(autonum("romanUcPeriod", 4), "IV.");
assert_eq!(autonum("alphaLcParenR", 2), "b)");
assert_eq!(autonum("arabicParenBoth", 1), "(1)");
assert_eq!(autonum("alphaUcPeriod", 27), "AA.");
}
#[test]
fn numbered_list_counts() {
let (p, mut sh) = setup("a\nb\nc", 400.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum {
scheme: "arabicPeriod".into(),
start_at: 1,
});
}
let l = lay(&p, &sh, 400.0, 300.0);
assert!(l.runs.len() >= 6);
}
const MIXED: &str = "سنة 2024 (Retrait) فقط";
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
let (p, mut sh) = setup("x", w, 300.0);
let t = sh.text.as_mut().unwrap();
t.paragraphs = vec![Paragraph {
runs,
..Default::default()
}];
let pp = &mut t.paragraphs[0].props;
pp.rtl = Some(rtl);
pp.align = Some(align);
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
(p, sh)
}
fn arabic_face() -> Option<Arc<FontFace>> {
let db = FontDb::global();
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
f.covers('\u{0633}').then_some(f)
}
#[test]
fn rtl_mixed_string_is_reordered_per_line() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
assert!(line.rtl);
let n = MIXED.chars().count();
assert_eq!(line.edges.len(), n);
assert_eq!(line.caret_x.len(), n + 1);
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
assert!(mid(0) > mid(4), "first Arabic word right of the number");
assert!(mid(4) > mid(10), "number right of the Latin word");
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
assert!(mid(0) > mid(1) && mid(1) > mid(2));
assert!(mid(4) < mid(5) && mid(5) < mid(7));
assert!(mid(10) < mid(16));
assert!(mid(9) > mid(16) && mid(17) < mid(10));
let right_edge = l.inner.x1;
assert!(
(line.extent.1 - right_edge).abs() < 0.5,
"{} vs {right_edge}",
line.extent.1
);
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
assert_eq!(line.levels[0], 1);
assert_eq!(line.levels[4], 2);
assert_eq!(line.levels[10], 2);
}
#[test]
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
pp.bullet = Some(Bullet::Char { char: "•".into() });
pp.margin_left = Some(36.0);
pp.indent = Some(-36.0);
let l = lay(&p, &sh, 400.0, 300.0);
let line = &l.lines[0];
let s_edge = l.inner.x1;
assert!(
(line.extent.1 - (s_edge - 36.0)).abs() < 0.5,
"text right {} expected {}",
line.extent.1,
s_edge - 36.0
);
let bullet = l
.runs
.iter()
.find(|r| r.chars == (0, 0))
.expect("bullet run");
let bx = bullet.glyphs[0].1;
assert!(
bx > line.extent.1 - 0.5,
"bullet {bx} right of the text {}",
line.extent.1
);
assert!(bx < s_edge, "bullet inside the box");
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
let bullet = l
.runs
.iter()
.find(|r| r.chars == (0, 0))
.expect("bullet run");
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
}
#[test]
fn rtl_left_and_center_alignment() {
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
assert!(
(l.lines[0].extent.0 - l.inner.x0).abs() < 0.5,
"algn=l is flush left in RTL too"
);
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
let l = lay(&p, &sh, 400.0, 300.0);
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
}
#[test]
fn bidi_caret_stops_round_trip_through_hit_testing() {
for rtl in [true, false] {
let (p, sh) = bidi_setup(
vec![Run::new(MIXED)],
2000.0,
rtl,
if rtl { Align::Right } else { Align::Left },
);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
for (x, ch, aff) in line.visual_stops() {
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
assert!(
(cx - x).abs() < 0.01,
"stop {ch} {aff:?}: caret {cx} vs stop {x}"
);
}
for (a, b) in line.graphemes() {
let Some((x0, x1)) = line.grapheme_box(a, b) else {
continue;
};
if x1 - x0 < 1.0 {
continue;
}
let y = (line.top + line.bottom) / 2.0;
for x in [x0 + 0.2, x1 - 0.2] {
let (pos, aff) = l.hit_affinity(Point::new(x, y));
let (cx, ..) = l.caret_at(pos, aff).unwrap();
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
assert!(
(cx - side).abs() < 0.01,
"rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}"
);
}
}
}
}
#[test]
fn visual_movement_walks_the_line_left_to_right() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let stops = l.lines[0].visual_stops();
let (x0, ch0, aff0) = stops[0];
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
let mut xs = vec![x0];
for _ in 0..200 {
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
if (np, na) == (pos, aff) {
break;
}
(pos, aff) = (np, na);
xs.push(l.caret_at(pos, aff).unwrap().0);
}
assert!(
xs.windows(2).all(|w| w[1] > w[0]),
"moving right always moves right: {xs:?}"
);
let right = l.lines[0].extent.1;
assert!(
(xs.last().unwrap() - right).abs() < 0.01,
"reaches the right edge"
);
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
let (p1, _) = l.move_caret(
Pos { para: 0, ch: 0 },
Affinity::Downstream,
false,
MoveMode::Visual,
);
assert_eq!(p1.ch, 1);
}
#[test]
fn logical_movement_steps_over_harakat() {
let (p, sh) = bidi_setup(
vec![Run::new("\u{0628}\u{064E}\u{062A}")],
400.0,
true,
Align::Right,
);
let l = lay(&p, &sh, 400.0, 300.0);
let (n1, _) = l.move_caret(
Pos { para: 0, ch: 0 },
Affinity::Downstream,
true,
MoveMode::Logical,
);
assert_eq!(n1.ch, 2);
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
assert_eq!(n2.ch, 0);
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
}
#[test]
fn selection_is_the_union_of_visual_boxes() {
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 2, "{r:?}");
assert_eq!(
l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 })
.len(),
1
);
}
#[test]
fn paint_only_style_changes_keep_arabic_joining() {
let Some(_) = arabic_face() else {
eprintln!("skipped: no Arabic face available");
return;
};
let word = "سنة";
let glyphs = |runs: Vec<Run>| {
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let mut g: Vec<(u32, f64)> = l
.runs
.iter()
.flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1)))
.collect();
g.sort_by(|a, b| a.1.total_cmp(&b.1));
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
};
let whole = glyphs(vec![Run::new(word)]);
let mut red = Run::new("سن");
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(
Rgba::from_hex("C00000").unwrap(),
)));
let split = glyphs(vec![red, Run::new("ة")]);
assert_eq!(
whole, split,
"a colour change inside a word keeps the joined forms"
);
let isolated: Vec<u32> = word
.chars()
.map(|c| arabic_face().unwrap().glyph_for(c))
.collect();
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
}
#[test]
fn just_low_stretches_arabic_with_kashida() {
if arabic_face().is_none() {
eprintln!("skipped: no Arabic face available");
return;
}
let tatweels = |l: &TextLayout| {
l.runs
.iter()
.map(|r| {
let tg = r.face.glyph_for('\u{0640}');
if tg == 0 {
0
} else {
r.glyphs.iter().filter(|g| g.0 == tg).count()
}
})
.sum::<usize>()
};
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
let l = lay(&p, &sh, 220.0, 600.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let w = l.inner.width();
assert!(
(first.extent.1 - first.extent.0 - w).abs() < 0.5,
"first line fills the box: {:?} vs {w}",
first.extent
);
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
eprintln!("skipped: the Arabic face has no tatweel");
return;
}
assert!(tatweels(&l) > 0, "kashida drawn");
let last = l.lines.last().unwrap();
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
let l = lay(&p, &sh, 220.0, 600.0);
assert_eq!(tatweels(&l), 0);
}
#[test]
fn wrapped_rtl_lines_break_in_logical_order() {
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
let l = lay(&p, &sh, 160.0, 600.0);
assert!(l.lines.len() >= 2);
assert_eq!(l.lines[0].start, 0);
for w in l.lines.windows(2) {
assert!(w[1].start >= w[0].end);
}
for line in &l.lines {
assert!(
line.extent.0 >= l.inner.x0 - 0.5,
"line fits: {:?}",
line.extent
);
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
}
let end0 = l.lines[0].end;
assert_eq!(
l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream),
Some(0)
);
}
#[test]
fn hostile_bidi_input_never_panics() {
let nasty = format!(
"{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}",
"\u{202B}".repeat(200),
"\u{2067}".repeat(130)
);
for rtl in [true, false] {
for align in [
Align::Left,
Align::Right,
Align::Center,
Align::Justify,
Align::JustLow,
Align::Distributed,
] {
let (p, sh) = bidi_setup(
vec![Run::new(nasty.clone()), Run::new(MIXED)],
60.0,
rtl,
align,
);
let l = lay(&p, &sh, 60.0, 60.0);
for line in &l.lines {
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
assert_eq!(line.edges.len(), line.end - line.start);
}
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
let mut pos = (Pos::default(), Affinity::Downstream);
for i in 0..50 {
pos = l.move_caret(
pos.0,
pos.1,
i % 3 != 0,
if i % 2 == 0 {
MoveMode::Visual
} else {
MoveMode::Logical
},
);
}
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
}
}
}
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
let mut run = Run::new(word);
run.props.size = Some(24.0);
run.props.baseline = baseline;
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
}
#[test]
fn superscript_and_subscript_advance_at_their_drawn_size() {
let normal = run_width("superscript", None);
for b in [Some(0.3), Some(-0.25)] {
let w = run_width("superscript", b);
assert!(
(w / normal - SUB_SUPER).abs() < 0.02,
"{b:?}: {w} vs {normal}"
);
}
}
#[test]
fn superscript_glyphs_sit_inside_their_cells() {
let mut run = Run::new("super");
run.props.size = Some(24.0);
run.props.baseline = Some(0.3);
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
let (x0, x1) = line.edges[*ci - line.start];
assert!(
g.1 >= x0 - 0.01 && g.1 < x1,
"glyph at {} outside its cell {x0}..{x1}",
g.1
);
}
}
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
let text =
"Wrapping words go on and on so the paragraph needs at least two or three lines here";
let (p, mut sh) = setup(text, 300.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(bullet);
para.props.margin_left = Some(margin);
para.props.indent = Some(indent);
let l = lay(&p, &sh, 300.0, 300.0);
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
let bx = l
.runs
.iter()
.find(|r| r.cells.is_empty() && !r.glyphs.is_empty())
.map(|r| r.glyphs[0].1);
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
}
#[test]
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
let num = || Bullet::AutoNum {
scheme: "arabicPeriod".into(),
start_at: 1,
};
let (bx, first, rest) = hanging(0.0, -36.0, num());
assert!(
(first - rest - 36.0).abs() < 0.5,
"first {first} rest {rest}"
);
assert!(
(bx.unwrap() - rest).abs() < 0.5,
"bullet {bx:?} rest {rest}"
);
let (bx, first, rest) = hanging(18.0, -54.0, num());
assert!(
(first - rest - 36.0).abs() < 0.5,
"first {first} rest {rest}"
);
assert!(
(rest - bx.unwrap() - 18.0).abs() < 0.5,
"bullet {bx:?} rest {rest}"
);
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
assert!(
(first - rest - 36.0).abs() < 0.5,
"first {first} rest {rest}"
);
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
}
#[test]
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
let (bx, first, rest) = hanging(
36.0,
-36.0,
Bullet::AutoNum {
scheme: "arabicPeriod".into(),
start_at: 1,
},
);
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
}
#[test]
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
for size in [18.0, 40.0] {
let mut run = Run::new("HHH");
run.props.size = Some(size);
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.autofit = Some(AutoFit::None);
let second = t.paragraphs[0].clone();
t.paragraphs.push(second.clone());
t.paragraphs.push(second);
for para in &mut t.paragraphs {
para.props.line_spacing = Some(Spacing::Pct(1.0));
para.props.space_before = Some(Spacing::Pts(0.0));
para.props.space_after = Some(Spacing::Pts(0.0));
}
let l = lay(&p, &sh, 2000.0, 600.0);
assert_eq!(l.lines.len(), 3);
for line in &l.lines {
assert!(
(line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6,
"{size}: {}",
line.bottom - line.top
);
}
let face = &l.runs[0].face;
let first = &l.lines[0];
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
}
}
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.inset_l = Some(0.0);
t.body.inset_r = Some(0.0);
set(&mut t.paragraphs[0].props);
let l = lay(&p, &sh, w, 600.0);
let chars: Vec<char> = text.chars().collect();
l.lines
.iter()
.map(|li| chars[li.start..li.end].iter().collect())
.collect()
}
fn one_line(text: &str) -> f64 {
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
let l = lay(&p, &sh, 5000.0, 300.0);
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
}
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
for sep in ["/", "\\"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(
lines[0].chars().count() > "alpha/beta".len(),
"filled to the edge, not cut at a separator: {lines:?}"
);
}
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| {
p.latin_line_break = Some(true)
});
assert!(lines[0].ends_with('/'), "{lines:?}");
let lines = wrapped(
"alpha-beta-gamma-delta-epsilon",
one_line("alpha-beta-gamma-") + 1.0,
|_| {},
);
assert_eq!(lines[0], "alpha-beta-gamma-");
}
#[test]
fn korean_wraps_between_words() {
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
let w = one_line("한국어 안녕하세요 반") + 0.5;
let lines = wrapped(text, w, |_| {});
assert_eq!(
lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(),
["한국어 안녕하세요", "반갑습니다", "감사합니다"],
"{lines:?}"
);
let lines = wrapped(
"日本語の文章は空白なしで続きます",
one_line("日本語の文章") + 0.5,
|_| {},
);
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
}
#[test]
fn emoji_take_a_colour_face_when_one_is_installed() {
let db = FontDb::global();
let probe = db.cascade(&['😀'], &[], "Regular");
if !probe.is_color() {
return;
}
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let emoji = l
.runs
.iter()
.find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii()))
.unwrap();
assert!(
emoji.face.is_color(),
"{s:?} drawn with {}",
emoji.face.family
);
assert!(
l.runs
.iter()
.filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii()))
.all(|r| !r.face.is_color()),
"the letters keep a text face"
);
}
}
#[test]
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
let db = FontDb::global();
if !db.cascade(&['①'], &[], "Regular").covers('①') {
return;
}
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum {
scheme: "circleNumDbPlain".into(),
start_at: 1,
});
}
let l = lay(&p, &sh, 600.0, 300.0);
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
assert_eq!(bullets.len(), 2);
for b in bullets {
assert!(
b.face.covers('①'),
"drawn with {}, which lacks the circled digit",
b.face.family
);
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
}
}
#[test]
fn symbol_font_bullets_map_by_font() {
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
}
#[test]
fn an_installed_symbol_font_draws_its_own_bullet() {
let db = FontDb::global();
if !db.has_family("Wingdings 2") {
return;
}
let face = db.face("Wingdings 2", "Regular");
assert_eq!(
symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")),
Some('\u{f0ea}')
);
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
}
#[test]
fn symbol_char_maps_symbol_font_codes() {
let face = FontDb::global().face("Inter", "Regular");
assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font");
assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}'));
assert_eq!(
symbol_char(&face, "Webdings", '\u{F06E}'),
Some('\u{25CF}'),
"the private-use form of the code"
);
assert_eq!(
symbol_char(&face, "Wingdings", '\u{F06E}'),
Some('\u{25A0}')
);
assert_eq!(
symbol_char(&face, "Wingdings", '\u{100}'),
None,
"not a symbol code"
);
}
#[test]
fn symbol_font_characters_draw_in_the_sym_font() {
let symbol = "Symbol";
if !FontDb::global().has_family(symbol) {
eprintln!("skipped: no Symbol font installed");
return;
}
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.font = Some("Arial".into());
para.runs[0].props.font_sym = Some(symbol.into());
let l = lay(&p, &sh, 600.0, 300.0);
let families: Vec<(String, (usize, usize))> = l
.runs
.iter()
.map(|r| (r.face.family.clone(), r.chars))
.collect();
assert!(
families
.iter()
.any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1),
"{families:?}"
);
assert!(
families
.iter()
.all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)),
"{families:?}"
);
}