Files
2026-10-14 23:16:33 -03:00

333 lines
12 KiB
Rust
Raw Permalink 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.
//! Layout geometry over a fixed formula corpus (`FakeMetrics`: width 0.5·size, ascent
//! 0.75·size, descent 0.25·size, x-height 0.5·size, rule 0.04·size), all at 10 pt.
use notescraft_math::{Error, Style, layout, parse, testutil::FakeMetrics};
fn lb(src: &str, size: f32, display: bool) -> notescraft_math::MathBox {
match parse(src) {
Ok(node) => layout(&node, &FakeMetrics, Style { size_pt: size, display }),
Err(e) => panic!("{src:?}: {e}"),
}
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 1.0e-3
}
fn count_items(b: &notescraft_math::MathBox, is_path: bool) -> usize {
b.items.iter().filter(|i| matches!(i, notescraft_math::Item::Path { .. }) == is_path).count()
}
#[test]
fn atoms_and_spacing() {
let cases: &[(&str, f32)] = &[
("x", 5.0),
("xy", 10.0),
("a+b", 19.4444),
("a = b", 20.5556),
("a/b", 15.0),
("(x)", 15.0),
("a,b", 15.0),
("a\\,b", 11.6667),
("a\\:b", 12.2222),
("a\\;b", 12.7778),
("a\\!b", 8.3333),
("a\\quad b", 20.0),
("a\\qquad b", 30.0),
("{a}{b}", 10.0),
("\\infty", 5.0),
("\\alpha", 5.0),
("\\alpha + \\beta", 19.4444),
("a\\cdot b", 19.4444),
("f(x)", 20.0),
("x^2y", 14.0),
("1+2", 19.4444),
];
for (src, w) in cases {
let b = lb(src, 10.0, false);
assert!(near(b.width, *w), "{src}: {}", b.width);
}
}
#[test]
fn scripts_geometry() {
let b = lb("x^2", 10.0, false);
assert!(near(b.width, 9.0));
assert!(near(b.height, 9.0));
assert!(near(b.depth, 2.5));
let b = lb("x_2^3", 10.0, false);
assert!(near(b.width, 9.0));
// Both scripts move apart until their ink boxes have four rule widths of clearance.
assert!(near(b.depth, 4.8));
let b = lb("x'", 10.0, false);
assert!(near(b.width, 9.0));
let b = lb("\\sum_{i}^{n}", 10.0, false);
// Inline sum: scripts beside, with operator correction and vertical clearance.
assert!(near(b.width, 9.0));
assert!(near(b.height, 10.7375));
assert!(near(b.depth, 4.8625));
}
#[test]
fn big_operators_limits_display() {
let b = lb("\\sum", 10.0, true);
assert!(near(b.width, 7.0));
let b = lb("\\sum_{i}", 10.0, true);
assert!(near(b.width, 7.0));
assert!(near(b.depth, 12.5));
let b = lb("\\sum_{i}^{n}", 10.0, true);
assert!(near(b.width, 7.0));
assert!(near(b.height, 17.5));
assert!(near(b.depth, 12.5));
let b = lb("\\int_0^1", 10.0, true);
// ∫ keeps scripts beside even in display style.
assert!(near(b.width, 11.0));
assert!(near(b.depth, 6.25));
let b = lb("\\lim_{a}^{b}", 10.0, true);
assert!(near(b.width, 15.0), "{}", b.width);
assert!(near(b.height, 15.5));
let b = lb("\\max_{c}", 10.0, true);
assert!(near(b.width, 15.0));
assert!(near(b.depth, 10.5));
}
#[test]
fn binomial_and_sine_examples_typeset() -> Result<(), Error> {
for source in [r"\sum_{k=0}^{n} \binom{n}{k} x^k = (1+x)^n", r"\lim_{x\to 0}\frac{\sin x}{x}=1"] {
let node = parse(source)?;
let math = layout(&node, &FakeMetrics, Style { size_pt: 10.0, display: true });
assert!(math.width > 0.0 && math.height > 0.0);
}
let node = parse(r"\binom{n}{k}")?;
let math = layout(&node, &FakeMetrics, Style { size_pt: 10.0, display: true });
assert_eq!(math.items.iter().filter(|item| matches!(item, notescraft_math::Item::Rule { .. })).count(), 0);
let node = parse(r"\sin^2 x")?;
let math = layout(&node, &FakeMetrics, Style { size_pt: 10.0, display: true });
assert!(math.items.iter().any(|item| matches!(item, notescraft_math::Item::Glyphs { text, x, .. } if text == "2" && *x >= 15.0)));
Ok(())
}
#[test]
fn fractions_geometry() {
let cases: &[(&str, bool, f32)] = &[("\\frac12", false, 3.5), ("\\frac{a}{b}", false, 3.5), ("\\frac12", true, 5.0)];
for (src, display, w) in cases {
let b = lb(src, 10.0, *display);
// Inline `\frac` children run at the script size; display at full size.
assert!(near(b.width, *w), "{src}: {}", b.width);
}
let b = lb("\\frac12", 10.0, false);
assert!(near(b.height, 10.5));
assert!(near(b.depth, 10.5));
let b = lb("\\dfrac{a}{b}", 10.0, false);
assert!(near(b.width, 5.0));
assert!(near(b.height, 13.5));
assert!(near(b.depth, 13.5));
let b = lb("\\tfrac{a}{b}", 10.0, true);
assert!(near(b.width, 5.0));
assert!(near(b.height, 13.5));
let b = lb("\\frac{\\sqrt{a}}{2}", 10.0, false);
// Sqrt at script size 7: rad max(0.75·(5.25+0.56), 3.5) = 4.3575 + 3.5 wide.
assert!(near(b.width, 7.8575), "{}", b.width);
}
#[test]
fn radicals_geometry() {
let b = lb("2\\sqrt3", 10.0, false);
// '2' + sqrt('3'): rad max(0.75·8.3, 5) = 6.225 + body 5.
assert!(near(b.width, 5.0 + 11.225), "{}", b.width);
let b = lb("\\sqrt{ab}", 10.0, false);
assert!(near(b.width, 16.225));
assert!(near(b.height, 8.3));
assert_eq!(count_items(&b, true), 1);
assert_eq!(count_items(&b, false), 2);
let b = lb("\\sqrt[3]{a}", 10.0, false);
assert!(near(b.height, 15.05));
let b = lb("\\sqrt{\\frac{a}{b}}", 10.0, false);
// Body fraction (children at 0.7 → 3.5 wide): h 11.3; rad 8.475 + 3.5.
assert!(near(b.width, 11.975), "{}", b.width);
assert!(near(b.height, 11.3), "{}", b.height);
assert!(near(b.depth, 10.5));
}
#[test]
fn text_and_fonts() {
let b = lb("\\text{hi}", 10.0, false);
assert!(near(b.width, 10.0));
let b = lb("\\text{a b}", 10.0, false);
assert!(near(b.width, 15.0));
let b = lb("\\mathrm{x}", 10.0, false);
assert!(near(b.width, 5.0));
let runs: Vec<_> = b
.items
.iter()
.filter_map(|i| match i {
notescraft_math::Item::Glyphs { face, .. } => Some(*face),
_ => None,
})
.collect();
assert_eq!(runs.len(), 1);
let b = lb("\\mathbf{x}", 10.0, false);
assert!(near(b.width, 5.0));
let b = lb("\\mathbb{R}", 10.0, false);
let text: Vec<_> = b
.items
.iter()
.filter_map(|i| match i {
notescraft_math::Item::Glyphs { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(text, ["ℝ"]);
let b = lb("\\mathcal{F}", 10.0, false);
assert!(near(b.width, 5.0));
let b = lb("\\mathit{x}", 10.0, false);
assert!(near(b.width, 5.0));
}
#[test]
fn accents_geometry() {
for src in ["\\hat{x}", "\\bar{x}", "\\vec{v}", "\\dot{x}", "\\tilde{x}", "\\ddot{x}"] {
let b = lb(src, 10.0, false);
assert!(near(b.width, 5.0), "{src}: {}", b.width);
assert!(near(b.height, 8.5 + 5.25), "{src}: {}", b.height);
assert!(near(b.depth, 2.5));
assert_eq!(b.items.len(), 2);
}
}
#[test]
fn delimiters_geometry() {
let b = lb("\\left(x\\right)", 10.0, false);
assert!(near(b.width, 15.0));
let b = lb("\\left( x+y \\right)", 10.0, false);
// Body fits a normal glyph extent → glyph delimiters, no paths.
assert!(near(b.width, 29.4444), "{}", b.width);
assert_eq!(count_items(&b, true), 0);
let b = lb("\\left(x^2\\right)", 10.0, false);
// Body 9 tall > ascent 7.5 → stretched parens: 0.35·(9+2.5) each.
assert!(near(b.width, 17.05), "{}", b.width);
assert_eq!(count_items(&b, true), 2);
let b = lb("\\left|f\\right|", 10.0, false);
assert!(near(b.width, 15.0));
let b = lb("\\left. a \\right.", 10.0, false);
assert!(near(b.width, 5.0));
let b = lb("\\left\\langle a\\right\\rangle", 10.0, false);
assert!(near(b.width, 15.0));
let b = lb("\\left(\\frac{a}{b}\\right)", 10.0, true);
assert!(near(b.width, 23.9), "{}", b.width);
assert_eq!(count_items(&b, true), 2);
}
#[test]
fn matrices_geometry() {
let b = lb("\\begin{matrix} a \\\\ b \\end{matrix}", 10.0, false);
assert!(near(b.width, 5.0));
assert!(near(b.height, 14.0));
assert!(near(b.depth, 9.0));
let b = lb("\\begin{matrix} a & b \\\\ c & d \\end{matrix}", 10.0, false);
assert!(near(b.width, 13.0));
assert!(near(b.height, 14.0));
assert!(near(b.depth, 9.0));
let b = lb("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}", 10.0, false);
assert!(near(b.width, 29.1), "{}", b.width);
let b = lb("\\begin{bmatrix} a \\\\ b \\end{bmatrix}", 10.0, false);
assert_eq!(count_items(&b, true), 2);
let b = lb("\\begin{cases} a \\\\ b \\end{cases}", 10.0, false);
assert_eq!(count_items(&b, true), 1);
let b = lb("\\begin{aligned} a &= b \\\\ c &= d \\end{aligned}", 10.0, false);
let xs: Vec<(f32, f32)> = b
.items
.iter()
.filter_map(|i| match i {
notescraft_math::Item::Glyphs { text, x, baseline_y, .. } if text == "=" => Some((*x, *baseline_y)),
_ => None,
})
.collect();
// Both `=` sit at the same cell origin (right-aligned first column); baselines
// differ per row.
assert_eq!(xs.len(), 2);
assert!(near(xs[0].0, 8.0), "{xs:?}");
assert!(near(xs[0].0, xs[1].0));
}
#[test]
fn styles_scale_layout() {
let b = lb("x", 20.0, false);
assert!(near(b.width, 10.0));
let b = lb("\\frac{a}{b}", 5.0, true);
// Display-style frac children stay at full size → 2.5 wide.
assert!(near(b.width, 2.5));
// Script sizes: 0.7 and 0.5 ladders inside nested scripts.
let b = lb("x^{y^{z}}", 10.0, false);
// x + corr + y at 0.7 (5.6 wide with sup z at 0.5 of script size).
assert!(near(b.width, 11.1), "{}", b.width);
assert!(near(b.height, 9.0), "{}", b.height);
}
#[test]
fn bad_style_falls_back() {
for size in [f32::NAN, 0.0, -3.0, f32::INFINITY] {
let good = lb("x", 12.0, false);
let b = lb("x", size, false);
assert_eq!(b.width, good.width);
}
}
#[test]
fn corpus_of_valid_formulas_all_layout() {
let corpus = [
"x+y",
"\\frac{a+b}{c+d}",
"\\sqrt[n]{\\frac{a}{b}}",
"\\frac{d}{dx}(x^2)",
"\\sum_{k=1}^{n} k^2",
"\\prod_{i=1}^{n} i",
"\\coprod_{a\\in A}",
"\\int_{0}^{1} x^2 dx",
"\\oint_{C} F \\cdot dA",
"\\bigcup_{i} A_i \\cap \\bigcap_{j} B_j",
"\\limsup_{n\\to\\infty} a_n",
"\\lim_{h\\to 0} \\frac{f(x+h) - f(x)}{h}",
"\\begin{pmatrix} \\frac{1}{2} & 0 \\\\ 0 & 1 \\end{pmatrix}",
"\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}",
"\\begin{aligned} x &= 1 + 2 \\\\ &= 3 \\end{aligned}",
"\\left(\\sum_{i}^{n} a_i\\right)^{2}",
"\\frac{\\partial f}{\\partial x}",
"\\nabla \\times \\mathbf{F} = 0",
"\\hat \\alpha \\neq \\tilde \\beta",
"\\forall x \\exists y: x \\leq y",
"\\mathbb{Q} \\subset \\mathbb{R}",
"\\alpha \\wedge \\neg \\beta \\vee \\gamma",
"e^{i\\pi} + 1 = 0",
"\\varnothing \\in \\mathcal{P}(X)",
"f: X \\to Y, x \\mapsto f(x)",
"\\text{if } x > 0 \\text{ then } 1 \\text{ else } -1",
"\\max(a,b) \\geq \\min(a,b)",
"\\pm 1 \\mp 2",
"\\hbar \\omega \\ell",
"\\frac{1}{2} \\cdot \\frac{3}{4}",
"\\sqrt{2} \\approx 1.414",
"\\frac{a_1}{b_1} + \\frac{a_2}{b_2}",
"\\left\\{ 1, 2, 3 \\right\\}",
"\\mathbf{x}^\\top \\mathbf{y}",
"\\vec{r} = \\dot{\\vec{r}} + \\ddot{\\vec{r}}",
];
for src in corpus {
let node = parse(src);
match node {
Ok(node) => {
let b = layout(&node, &FakeMetrics, Style { size_pt: 10.0, display: true });
assert!(b.width.is_finite(), "{src}");
assert!(b.height.is_finite(), "{src}");
assert!(b.depth.is_finite(), "{src}");
}
Err(e @ (Error::Unknown { .. } | Error::Unexpected { .. })) => {
// Known TeX commands must not error; anything else is a design bug.
let what = e.to_string();
panic!("{src}: {what}");
}
Err(_) => {}
}
}
}