Files
deckcraft/crates/text/src/tests.rs
T

1843 lines
84 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 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).expect("slide");
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);
}
// Lines cover the text without gaps (spaces at wraps are skipped).
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);
// Two text runs + two bullet runs.
assert!(l.runs.len() >= 4, "{:?}", l.runs);
assert_eq!(l.para_lines.len(), 2);
}
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
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)
}
/// The first installed or bundled family of `families` for which `pick` holds.
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() {
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
// standard 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;
};
// `kern` is the smallest size kerned; 0 is no kerning.
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");
}
/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run
/// has character spacing `spc`.
fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 {
let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0);
let t = sh.text.as_mut().unwrap();
let mut second = t.paragraphs[0].clone();
second.runs[0].props.spacing = Some(spc);
t.paragraphs.push(second);
for para in &mut t.paragraphs {
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 = Some(12.0);
}
}
let l = lay(&p, &sh, 2000.0, 300.0);
l.lines[0].extent.1 - l.lines[0].extent.0
}
#[test]
fn character_spacing_in_the_body_keeps_kerning() {
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));
assert!(on < off);
// Spacing elsewhere in the box leaves the pairs kerned (Calibri, Segoe UI, Times New Roman in
// PowerPoint; only Arial drops them).
for spc in [0.0, 0.5, -1.0, 0.6, 2.0] {
let w = kerned_width_beside_spacing(&family, spc);
assert!((w - on).abs() < 0.01, "{family} spc {spc}: {w} vs {on}");
}
}
#[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, upright: false })
.collect::<Vec<_>>()
};
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
// Arabic needs its ligatures; emoji sequences are joined by them.
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() {
// "••" (seen in the wild) draws one dot in PowerPoint.
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:?}");
}
// The first grapheme cluster is kept whole.
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 saved_shrink_scale_is_drawn() {
// A file saved with fontScale 62.5% and lnSpcReduction 20% draws at that scale even though
// the text would fit at full size, as PowerPoint draws it.
let (p, mut sh) = setup("Short", 600.0, 300.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 });
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!((l.font_scale, l.line_reduction), (0.625, 0.2));
// Text our metrics find overflowing at the saved scale is still drawn at it, as PowerPoint
// draws it until the text changes; with no saved scale it shrinks to fit.
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
let (p, mut sh) = setup(&text, 600.0, 200.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.9), line_reduction: 0.1 });
let l = lay(&p, &sh, 600.0, 200.0);
assert_eq!((l.font_scale, l.line_reduction), (0.9, 0.1));
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let l = lay(&p, &sh, 600.0, 200.0);
assert!(l.font_scale < 0.9, "{}", l.font_scale);
// Nonsense saved values are ignored.
let (p, mut sh) = setup("Short", 600.0, 300.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(f64::NAN), line_reduction: 5.0 });
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!((l.font_scale, l.line_reduction), (1.0, 0.2));
}
/// A body whose file has `<a:normAutofit/>` with no `fontScale` is the schema default 100 %:
/// PowerPoint draws it as it is, even overflowing. DeckCraft draws it the same (#724).
#[test]
fn bare_normautofit_draws_at_full_size() {
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
let (p, mut sh) = setup(&text, 600.0, 200.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
let l = lay(&p, &sh, 600.0, 200.0);
assert_eq!(l.font_scale, 1.0, "a body with no saved fontScale draws at 100%");
assert!(l.overflow, "it overflows, as PowerPoint draws it");
}
#[test]
fn exact_line_spacing_ends_at_the_last_descent() {
// 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text):
// the baseline at 80 % of the pitch, the text ending at the last line's descent.
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.line_spacing = Some(Spacing::Pts(26.59));
para.props.bullet = Some(Bullet::None);
for r in &mut para.runs {
r.props.size = Some(12.0);
}
}
let l = lay(&p, &sh, 600.0, 300.0);
let first = &l.lines[0];
assert!((first.baseline - first.top - 26.59 * 0.8).abs() < 0.01, "{:?}", first.baseline - first.top);
// The text ends at the last line's descent: above its full pitch by 20 % of it less the
// descent (~2.3 pt for 12 pt type).
let short = (l.lines[1].bottom - l.lines[0].top) - l.content_height;
assert!(short > 1.0 && short < 26.59 * 0.2, "{short}");
// Proportional spacing keeps full pitches.
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.line_spacing = Some(Spacing::Pct(1.0));
}
let l = lay(&p, &sh, 600.0, 300.0);
assert!((l.content_height - (l.lines[1].bottom - l.lines[0].top)).abs() < 0.01);
}
#[test]
fn line_spacing_reduction_comes_off_the_percentage() {
// 90 % line spacing and 10 pt before paragraphs, shrunk with a 20 % line spacing reduction:
// PowerPoint draws 70 % lines and keeps the 10 pt.
let (p, mut sh) = setup("One\nTwo\nThree", 600.0, 400.0);
for (i, para) in sh.text.as_mut().unwrap().paragraphs.iter_mut().enumerate() {
para.props.line_spacing = Some(Spacing::Pct(0.9));
para.props.space_before = Some(Spacing::Pts(10.0));
para.props.bullet = Some(Bullet::None);
if i == 0 {
para.runs[0].text.push_str(" wraps onto a second line because this paragraph is long enough to wrap in a narrow box");
}
}
let ctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let body = sh.text.clone().unwrap();
let bp = resolve::body(&ctx, &sh);
let opts = Opts { rect: Rect::new(0.0, 0.0, 200.0, 400.0), fields: &NoFields, prompt_color: None, no_shrink: true };
let full = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.0);
let reduced = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.2);
let pitch = |l: &TextLayout| l.lines[1].top - l.lines[0].top;
// Lines within the first paragraph: 0.7 / 0.9 of the full pitch.
assert!(full.lines[1].para == 0 && reduced.lines[1].para == 0);
assert!((pitch(&reduced) / pitch(&full) - 0.7 / 0.9).abs() < 0.005, "{} {}", pitch(&reduced), pitch(&full));
// The space before the next paragraph is the same 10 pt.
let gap = |l: &TextLayout| {
let k = l.lines.iter().position(|x| x.para == 1).unwrap();
l.lines[k].top - l.lines[k - 1].bottom
};
assert!((gap(&full) - 10.0).abs() < 1e-6 && (gap(&reduced) - 10.0).abs() < 1e-6, "{} {}", gap(&full), gap(&reduced));
}
#[test]
fn shrink_gives_up_at_once_when_even_the_smallest_scale_overflows() {
// Found by fuzzing: a tiny box with 20,000 paragraphs and shrink on overflow took 15 s to
// open, laying out the whole body 13 times in a search that could never fit.
let text = (0..400).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
let (p, sh) = setup(&text, 100.0, 20.0);
crate::LAYOUT_PASSES.with(|n| n.set(0));
let l = lay(&p, &sh, 100.0, 20.0);
assert!(l.overflow && l.font_scale <= 0.11, "scale {}", l.font_scale);
assert!(crate::LAYOUT_PASSES.with(|n| n.get()) <= 2, "{} passes", crate::LAYOUT_PASSES.with(|n| n.get()));
// Text that fits somewhere in the range still gets the search.
let (p, sh) = setup(&(0..12).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n"), 600.0, 200.0);
let l = lay(&p, &sh, 600.0, 200.0);
assert!(l.font_scale > 0.11 && l.font_scale < 1.0 && !l.overflow, "scale {} overflow {}", l.font_scale, l.overflow);
}
#[test]
fn space_before_the_first_paragraph_with_spc_first_last_para() {
// 10 pt before every paragraph: skipped on the first one, unless the body asks for it.
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.space_before = Some(Spacing::Pts(10.0));
para.props.space_after = Some(Spacing::Pts(6.0));
}
let plain = lay(&p, &sh, 600.0, 300.0);
sh.text.as_mut().unwrap().body.space_first_last = Some(true);
let both = lay(&p, &sh, 600.0, 300.0);
assert!((both.lines[0].top - plain.lines[0].top - 10.0).abs() < 1e-6);
assert!((both.content_height - plain.content_height - 16.0).abs() < 1e-6);
}
#[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);
}
/// #101: the text an input method is composing is underlined in place, under the baseline of each
/// line it occupies, and only over the composed characters.
#[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);
// Reversed ends give the same; an empty composition, nothing.
assert_eq!(l.composition_underlines(b, a), u);
assert!(l.composition_underlines(a, a).is_empty());
// Wrapped: one underline per line.
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:?}");
// Positions past the text never panic.
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.");
assert_eq!(autonum("alphaUcPeriod", u32::MAX).len(), 51); // 50 letters + '.'
}
#[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);
}
// ---------- Bidirectional text and Arabic typography ----------
const MIXED: &str = "سنة 2024 (Retrait) فقط";
/// A one-paragraph body with no bullet, `rtl` and `align` set.
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)
}
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
/// real Arabic glyphs are skipped without one.
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() {
// An Arabic run (PowerPoint tags typed Arabic `ar-…`): its digits and brackets go with the
// right-to-left text.
let mut run = Run::new(MIXED);
run.props.lang = Some("ar-SA".into());
let (p, sh) = bidi_setup(vec![run], 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;
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
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");
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
assert!(mid(0) > mid(1) && mid(1) > mid(2));
assert!(mid(4) < mid(5) && mid(5) < mid(7));
assert!(mid(10) < mid(16));
// The brackets sit outside "Retrait": "(" (logically first) on its right.
assert!(mid(9) > mid(16) && mid(17) < mid(10));
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
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);
// Levels after L1: Arabic odd, digits and Latin even.
assert_eq!(line.levels[0], 1);
assert_eq!(line.levels[4], 2);
assert_eq!(line.levels[10], 2);
}
#[test]
fn an_english_run_keeps_digits_and_brackets_left_to_right() {
// The same text in a run without a right-to-left language: PowerPoint keeps "2024 (Retrait) "
// one left-to-right stretch, its number on the left.
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
assert!(mid(0) > mid(10), "first Arabic word right of the Latin");
assert!(mid(4) < mid(10), "number left of the Latin word");
assert!(mid(9) < mid(10) && mid(17) > mid(16), "brackets around Retrait, unmirrored");
assert!(mid(19) < mid(4), "last Arabic word on the left");
}
#[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];
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
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");
// An LTR paragraph keeps the bullet on the left.
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}");
}
// Clicking just inside either side of each grapheme puts the caret on that side.
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");
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
// Moving left from the right edge in an RTL paragraph is logically forward.
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() {
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
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);
// No caret stop inside a grapheme.
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
}
#[test]
fn selection_is_the_union_of_visual_boxes() {
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 2, "{r:?}");
// The whole line is one box.
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");
// And the joined forms differ from the isolated letters.
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;
}
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
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");
// The last line isn't stretched and sits at the start (right) edge.
let last = l.lines.last().unwrap();
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
// Plain justify uses spaces: no tatweel.
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);
// Lines cover the text in logical order: the first line holds the first words.
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");
}
// Upstream affinity at a wrap keeps the caret on the earlier line.
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);
}
}
}
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
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() {
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
// letters apart ("s u p e r").
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);
}
}
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
/// x, second line's text x).
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() {
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
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}");
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
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}");
// A character bullet behaves the same.
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
// Without a bullet the first line just starts at the edge.
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() {
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
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);
// Each line is 1.2 × the size tall (the face is not compared across lines: another
// test's thread can load the family between them, see #252).
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);
}
}
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
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()
}
/// Width of `text` on one line.
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() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
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:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
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:?}");
// A slash followed by a space still breaks, and hyphens still break.
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:?}");
// Japanese and Chinese still break between characters.
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
// edge instead of pulling the character before it onto the next line.
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
// Without it, "。" can't start a line, so "す。" moves down together.
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() {
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
// even when the run's font has a plain glyph for some of them. Skipped without an installed
// colour emoji font.
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() {
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
// bundled face has ①.
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() {
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
// Wingdings keeps its own table (0xA7 is a small square).
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
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() {
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
// look-alike. Skipped without Wingdings 2.
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_font_char_keeps_covered_non_symbol_bullets() {
// `Bullet::Char.char` is unrestricted text; the PPTX reader stores `buChar` directly. A bullet
// such as U+2022 • must draw its own covered glyph, never a low-byte remap (0x22 → FOR ALL)
// to an unrelated Symbol code. Skipped when the face doesn't cover U+2022.
let db = FontDb::global();
if !db.has_family("Symbol") {
eprintln!("skipped: no Symbol font installed");
return;
}
let face = db.face("Symbol", "Regular");
if !face.covers('•') {
eprintln!("skipped: installed Symbol face lacks U+2022");
return;
}
assert_eq!(symbol_font_char(&face, "•", Some("Symbol")), Some('•'), "U+2022 draws itself, not a low-byte remap");
// A real symbol code still takes the mapping.
let f0de = String::from_utf8(vec![0xEF, 0x83, 0x9E]).unwrap(); // U+F0DE
assert_eq!(symbol_font_char(&face, &f0de, Some("Symbol")), symbol_font_draw(&face, 0xDE));
}
#[test]
fn symbol_char_maps_symbol_font_codes() {
// Inter is not a symbol font, so the codes map to look-alikes.
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_remaps_private_use_to_real_unicode() {
// Apple's "Symbol" on macOS has no private-use cmap: it draws the classic Symbol glyphs at
// their real Unicode points. A document's U+F0xx symbol characters must be remapped to those.
// Skipped without an installed Symbol face that actually draws the remapped character.
let db = FontDb::global();
if !db.has_family("Symbol") {
eprintln!("skipped: no Symbol font installed");
return;
}
let face = db.face("Symbol", "Regular");
// U+F0DE is the Symbol font's double arrow (⇒, U+21D2). The face must draw it at the real
// Unicode point — the PUA code itself only when the face has a private-use cmap (Windows).
let drawn = symbol_font_draw(&face, 0xDE);
if face.covers('\u{F0DE}') {
assert_eq!(drawn, Some('\u{F0DE}'));
} else {
assert_eq!(drawn, Some('\u{21D2}'), "Apple Symbol maps U+F0DE to the real double arrow");
}
}
#[test]
fn symbol_font_draw_falls_back_to_ascii() {
// 0x3C–0x3F are plain ASCII < = > ? in Adobe's symbol encoding (≤ ≥ ∞ sit at 0xA3/0xB3/0xA5 and
// ∴ at 0x5C), so a document's "a<b" set in Symbol must draw "a<b", not "a≤b". ASCII codes with no
// table entry draw themselves too: U+F031 is "1", U+F028 "(". Skipped without an installed
// Symbol face.
let db = FontDb::global();
if !db.has_family("Symbol") {
eprintln!("skipped: no Symbol font installed");
return;
}
let face = db.face("Symbol", "Regular");
for &code in &[0x3C, 0x3D, 0x3E, 0x3F, 0x31, 0x28, 0x30, 0x2B] {
let pua = char::from_u32(0xF000 | code).unwrap();
let ascii = char::from_u32(code).unwrap();
let drawn = symbol_font_draw(&face, code);
if face.covers(pua) {
// Windows Symbol covers its private-use block; that glyph wins.
assert_eq!(drawn, Some(pua), "the face's private-use glyph wins for 0x{code:02X}");
} else if face.covers(ascii) {
// Apple's Symbol (no PUA cmap) draws the ASCII character itself, never a remap of
// 0x3C–0x3F to ≤ ≥ ∞ ∴.
assert_eq!(drawn, Some(ascii), "Symbol code 0x{code:02X} must draw its ASCII character");
} else {
assert_eq!(drawn, None, "0x{code:02X} has no glyph in this Symbol face");
}
}
}
#[test]
fn symbol_font_draw_tries_apple_alternatives() {
// Apple's Symbol maps a few codes differently from Adobe: 0xA4 (fraction slash) to U+2044
// instead of Adobe's U+2215, and 0xE1/0xF1 (angle brackets) to U+3008/U+3009 instead of
// U+2329/U+232A. When the installed face doesn't cover the Adobe character, the alternative is
// tried before giving up. Skipped without an installed Symbol face.
let db = FontDb::global();
if !db.has_family("Symbol") {
eprintln!("skipped: no Symbol font installed");
return;
}
let face = db.face("Symbol", "Regular");
for &(code, adobe, alt) in &[(0xA4, '\u{2215}', '\u{2044}'), (0xE1, '\u{2329}', '\u{3008}'), (0xF1, '\u{232A}', '\u{3009}')] {
let pua = char::from_u32(0xF000 | code).unwrap();
let drawn = symbol_font_draw(&face, code);
if face.covers(pua) {
assert_eq!(drawn, Some(pua), "the face's private-use glyph wins for 0x{code:02X}");
} else if face.covers(adobe) {
assert_eq!(drawn, Some(adobe), "Adobe U+{:04X} wins for 0x{code:02X} when covered", adobe as u32);
} else {
assert_eq!(drawn, Some(alt), "Apple alternative U+{:04X} is tried for 0x{code:02X}", alt as u32);
}
}
}
#[test]
fn symbol_unicode_maps_the_euro() {
// Adobe symbol.txt: 20AC A0 # EURO SIGN. The euro is the one currency symbol in the encoding.
assert_eq!(symbol_unicode(0xA0), Some(0x20AC));
// Apple alternatives are tried in order for 0xA4/0xE1/0xF1.
assert_eq!(symbol_unicode_alts(0xA4), &[0x2044u32]);
assert_eq!(symbol_unicode_alts(0xE1), &[0x3008u32]);
assert_eq!(symbol_unicode_alts(0xF1), &[0x3009u32]);
assert_eq!(symbol_unicode_alts(0x44), &[] as &[u32]);
assert_eq!(symbol_unicode_alts(0x57), &[] as &[u32]);
assert_eq!(symbol_unicode_alts(0x6D), &[] as &[u32]);
}
/// Every entry of the Adobe Symbol encoding table, in full. Pure and platform-independent: the
/// table maps a Symbol code to its real Unicode character regardless of any installed font, so this
/// runs on every machine. Guarded against typos in `symbol_unicode` (e.g. 0x40 must be U+2245, the
/// congruent sign, not U+22C5 dot operator).
#[test]
fn symbol_unicode_table_covers_the_adobe_encoding() {
// (code, real Unicode) from unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt.
// Only the non-ASCII, non-CUS entries; ASCII codes pass through as themselves and CUS fragment
// glyphs have no standard Unicode point, so `symbol_unicode` returns None for them.
let table: &[(u32, u32)] = &[
// 0x20–0x7E: the few mathematical ones among the ASCII-range characters.
(0x22, 0x2200),
(0x24, 0x2203),
(0x27, 0x220B),
(0x2A, 0x2217),
(0x2D, 0x2212),
(0x40, 0x2245),
(0x5C, 0x2234),
(0x5E, 0x22A5),
(0x7E, 0x223C),
// 0x41–0x5A: Greek uppercase.
(0x41, 0x0391),
(0x42, 0x0392),
(0x43, 0x03A7),
(0x44, 0x0394),
(0x45, 0x0395),
(0x46, 0x03A6),
(0x47, 0x0393),
(0x48, 0x0397),
(0x49, 0x0399),
(0x4A, 0x03D1),
(0x4B, 0x039A),
(0x4C, 0x039B),
(0x4D, 0x039C),
(0x4E, 0x039D),
(0x4F, 0x039F),
(0x50, 0x03A0),
(0x51, 0x0398),
(0x52, 0x03A1),
(0x53, 0x03A3),
(0x54, 0x03A4),
(0x55, 0x03A5),
(0x56, 0x03C2),
(0x57, 0x03A9),
(0x58, 0x039E),
(0x59, 0x03A8),
(0x5A, 0x0396),
// 0x61–0x7A: Greek lowercase.
(0x61, 0x03B1),
(0x62, 0x03B2),
(0x63, 0x03C7),
(0x64, 0x03B4),
(0x65, 0x03B5),
(0x66, 0x03C6),
(0x67, 0x03B3),
(0x68, 0x03B7),
(0x69, 0x03B9),
(0x6A, 0x03D5),
(0x6B, 0x03BA),
(0x6C, 0x03BB),
(0x6D, 0x03BC),
(0x6E, 0x03BD),
(0x6F, 0x03BF),
(0x70, 0x03C0),
(0x71, 0x03B8),
(0x72, 0x03C1),
(0x73, 0x03C3),
(0x74, 0x03C4),
(0x75, 0x03C5),
(0x76, 0x03D6),
(0x77, 0x03C9),
(0x78, 0x03BE),
(0x79, 0x03C8),
(0x7A, 0x03B6),
// 0xA0–0xAF: euro, Greek upsilon with hook, primes, math, suits and arrows.
(0xA0, 0x20AC),
(0xA1, 0x03D2),
(0xA2, 0x2032),
(0xA3, 0x2264),
(0xA4, 0x2215),
(0xA5, 0x221E),
(0xA6, 0x0192),
(0xA7, 0x2663),
(0xA8, 0x2666),
(0xA9, 0x2665),
(0xAA, 0x2660),
(0xAB, 0x2194),
(0xAC, 0x2190),
(0xAD, 0x2191),
(0xAE, 0x2192),
(0xAF, 0x2193),
// 0xB0–0xBC: degree, plus-minus, double prime, math and ellipsis.
(0xB0, 0x00B0),
(0xB1, 0x00B1),
(0xB2, 0x2033),
(0xB3, 0x2265),
(0xB4, 0x00D7),
(0xB5, 0x221D),
(0xB6, 0x2202),
(0xB7, 0x2022),
(0xB8, 0x00F7),
(0xB9, 0x2260),
(0xBA, 0x2261),
(0xBB, 0x2248),
(0xBC, 0x2026),
(0xBF, 0x21B5),
// 0xC0–0xCF: letterlike symbols, set relations.
(0xC0, 0x2135),
(0xC1, 0x2111),
(0xC2, 0x211C),
(0xC3, 0x2118),
(0xC4, 0x2297),
(0xC5, 0x2295),
(0xC6, 0x2205),
(0xC7, 0x2229),
(0xC8, 0x222A),
(0xC9, 0x2283),
(0xCA, 0x2287),
(0xCB, 0x2284),
(0xCC, 0x2282),
(0xCD, 0x2286),
(0xCE, 0x2208),
(0xCF, 0x2209),
// 0xD0–0xDF: angle, nabla, product, radical, not, ands, ors, double arrows.
(0xD0, 0x2220),
(0xD1, 0x2207),
(0xD5, 0x220F),
(0xD6, 0x221A),
(0xD7, 0x22C5),
(0xD8, 0x00AC),
(0xD9, 0x2227),
(0xDA, 0x2228),
(0xDB, 0x21D4),
(0xDC, 0x21D0),
(0xDD, 0x21D1),
(0xDE, 0x21D2),
(0xDF, 0x21D3),
// 0xE0–0xF5: lozenge, angle brackets, summation, integral.
(0xE0, 0x25CA),
(0xE1, 0x2329),
(0xE5, 0x2211),
(0xF1, 0x232A),
(0xF2, 0x222B),
(0xF3, 0x2320),
(0xF5, 0x2321),
];
for &(code, uni) in table {
assert_eq!(symbol_unicode(code), Some(uni), "Symbol code 0x{code:02X} must map to U+{uni:04X}");
}
// ASCII codes pass through as themselves (symbol_unicode has no entry), and the CUS fragment
// codes return None: 0x60 radical extender, 0xBD/0xBE arrow extenders, 0xD2–0xD4 serif marks,
// 0xE2–0xE4 sans-serif marks and the paren/bracket extender run 0xE6–0xFF.
for code in [
0x20, 0x21, 0x23, 0x25, 0x26, 0x28, 0x29, 0x2B, 0x2C, 0x2E, 0x2F, 0x30, 0x31, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x5B, 0x5D, 0x5F,
0x7B, 0x7C, 0x7D,
] {
assert_eq!(symbol_unicode(code), None, "ASCII code 0x{code:02X} has no Symbol remap");
}
for code in [
0x60, 0xBD, 0xBE, 0xD2, 0xD3, 0xD4, 0xE2, 0xE3, 0xE4, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF4, 0xF6, 0xF7,
0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF,
] {
assert_eq!(symbol_unicode(code), None, "CUS code 0x{code:02X} has no standard Unicode remap");
}
}
#[test]
fn symbol_font_characters_draw_in_the_sym_font() {
// PowerPoint stores a Symbol-font character as U+F0xx with `<a:sym typeface="Symbol"/>`:
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
let symbol = "Symbol";
let db = FontDb::global();
// The installed Symbol face draws the code either at the private-use U+F0DE or, when it has no
// private-use mapping (Apple's Symbol on macOS maps glyphs to real Unicode), at the real
// character the Symbol font stores the code at. Skip only if neither is covered.
if !db.has_family(symbol) || !covers_symbol(&db.face(symbol, "Regular"), '\u{F0DE}') {
eprintln!("skipped: no installed Symbol face draws U+F0DE");
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:?}");
// The letters around it keep the run's font.
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
}
#[test]
fn small_capitals_draw_smaller_than_the_capitals_beside_them() {
let (p, mut sh) = setup("HxHx", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.caps = Some(Caps::Small);
para.runs[0].props.size = Some(40.0);
let l = lay(&p, &sh, 2000.0, 300.0);
let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect();
// H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter).
assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]);
}
#[test]
fn capitals_keep_characters_whose_capital_is_a_sequence() {
assert_eq!(caps_char('a'), 'A');
assert_eq!(caps_char('dž'), 'DŽ');
// PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps.
assert_eq!(caps_char('ß'), 'ß');
assert_eq!(caps_char('\u{FB01}'), '\u{FB01}');
assert_eq!(caps_char('ʼn'), 'ʼn');
let (p, mut sh) = setup("aß", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.runs[0].props.caps = Some(Caps::All);
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let face = &r.face;
if face.covers('ß') {
assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S");
}
}
#[test]
fn transparent_text_is_faded_once() {
let (p, mut sh) = setup("Faded", 2000.0, 300.0);
let run = &mut sh.text.as_mut().unwrap().paragraphs[0].runs[0];
run.props.fill = Some(deckcraft_model::Fill::solid(
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)).with(deckcraft_color::ColorTransform::Alpha(40000)),
));
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
// 60 % transparency: 40 % opaque in all, not 40 % of 40 %.
let opacity = r.color.a as f64 / 255.0 * r.alpha;
assert!((opacity - 0.4).abs() < 0.01, "{opacity}");
}
/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent.
fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout {
let (p, mut sh) = setup(text, w, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
for r in &mut para.runs {
r.props.size = Some(18.0);
}
let pp = &mut para.props;
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
pp.default_tab = Some(72.0);
pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect());
lay(&p, &sh, w, 300.0)
}
#[test]
fn tab_stops_align_the_text_after_them() {
let x0 = |l: &TextLayout| l.inner.x0;
// Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop.
let l = tabbed("a\tright", &[(200.0, "r")], 600.0);
assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent);
let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0);
let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1);
assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01);
let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0);
assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop");
// Text without a point ends at a decimal stop.
let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0);
assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01);
// Text wider than the room before a right stop starts where the tab is; it never moves back.
let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0);
assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01);
// A left stop and then default stops every 72 pt.
let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0);
assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01);
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
}
#[test]
fn a_tab_past_the_end_moves_to_the_next_line() {
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
// line, so the text after it lands on that line's first stop.
let l = tabbed("abcdefghij\tk", &[], 130.0);
assert_eq!(l.lines.len(), 2);
assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::<Vec<_>>());
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
}
/// Underline decorations of "ab cd" underlined `u` at 40 pt.
fn underline_decos(u: &str) -> (TextLayout, Vec<Deco>) {
let (p, mut sh) = setup("ab cd", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(40.0);
para.runs[0].props.underline = Some(u.into());
let l = lay(&p, &sh, 2000.0, 300.0);
let d = l.decos.iter().filter(|d| !d.behind).cloned().collect();
(l, d)
}
#[test]
fn underlines_sit_at_the_font_underline_in_their_style() {
let (l, sng) = underline_decos("sng");
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let k = 40.0 / r.face.upem;
let base = l.lines[0].baseline;
assert_eq!(sng.len(), 1);
assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position");
assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01);
let t = sng[0].rect.height();
// Double: two thin lines spanning from one thickness above the single line to its bottom.
let (_, dbl) = underline_decos("dbl");
assert_eq!(dbl.len(), 2);
assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01);
// Heavy is thicker; dotted is dots as long as the line is thick.
let (_, heavy) = underline_decos("heavy");
assert!(heavy[0].rect.height() > t * 1.4);
let (_, dotted) = underline_decos("dotted");
assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot));
let pieces = dotted[0].pieces();
assert!(pieces.len() > 3);
assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01);
// Wavy: a zigzag between the box's top and bottom.
let (_, wavy) = underline_decos("wavy");
let (pts, w) = wavy[0].wave().expect("a wave");
assert!(pts.len() > 4 && w > 0.0 && w < t);
assert!(pts[0].y < pts[1].y);
// "words" underlines the spaces too, as PowerPoint draws it: one line.
let (_, words) = underline_decos("words");
assert_eq!(words.len(), 1);
}
#[test]
fn no_break_after_a_zero_width_space_or_before_an_em_dash() {
let (allowed, _) = break_opportunities("ab\u{200B}cd", false);
assert!(!allowed[3], "PowerPoint does not break after a zero-width space");
let (allowed, _) = break_opportunities("ab\u{2014}cd", false);
assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one");
}
#[test]
fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() {
let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(24.0);
let l = lay(&p, &sh, 150.0, 300.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let shy = first.end - 1;
assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::<Vec<_>>());
let (x0, x1) = first.edges[shy - first.start];
assert!(x1 - x0 > 2.0, "the hyphen takes room");
let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen");
assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-'));
// Soft hyphens draw only where a line ends; in a wide box, nowhere.
let ends: Vec<usize> = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect();
for r in &l.runs {
for (g, c) in r.glyphs.iter().zip(&r.cells) {
assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn");
}
}
let wide = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(wide.lines.len(), 1);
assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-')))));
}
#[test]
fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() {
// "っ" and "ー" never start a line: the character before them moves down too.
let w = one_line("あいうえお") + 0.5;
for (text, first) in [("あいうえおっかき", "あいうえ"), ("あいうえおーかき", "あいうえ"), ("あいうえおかき", "あいうえお")]
{
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], first, "{text}: {lines:?}");
}
// A closing bracket doesn't hang like "。" does: "「。」" overflowing by "」" moves down whole.
let w = one_line("あいうえ「。") + 0.5;
let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "あいうえ", "{lines:?}");
}
#[test]
fn autonumber_schemes_follow_powerpoint() {
// As PowerPoint's export draws them (start values as in the probe deck).
for (scheme, n, want) in [
("ea1ChsPlain", 4, "四"),
("ea1ChsPeriod", 10, "十."),
("ea1ChtPeriod", 21, "二十一."),
("ea1JpnKorPeriod", 10, "一〇."),
("ea1JpnChsDbPeriod", 13, "一三."),
("arabicDbPlain", 10, "10"),
("arabicDbPeriod", 13, "13."),
("circleNumDbPlain", 13, "⑬"),
("circleNumWdWhitePlain", 13, "③"),
("circleNumWdBlackPlain", 2, "❷"),
("thaiAlphaPeriod", 1, "ก."),
("thaiAlphaParenR", 4, "ง)"),
("thaiAlphaParenBoth", 7, "(ช)"),
("thaiNumPeriod", 10, "๑๐."),
("hindiAlphaPeriod", 4, "ई."),
("hindiAlpha1Period", 1, "क."),
("hindiNumParenR", 13, "१३)"),
("hebrew2Minus", 1, "א-"),
("arabic1Minus", 13, "ش-"),
("arabic2Minus", 10, "ي-"),
("arabicPlain", 10, "10"),
] {
assert_eq!(autonum(scheme, n), want, "{scheme} {n}");
}
assert_eq!(chinese_num(105), "一百零五");
assert_eq!(chinese_num(20), "二十");
// Past the end of an alphabet the letter doubles, like "aa".
assert_eq!(autonum("hebrew2Minus", 23), "אא-");
assert_eq!(autonum("thaiAlphaPeriod", 0), ".");
}
#[test]
fn east_asian_vertical_text_marks_cjk_upright() {
let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical);
let l = lay(&p, &sh, 300.0, 600.0);
assert_eq!(l.rotation, 90.0);
let upright: Vec<bool> = l
.runs
.iter()
.filter(|r| !r.cells.is_empty())
.flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright)))
.filter(|(c, _)| *c != ' ')
.map(|(_, u)| u)
.collect();
assert_eq!(upright, vec![true, true, true, false, false, false, false, false]);
// In vert nothing stands upright.
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
let l = lay(&p, &sh, 300.0, 600.0);
assert!(l.runs.iter().all(|r| !r.upright));
assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1'));
}
#[test]
fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() {
let (p, mut sh) = setup("one\ntwo", 200.0, 400.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
let v = lay(&p, &sh, 200.0, 400.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical);
let m = lay(&p, &sh, 200.0, 400.0);
assert_eq!(m.rotation, 90.0);
// In layout space (turned a quarter turn) vert puts the first line at the top, which shows on
// the right; mongolianVert mirrors the line places, so the first line shows on the left.
assert!(v.lines[0].top < v.lines[1].top);
assert!(m.lines[0].top > m.lines[1].top);
let mid = (m.inner.y0 + m.inner.y1) / 2.0;
assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01);
// Stacked: every letter upright, one em apart.
let (p, mut sh) = setup("Lil", 200.0, 400.0);
let t = sh.text.as_mut().unwrap();
t.body.vert = Some(deckcraft_model::text::TextDir::Stacked);
t.paragraphs[0].runs[0].props.size = Some(20.0);
let s = lay(&p, &sh, 200.0, 400.0);
assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
// Its lines run left to right too.
let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0);
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked);
let st = lay(&p2, &sh2, 200.0, 400.0);
assert!(st.lines[0].top > st.lines[1].top);
// wordArtVertRtl stacks the same way with lines from right to left.
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl);
let sr = lay(&p2, &sh2, 200.0, 400.0);
assert!(sr.lines[0].top < sr.lines[1].top);
assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
let e = &s.lines[0].edges;
assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}");
}
#[test]
fn symbols_after_an_emoji_keep_a_text_face() {
// "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text.
let db = FontDb::global();
if !db.cascade(&['✅'], &[], "Regular").is_color() {
return;
}
let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
for r in &l.runs {
for (_, c) in &r.cells {
assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family);
}
}
}
#[test]
fn fallback_faces_do_not_move_the_baseline() {
// PowerPoint keeps a line's baseline and pitch from the run's own font when an emoji, a symbol
// or a script the font lacks is drawn from another face.
let base = |text: &str| {
let (p, sh) = bidi_setup(vec![Run::new(text)], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
(l.lines[0].baseline, l.lines[0].bottom)
};
let plain = base("Hxg Hxg");
for text in ["Hxg \u{1F600} Hxg", "Hxg \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}", "Hxg \u{222B}\u{2211}"] {
let b = base(text);
assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}");
}
}
#[test]
fn strikethrough_sits_at_the_font_strikeout_position() {
let (p, mut sh) = setup("Struck", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(96.0);
para.runs[0].props.strike = Some(Strike::Single);
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let k = 96.0 / r.face.upem;
let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough");
assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position");
assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01);
}
#[test]
fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() {
let text = "word ".repeat(200);
let (p, mut sh) = setup(&text, 400.0, 200.0);
let t = sh.text.as_mut().unwrap();
t.body.columns = Some(2);
t.body.col_spacing = Some(20.0);
t.paragraphs[0].runs[0].props.size = Some(12.0);
let ltr = lay(&p, &sh, 400.0, 200.0);
sh.text.as_mut().unwrap().body.rtl_col = Some(true);
let rtl = lay(&p, &sh, 400.0, 200.0);
let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap();
let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap();
assert!(first(&ltr) < second(&ltr));
assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right");
// anchor="just" and "dist" draw like "b".
let (p, mut sh) = setup("one line", 400.0, 300.0);
sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom);
let b = lay(&p, &sh, 400.0, 300.0).lines[0].top;
for a in [Anchor::Justified, Anchor::Distributed] {
sh.text.as_mut().unwrap().body.anchor = Some(a);
assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}");
}
}
#[test]
fn stacked_combining_marks_take_a_face_that_draws_them() {
// A letter with marks the run's font lacks draws in a face that has them all (no boxes).
let db = FontDb::global();
let cluster = ['Z', '\u{0334}', '\u{0321}', '\u{031B}'];
let run_face = db.face("Inter", "Regular");
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
if !deckcraft_fonts::covers_cluster(&run_face, &cluster) && ["DejaVu Sans", "Segoe UI"].iter().any(|fam| db.has_family(fam)) {
// Make sure a face with the marks is loaded, then the cluster must take one.
for fam in ["DejaVu Sans", "Segoe UI"] {
if db.has_family(fam) {
let _ = db.face(fam, "Regular");
}
}
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
assert!(deckcraft_fonts::covers_cluster(&f, &cluster), "{} lacks the marks", f.family);
}
let _ = f;
}
/// #736: `anchorCtr` centres the text block horizontally, whatever the paragraphs' own
/// alignment, and overflowing content centres symmetrically past both edges.
#[test]
fn anchorctr_centres_the_text_block() {
let text = "The shape is top - center - overriding paragraph right alignment.";
// The block's centre: (min left + max right) over the laid-out lines, in box coordinates.
let block_centre = |l: &TextLayout| -> f64 {
// Empty lines carry no ink and sit where their alignment left them: not part of the block.
let ink = l.lines.iter().filter(|li| li.end > li.start);
let (min, max) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1)));
(min + max) / 2.0
};
let para = |align: Option<Align>, text: &str| Paragraph {
props: ParaProps { align, ..Default::default() },
runs: vec![Run::new(text)],
..Default::default()
};
let anchored =
|paragraphs: Vec<Paragraph>| TextBody { body: BodyProps { anchor_ctr: Some(true), ..Default::default() }, paragraphs, ..Default::default() };
// One right-aligned line in a wide box: the block is centred, not pushed past the right edge.
let body_right = anchored(vec![para(Some(Align::Right), text)]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_right);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "right-aligned: centre {}, want 414", block_centre(&l));
// Left-aligned text centres the same way.
let body_left = anchored(vec![para(None, text)]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_left);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "left-aligned: centre {}, want 414", block_centre(&l));
// A trailing empty paragraph carries no ink and must not skew the block.
let body_empty = anchored(vec![para(Some(Align::Right), text), para(None, "")]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_empty);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "trailing empty paragraph: centre {}, want 414", block_centre(&l));
// Mixed alignments: the drawn block (not either alignment edge) is what centres.
let body_mixed = anchored(vec![para(Some(Align::Right), text), para(None, "short")]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_mixed);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "mixed: centre {}, want 414", block_centre(&l));
// Content wider than the box overflows symmetrically past both edges (no wrap).
let body_wide = TextBody {
body: BodyProps { anchor_ctr: Some(true), wrap: Some(false), ..Default::default() },
paragraphs: vec![para(Some(Align::Right), text)],
..Default::default()
};
let (p, mut sh) = setup("", 300.0, 104.0);
sh.text = Some(body_wide);
let l = lay(&p, &sh, 300.0, 104.0);
assert!((block_centre(&l) - 150.0).abs() <= 2.0, "overflowing: centre {}, want 150", block_centre(&l));
// Control: without anchorCtr a right-aligned line still ends at the box's right edge.
let body_control = TextBody { body: BodyProps::default(), paragraphs: vec![para(Some(Align::Right), text)], ..Default::default() };
let (p, mut sh) = setup(text, 828.0, 104.0);
sh.text = Some(body_control);
let l = lay(&p, &sh, 828.0, 104.0);
assert!(
l.lines.iter().all(|li| li.extent.1 > 810.0),
"control: right-aligned at the box edge, got {:?}",
l.lines.iter().map(|li| li.extent).collect::<Vec<_>>()
);
}
#[test]
fn empty_bullets_reserve_the_typed_indent_without_becoming_content() {
for rtl in [false, true] {
for level in [0, 1, 2] {
let (p, mut sh) = setup("First\n", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.level = level;
para.props.rtl = Some(rtl);
para.props.align = Some(if rtl { Align::Right } else { Align::Left });
}
let empty = lay(&p, &sh, 600.0, 300.0);
assert_eq!(empty.empty_bullets.len(), 1);
assert_eq!(empty.empty_bullets[0].para, 1);
assert!(empty.runs.iter().all(|r| r.para == 0), "empty bullet must not be exported");
let caret = empty.caret(Pos { para: 1, ch: 0 }).unwrap();
let bx = empty.empty_bullets[0].glyphs[0].1;
assert!(if rtl { bx >= caret.0 } else { bx < caret.0 });
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (1, 0), if rtl { "אבג" } else { "Next" }, None);
let typed = lay(&p, &sh, 600.0, 300.0);
assert!(typed.empty_bullets.is_empty());
let after = typed.caret(Pos { para: 1, ch: 0 }).unwrap();
assert!(
(caret.0 - after.0).abs() < 1e-6 && (caret.1 - after.1).abs() < 1e-6 && (caret.2 - after.2).abs() < 1e-6,
"typing must not move the line's start: {caret:?} vs {after:?}"
);
}
}
}
#[test]
fn empty_numbered_paragraph_previews_the_next_number_without_consuming_it() {
let (p, mut sh) = setup("One\n\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 4 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let preview = &l.empty_bullets[0];
let next = l.runs.iter().find(|r| r.para == 2 && r.cells.is_empty()).unwrap();
let ids = |r: &GlyphRun| r.glyphs.iter().map(|g| g.0).collect::<Vec<_>>();
assert_eq!(preview.para, 1);
assert_eq!(ids(preview), ids(next), "both should use 5.");
let expected = measure_str(&preview.face, preview.size, "5.").1;
assert_eq!(ids(preview), expected.iter().map(|g| g.0).collect::<Vec<_>>());
}
#[test]
fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
let (p, mut sh) = setup("", 600.0, 300.0);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
assert!(lay(&p, &sh, 600.0, 300.0).empty_bullets.is_empty());
sh.text = Some(TextBody::from_text("First"));
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (0, 5), "\u{b}", None);
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!(l.lines.len(), 2);
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
}
/// A run of `text` at `size` points.
fn sized_run(text: &str, size: f64) -> Run {
let mut r = Run::new(text);
r.props.size = Some(size);
r
}
/// A line break whose own run properties are `size` points.
fn sized_break(size: f64) -> Run {
let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break };
r.props.size = Some(size);
r
}
/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line
/// tests measure against, so they do not depend on the test font's ascent and descent.
fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 {
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }];
let l = lay(p, sh, 400.0, 400.0);
l.lines[0].bottom - l.lines[0].top
}
#[test]
fn an_empty_line_is_sized_by_the_break_that_ends_it() {
// PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first
// run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so
// the empty second line is a 10pt line and not a 40pt one.
let (p, mut sh) = setup("x", 400.0, 400.0);
let (h10, h40, h60, h20) = (
one_line_height(&p, &mut sh, 10.0),
one_line_height(&p, &mut sh, 40.0),
one_line_height(&p, &mut sh, 60.0),
one_line_height(&p, &mut sh, 20.0),
);
let put = |sh: &mut Shape, runs: Vec<Run>| {
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }];
};
put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]);
let a = lay(&p, &sh, 400.0, 400.0);
assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::<Vec<_>>());
let empty_a = a.lines[2].top - a.lines[1].top;
assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}");
assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
// Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line
// down, the opposite direction from the first run's size -- so this cannot pass by the old
// behaviour under a different font.
put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]);
let c = lay(&p, &sh, 400.0, 400.0);
let empty_c = c.lines[2].top - c.lines[1].top;
assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}");
assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})");
}
#[test]
fn an_empty_last_line_is_sized_by_the_paragraph_mark() {
// Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break
// closes the empty last line, so it takes the mark's size.
let (p, mut sh) = setup("x", 400.0, 400.0);
let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0));
{
let t = sh.text.as_mut().unwrap();
let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() };
para.end_props.size = Some(10.0);
t.paragraphs = vec![para];
}
let l = lay(&p, &sh, 400.0, 400.0);
assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line");
let h = l.lines[1].bottom - l.lines[1].top;
assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}");
assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
}
#[test]
fn an_empty_paragraph_keeps_its_paragraph_mark_height() {
// Case D, the control: an empty *paragraph* already took `end_props` before this change,
// through `lead`, and must be unaffected by it.
let (p, mut sh) = setup("x", 400.0, 400.0);
let h10 = one_line_height(&p, &mut sh, 10.0);
{
let t = sh.text.as_mut().unwrap();
let mut empty = Paragraph::default();
empty.end_props.size = Some(10.0);
t.paragraphs = vec![
Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() },
empty,
Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() },
];
}
let l = lay(&p, &sh, 400.0, 400.0);
assert_eq!(l.lines.len(), 3);
let h = l.lines[1].bottom - l.lines[1].top;
assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}");
}