333 lines
12 KiB
Rust
333 lines
12 KiB
Rust
//! 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: ¬escraft_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(_) => {}
|
||
}
|
||
}
|
||
}
|