cargo-makepad, makepad-builder (with an Android step on every OS), CI tools and tiles, and the remaining libraries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
341 lines
15 KiB
Rust
341 lines
15 KiB
Rust
//! One test per construct: what the layout puts where, and that every item
|
|
//! carries its part.
|
|
|
|
use makepad_latex_math::{layout, parse, LayoutGlyph, LayoutItem, LayoutOutput, LayoutRule, MathStyle, NO_PART};
|
|
|
|
const FONT: &[u8] = include_bytes!("../fonts/NewCMMath-Regular.otf");
|
|
|
|
fn lay(tex: &str, style: MathStyle) -> LayoutOutput {
|
|
layout(&parse(tex), FONT, 100.0, style).expect("layout")
|
|
}
|
|
|
|
fn display(tex: &str) -> LayoutOutput {
|
|
lay(tex, MathStyle::Display)
|
|
}
|
|
|
|
fn glyphs(o: &LayoutOutput) -> Vec<&LayoutGlyph> {
|
|
o.items.iter().filter_map(|i| if let LayoutItem::Glyph(g) = i { Some(g) } else { None }).collect()
|
|
}
|
|
|
|
fn rules(o: &LayoutOutput) -> Vec<&LayoutRule> {
|
|
o.items.iter().filter_map(|i| if let LayoutItem::Rule(r) = i { Some(r) } else { None }).collect()
|
|
}
|
|
|
|
fn glyph_of<'a>(o: &'a LayoutOutput, ch: char) -> &'a LayoutGlyph {
|
|
glyphs(o).into_iter().find(|g| g.ch == ch).unwrap_or_else(|| panic!("no glyph for {ch:?}"))
|
|
}
|
|
|
|
fn part_path(o: &LayoutOutput, g: &LayoutGlyph) -> String {
|
|
o.parts[g.part as usize].path_string()
|
|
}
|
|
|
|
#[test]
|
|
fn fraction_bar_sits_between_numerator_and_denominator() {
|
|
let o = display(r"\frac{a}{b}");
|
|
let bar = rules(&o)[0];
|
|
let (a, b) = (glyph_of(&o, 'a'), glyph_of(&o, 'b'));
|
|
assert!(a.y < bar.y, "numerator baseline {} above bar {}", a.y, bar.y);
|
|
assert!(b.y > bar.y + bar.height, "denominator baseline {} below bar {}", b.y, bar.y);
|
|
// both centered on the bar
|
|
let mid = bar.x + bar.width * 0.5;
|
|
assert!((a.x - mid).abs() < 40.0 && (b.x - mid).abs() < 40.0);
|
|
}
|
|
|
|
#[test]
|
|
fn script_and_radical_and_accent() {
|
|
let o = display("x^2_i");
|
|
let (x, two, i) = (glyph_of(&o, 'x'), glyph_of(&o, '2'), glyph_of(&o, 'i'));
|
|
assert!(two.y < x.y && i.y > x.y && two.size < x.size);
|
|
|
|
// radical with an index: the index glyph sits left of and above the radicand
|
|
let o = display(r"\sqrt[3]{x+1}");
|
|
let (idx, rad) = (glyph_of(&o, '3'), glyph_of(&o, 'x'));
|
|
assert!(idx.x < rad.x && idx.size < rad.size);
|
|
assert_eq!(rules(&o).len(), 1, "one overbar");
|
|
assert!(glyph_of(&o, '\u{221A}').x < rad.x);
|
|
|
|
let o = display(r"\hat{x}");
|
|
let (acc, base) = (glyph_of(&o, '\u{302}'), glyph_of(&o, 'x'));
|
|
assert!(acc.x != base.x || acc.y != base.y);
|
|
let o = display(r"\bar{x}");
|
|
assert!(glyphs(&o).iter().any(|g| g.ch == '\u{304}'));
|
|
}
|
|
|
|
#[test]
|
|
fn display_sum_puts_limits_above_and_below() {
|
|
let o = display(r"\sum_{k=1}^{N} k");
|
|
let sum = glyph_of(&o, '\u{2211}');
|
|
let n = glyph_of(&o, 'N');
|
|
let one = glyph_of(&o, '1');
|
|
assert!(n.y < sum.y - 20.0, "upper limit above the sum");
|
|
assert!(one.y > sum.y + 20.0, "lower limit below the sum");
|
|
// centered over the sum, not beside it
|
|
assert!(n.x < sum.x + 80.0 && n.x + 10.0 > sum.x - 40.0, "N over the sum, x {} vs {}", n.x, sum.x);
|
|
// inline style: beside
|
|
let o = lay(r"\sum_{k=1}^{N} k", MathStyle::Text);
|
|
let (sum, n) = (glyph_of(&o, '\u{2211}'), glyph_of(&o, 'N'));
|
|
assert!(n.x > sum.x + 30.0);
|
|
}
|
|
|
|
#[test]
|
|
fn integral_limits_sit_beside_the_sign() {
|
|
let o = display(r"\int_{0}^{1} f");
|
|
let int = glyph_of(&o, '\u{222B}');
|
|
let one = glyph_of(&o, '1');
|
|
let zero = glyph_of(&o, '0');
|
|
assert!(one.x > int.x + 30.0, "upper limit to the right: {} vs {}", one.x, int.x);
|
|
assert!(zero.x > int.x + 20.0, "lower limit to the right too");
|
|
assert!(one.y < int.y && zero.y > int.y);
|
|
// the upper limit is further right than the lower (italic correction)
|
|
assert!(one.x >= zero.x);
|
|
}
|
|
|
|
#[test]
|
|
fn delimiters_grow_with_their_content() {
|
|
let small = display(r"\left( x \right)");
|
|
let tall = display(r"\left( \frac{\frac{a}{b}}{\frac{c}{d}} \right)");
|
|
let paren = |o: &LayoutOutput| -> f32 {
|
|
let p: Vec<_> = glyphs(o).into_iter().filter(|g| g.ch == '(').collect();
|
|
assert!(!p.is_empty());
|
|
p.iter().map(|g| g.size).fold(0.0, f32::max)
|
|
};
|
|
assert_eq!(paren(&small), paren(&tall));
|
|
let small_glyph = glyph_of(&small, '(').glyph_id;
|
|
let tall_glyph = glyph_of(&tall, '(').glyph_id;
|
|
assert_ne!(small_glyph, tall_glyph, "a taller variant is picked");
|
|
// the grown delimiter spans the content: the tall formula's height exceeds the small one's
|
|
assert!(tall.height > small.height * 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_very_tall_delimiter_is_assembled_from_parts() {
|
|
let rows = r"a\\b\\c\\d\\e\\f\\g\\h\\i\\j";
|
|
let o = display(&format!(r"\left[ \begin{{matrix}} {rows} \end{{matrix}} \right]"));
|
|
let parts: Vec<_> = glyphs(&o).into_iter().filter(|g| g.ch == '[').collect();
|
|
assert!(parts.len() >= 3, "end pieces plus extenders, got {}", parts.len());
|
|
// the pieces stack without gaps: sorted by y they cover the formula height
|
|
let top = parts.iter().map(|g| g.y).fold(f32::MAX, f32::min);
|
|
let bottom = parts.iter().map(|g| g.y).fold(f32::MIN, f32::max);
|
|
assert!(bottom - top > o.height * 0.6, "stack spans {} of {}", bottom - top, o.height);
|
|
// one part for the whole delimiter
|
|
let first = parts[0].part;
|
|
assert!(parts.iter().all(|g| g.part == first));
|
|
}
|
|
|
|
#[test]
|
|
fn matrix_aligned_and_cases() {
|
|
let o = display(r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}");
|
|
let (a, b, c) = (glyph_of(&o, 'a'), glyph_of(&o, 'b'), glyph_of(&o, 'c'));
|
|
assert!(b.x > a.x && c.y > a.y && (a.x - c.x).abs() < 30.0);
|
|
assert!(glyphs(&o).iter().any(|g| g.ch == '('));
|
|
assert!(part_path(&o, b).contains("row:0") && part_path(&o, b).contains("col:1"));
|
|
|
|
let o = display(r"\begin{aligned} x &= 1 \\ yy &= 22 \end{aligned}");
|
|
let eqs: Vec<_> = glyphs(&o).into_iter().filter(|g| g.ch == '=').collect();
|
|
assert_eq!(eqs.len(), 2);
|
|
assert!((eqs[0].x - eqs[1].x).abs() < 1.0, "the = signs line up: {} {}", eqs[0].x, eqs[1].x);
|
|
|
|
let o = display(r"\begin{cases} 1 & x > 0 \\ 0 & \text{else} \end{cases}");
|
|
assert!(glyphs(&o).iter().any(|g| g.ch == '{'));
|
|
let ones: Vec<_> = glyphs(&o).into_iter().filter(|g| g.ch == '1' || g.ch == '0').collect();
|
|
assert!((ones[0].x - ones[2].x).abs() < 1.0, "first column left aligned");
|
|
}
|
|
|
|
#[test]
|
|
fn symbols_names_and_text_have_glyphs() {
|
|
let o = display(r"\alpha \beta \infty \partial \pm \cdot \to \approx \leq \geq \sin x \text{if}");
|
|
for ch in ['\u{3B1}', '\u{3B2}', '\u{221E}', '\u{2202}', '\u{B1}', '\u{22C5}', '\u{2192}', '\u{2248}', '\u{2264}', '\u{2265}', 's', 'i', 'n'] {
|
|
glyph_of(&o, ch);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_item_carries_a_part_and_ids_do_not_move_anything() {
|
|
let plain = display(r"\frac{a+b}{c}");
|
|
let named = display(r"\frac{\id{top}{a+b}}{c}");
|
|
for o in [&plain, &named] {
|
|
assert!(o.items.iter().all(|i| i.part() != NO_PART && (i.part() as usize) < o.parts.len()));
|
|
}
|
|
// \id changes no position
|
|
let pos = |o: &LayoutOutput| glyphs(o).iter().map(|g| (g.glyph_id, g.x, g.y)).collect::<Vec<_>>();
|
|
assert_eq!(pos(&plain), pos(&named));
|
|
assert_eq!(plain.width, named.width);
|
|
// the named part is addressable and holds exactly a, + and b
|
|
let in_top: Vec<char> = glyphs(&named).into_iter().filter(|g| named.parts[g.part as usize].ids == ["top"]).map(|g| g.ch).collect();
|
|
assert_eq!(in_top.len(), 3, "{in_top:?}");
|
|
// paths tell the numerator from the denominator
|
|
let a = glyph_of(&named, 'a');
|
|
let c = glyph_of(&named, 'c');
|
|
assert!(part_path(&named, a).contains("num"), "{}", part_path(&named, a));
|
|
assert!(part_path(&named, c).contains("den"), "{}", part_path(&named, c));
|
|
// the bar belongs to the fraction node, not to either slot
|
|
let bar_part = &named.parts[match named.items.iter().find(|i| matches!(i, LayoutItem::Rule(_))).unwrap() { LayoutItem::Rule(r) => r.part, _ => unreachable!() } as usize];
|
|
assert!(!bar_part.path_string().contains("num") && !bar_part.path_string().contains("den"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_id_on_a_big_operator_keeps_its_limits() {
|
|
let a = display(r"\sum_{k}^{N} k");
|
|
let b = display(r"\id{s}{\sum}_{k}^{N} k");
|
|
let pos = |o: &LayoutOutput| glyphs(o).iter().map(|g| (g.glyph_id, g.x, g.y)).collect::<Vec<_>>();
|
|
assert_eq!(pos(&a), pos(&b));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_fractions_keep_their_bars_apart() {
|
|
let o = display(r"\frac{\frac{a}{b}}{\frac{c}{d}}");
|
|
let mut bars: Vec<_> = rules(&o).into_iter().map(|r| (r.y, r.height)).collect();
|
|
bars.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
|
assert_eq!(bars.len(), 3);
|
|
let (top, main, bottom) = (bars[0], bars[1], bars[2]);
|
|
// the inner denominator b sits above the main bar, the inner numerator c below it
|
|
let (b, c) = (glyph_of(&o, 'b'), glyph_of(&o, 'c'));
|
|
assert!(b.y < main.0 - 5.0, "b baseline {} above the main bar {}", b.y, main.0);
|
|
assert!(c.y - c.size * 0.45 > main.0 + main.1, "c top below the main bar");
|
|
assert!(top.0 < main.0 && main.0 < bottom.0);
|
|
}
|
|
|
|
#[test]
|
|
fn minus_is_the_minus_sign_and_integral_scripts_tuck_in() {
|
|
let o = display("a-b");
|
|
glyph_of(&o, '-');
|
|
assert_eq!(glyphs(&o).iter().filter(|g| g.ch == '-').count(), 1);
|
|
// the sign in the font is U+2212: its glyph id is not the hyphen's
|
|
let hyphen = layout(&parse("-"), FONT, 100.0, MathStyle::Display).unwrap();
|
|
let ids: Vec<u16> = glyphs(&hyphen).iter().map(|g| g.glyph_id).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
// the integral's lower limit starts left of its upper one
|
|
let o = display(r"\int_{a}^{b}");
|
|
assert!(glyph_of(&o, 'a').x < glyph_of(&o, 'b').x);
|
|
}
|
|
|
|
#[test]
|
|
fn aligned_columns_after_spaces() {
|
|
let o = display("\\begin{aligned} x &= 1 \\\\ y &\\ge 2 \\end{aligned}");
|
|
assert!(glyphs(&o).iter().all(|g| g.ch != '&'), "no stray & in the output");
|
|
}
|
|
|
|
/// Ink box (left, top, right, bottom) of a placed glyph, layout units, y down.
|
|
fn ink(g: &LayoutGlyph) -> (f32, f32, f32, f32) {
|
|
let ol = makepad_latex_math::glyph_outline(FONT, g.glyph_id, g.size).expect("outline");
|
|
let mut b = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
|
let mut at = |x: f32, y: f32| {
|
|
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
|
};
|
|
for s in ol {
|
|
use makepad_latex_math::OutlineSeg::*;
|
|
match s {
|
|
MoveTo(x, y) | LineTo(x, y) => at(x, y),
|
|
QuadTo(a, c, x, y) => {
|
|
at(a, c);
|
|
at(x, y)
|
|
}
|
|
CurveTo(a, c, d, e, x, y) => {
|
|
at(a, c);
|
|
at(d, e);
|
|
at(x, y)
|
|
}
|
|
Close => {}
|
|
}
|
|
}
|
|
(g.x + b.0, g.y + b.1, g.x + b.2, g.y + b.3)
|
|
}
|
|
|
|
#[test]
|
|
fn integral_limits_sit_at_the_signs_top_right_and_bottom_right() {
|
|
for style in [MathStyle::Display, MathStyle::Text] {
|
|
let o = lay(r"\int_{a}^{b}", style);
|
|
let sign = ink(glyph_of(&o, '\u{222B}'));
|
|
let (a, b) = (ink(glyph_of(&o, 'a')), ink(glyph_of(&o, 'b')));
|
|
let em = 100.0;
|
|
// the upper limit starts just right of the sign's ink, level with its top half
|
|
let gap = b.0 - sign.2;
|
|
assert!(gap > -0.05 * em && gap < 0.2 * em, "{style:?}: upper limit gap {gap}");
|
|
assert!(b.3 < (sign.1 + sign.3) * 0.5, "{style:?}: upper limit in the sign's top half");
|
|
// the lower limit is tucked in toward the sign, in its bottom half, left of the upper one
|
|
assert!(a.0 < b.0 - 0.1 * em, "{style:?}: lower {} left of upper {}", a.0, b.0);
|
|
assert!(a.0 > sign.0, "{style:?}: lower limit not left of the sign");
|
|
assert!(a.1 > (sign.1 + sign.3) * 0.5, "{style:?}: lower limit in the sign's bottom half");
|
|
assert!(a.0 - sign.2 < 0.2 * em, "{style:?}: lower limit not far from the sign");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unary_minus_is_ordinary_and_binary_minus_is_spaced() {
|
|
let o = display("a-b");
|
|
let (a, m, b) = (ink(glyph_of(&o, 'a')), ink(glyph_of(&o, '-')), ink(glyph_of(&o, 'b')));
|
|
let binary_gap = b.0 - m.2;
|
|
assert!(m.0 - a.2 > 0.15 * 100.0 && binary_gap > 0.15 * 100.0, "binary minus keeps its spaces");
|
|
// start of a list, after a relation or an opening delimiter
|
|
for tex in [r"-\infty", "a=-b", "(-b)", "a,-b"] {
|
|
let o = display(tex);
|
|
let minus = glyphs(&o).into_iter().find(|g| g.ch == '-').unwrap();
|
|
let next = glyphs(&o).into_iter().filter(|g| g.x > minus.x).min_by(|p, q| p.x.partial_cmp(&q.x).unwrap()).unwrap();
|
|
let (m, n) = (ink(minus), ink(next));
|
|
assert!(n.0 - m.2 < binary_gap - 0.1 * 100.0, "{tex}: unary minus gap {} vs binary {binary_gap}", n.0 - m.2);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn script_styles_drop_medium_and_thick_spaces() {
|
|
let em = 70.0;
|
|
let o = display("x^{a=1}");
|
|
let (a, eq, one) = (ink(glyph_of(&o, 'a')), ink(glyph_of(&o, '=')), ink(glyph_of(&o, '1')));
|
|
assert!(eq.0 - a.2 < 0.2 * em && one.0 - eq.2 < 0.2 * em, "relation in a script: {} {}", eq.0 - a.2, one.0 - eq.2);
|
|
let o = display("x^{a+b}");
|
|
let (a, p) = (ink(glyph_of(&o, 'a')), ink(glyph_of(&o, '+')));
|
|
assert!(p.0 - a.2 < 0.2 * em, "binary in a script: {}", p.0 - a.2);
|
|
// at text size the same relation is spaced
|
|
let o = display("a=1");
|
|
let (a, eq) = (ink(glyph_of(&o, 'a')), ink(glyph_of(&o, '=')));
|
|
assert!(eq.0 - a.2 > 0.15 * 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn superscript_sits_close_after_its_base() {
|
|
let o = display("e^{x}");
|
|
let (e, x) = (ink(glyph_of(&o, 'e')), ink(glyph_of(&o, 'x')));
|
|
let gap = x.0 - e.2;
|
|
assert!(gap > 0.0 && gap < 0.1 * 100.0, "gap between e and its superscript: {gap}");
|
|
}
|
|
|
|
#[test]
|
|
fn superscripts_on_ascender_letters_sit_as_on_short_ones() {
|
|
let height = |tex: &str| glyph_of(&display(tex), '2').y;
|
|
let (a, x, b, d) = (height("a^2"), height("x^2"), height("b^2"), height("d^2"));
|
|
assert!((a - x).abs() < 0.5, "a {a} x {x}");
|
|
assert!((b - a).abs() < 0.5 && (d - a).abs() < 0.5, "b {b} d {d} vs a {a}");
|
|
// A built base (a fraction) still lifts its script above itself.
|
|
let o = display(r"\left(\frac{a}{b}\right)^2");
|
|
assert!(glyph_of(&o, '2').y < glyph_of(&o, 'a').y);
|
|
}
|
|
|
|
#[test]
|
|
fn limit_operators_take_their_limits_underneath_in_display() {
|
|
let o = display(r"\lim_{h \to 0} f");
|
|
let (l, m, h) = (glyph_of(&o, 'l'), glyph_of(&o, 'm'), glyph_of(&o, 'h'));
|
|
assert!(h.y > l.y + 20.0, "the limit sits under the operator: h {} vs lim {}", h.y, l.y);
|
|
assert!(h.x < m.x + 10.0, "and under it, not beside: h {} m {}", h.x, m.x);
|
|
// In text style the limit goes beside, unless \limits says otherwise.
|
|
let side = lay(r"\lim_{h} f", MathStyle::Text);
|
|
assert!(glyph_of(&side, 'h').x > glyph_of(&side, 'm').x);
|
|
let under = lay(r"\lim\limits_{h} f", MathStyle::Text);
|
|
assert!(glyph_of(&under, 'h').y > glyph_of(&under, 'l').y + 20.0);
|
|
// \nolimits puts a display sum's limits beside it.
|
|
let beside = display(r"\sum\nolimits_{k} k");
|
|
let ks: Vec<_> = glyphs(&beside).into_iter().filter(|g| g.ch == 'k').collect();
|
|
assert!(ks[0].x > glyph_of(&beside, '\u{2211}').x + 20.0);
|
|
}
|
|
|
|
#[test]
|
|
fn style_switches_and_limits_are_commands_not_text() {
|
|
for tex in [r"\displaystyle \frac{a}{b}", r"\lim\limits_{x} x", r"\textstyle \sum_{k} k", r"\sum\nolimits_k k"] {
|
|
let o = display(tex);
|
|
assert!(!glyphs(&o).iter().any(|g| g.ch == '\\'), "{tex} shows a command as text");
|
|
}
|
|
let size = |tex: &str, style| glyph_of(&lay(tex, style), 'a').size;
|
|
assert!(size(r"\displaystyle \frac{a}{b}", MathStyle::Text) > size(r"\frac{a}{b}", MathStyle::Text));
|
|
let o = display(r"\textstyle \sum_{k} k");
|
|
let ks: Vec<_> = glyphs(&o).into_iter().filter(|g| g.ch == 'k').collect();
|
|
assert!(ks[0].x > glyph_of(&o, '\u{2211}').x + 10.0, "text style: limits beside");
|
|
}
|