use super::*; use wordcraft_doc::math::Math; fn rc() -> ResolvedChar { wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &Default::default()) } fn lay(lin: &str, display: bool) -> MathLayout { layout_equation(&Math::default(), lin, &rc(), display, 400.0, &MathProps::default(), &mut 0) } /// The glyphs of a laid-out equation by line: (baseline, [(x, text)]), top line first. fn lines(m: &MathLayout) -> Vec<(f32, Vec<(f32, String)>)> { let mut out: Vec<(f32, Vec<(f32, String)>)> = Vec::new(); for it in &m.items { if let MItem::Glyphs { glyphs, text, .. } = it && let Some(&(_, x, y)) = glyphs.first() { match out.iter_mut().find(|l| (l.0 - y).abs() < 0.5) { Some(l) => l.1.push((x, text.clone())), None => out.push((y, vec![(x, text.clone())])), } } } out.sort_by(|a, b| b.0.total_cmp(&a.0)); for l in &mut out { l.1.sort_by(|a, b| a.0.total_cmp(&b.0)); } out } fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) -> MathLayout { layout_equation(math, lin, &rc(), display, avail, mp, &mut 0) } #[test] fn eq_array_rows_without_ampersand_are_centred() { // Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above // "|a|, &n even"), whether or not the other rows have one. use wordcraft_doc::math::*; let run = |s: &str| vec![MNode::Run(MRun::new(s))]; let w_a = lay("a", true).width; for other in ["bbbbbb&c", "bbbbbbc"] { let math = Math { nodes: vec![MNode::EqArr { rows: vec![run("a"), run(other)] }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); let xa = ls.first().and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let x0 = ls.get(1).and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let want = x0 + (m.width - x0 - w_a) / 2.0; assert!((xa - want).abs() < 1.0, "{other}: a at {xa}, want {want} ({ls:?}, width {})", m.width); // The lone row doesn't widen the aligned columns. assert!(x0.abs() < 0.5 && m.width < lay("bbbbbbc", true).width + 1.0, "{other}: {ls:?}, width {}", m.width); } // A lone row wider than the aligned columns sets the array's width; the aligned // rows are centred under it as one block. let math = Math { nodes: vec![MNode::EqArr { rows: vec![run("bbbbbbbbbb"), run("a&c")] }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); let xw = ls.first().and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let x0 = ls.get(1).and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); assert!(xw.abs() < 0.5 && (m.width - lay("bbbbbbbbbb", true).width).abs() < 1.0, "wide lone row: {ls:?}, width {}", m.width); let want = (m.width - w_a - lay("c", true).width) / 2.0; assert!(want > 1.0 && (x0 - want).abs() < 1.0, "aligned row at {x0}, want {want} ({ls:?})"); } #[test] fn long_display_equations_wrap_before_operators_and_indent() { let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w"; let mp = MathProps::default(); let full = lay_in(&Math::default(), sum, true, 1e4, &mp); assert_eq!(lines(&full).len(), 1, "fits on one line when there is room"); let avail = full.width * 0.45; assert!(avail > 2.0 * mp.wrap_indent, "room for the indent: {avail}"); let m = lay_in(&Math::default(), sum, true, avail, &mp); let ls = lines(&m); assert!(ls.len() >= 2, "{ls:?}"); assert!((m.width - avail).abs() < 0.01, "the wrapped equation fills the line: {} vs {avail}", m.width); for (k, (_, gs)) in ls.iter().enumerate() { let (x0, first) = gs.first().cloned().unwrap(); if k == 0 { // Math italic 𝑦 when a font has it; plain y where none does (FreeBSD CI has no fonts). assert!(x0.abs() < 0.01 && (first == "𝑦" || first == "y"), "{gs:?}"); } else { // Continuation lines start with the operator, one wrap indent in. assert!((x0 - mp.wrap_indent).abs() < 0.01 && first == "+", "line {k}: {gs:?}"); } for (x, t) in gs { assert!(*x < avail, "line {k}: {t} at {x} past {avail}"); } } // Breaks after the operator, and the operator repeated, as the settings say. let after = MathProps { brk_bin: BrkBin::After, ..Default::default() }; let ls = lines(&lay_in(&Math::default(), sum, true, avail, &after)); assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 != "+", "{ls:?}"); let repeat = MathProps { brk_bin: BrkBin::Repeat, wrap_right: true, ..Default::default() }; let m = lay_in(&Math::default(), sum, true, avail, &repeat); let ls = lines(&m); assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 == "+", "{ls:?}"); // Inline equations never wrap. assert_eq!(lines(&lay_in(&Math::default(), sum, false, avail, &mp)).len(), 1); // A term wider than the line overflows it rather than breaking inside. let frac = lay_in(&Math::default(), "y=(a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w)/2", true, avail, &mp); assert!(frac.width > avail && frac.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "{}", frac.width); } #[test] fn manual_breaks_start_new_lines() { let mp = MathProps::default(); let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })]; let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp); let ls = lines(&m); assert_eq!(ls.len(), 2, "{ls:?}"); assert!((ls[1].1[0].0 - mp.wrap_indent).abs() < 0.01 && ls[1].1[0].1 == "+", "{ls:?}"); assert!(m.width < 400.0, "a manually broken equation is as wide as its lines"); // `m:alnAt`: the new line's operator sits under the first line's first operator. let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") })]; let ls = lines(&lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp)); let eq_x = ls[0].1.iter().find(|g| g.1 == "=").unwrap().0; assert!((ls[1].1[0].0 - eq_x).abs() < 0.01, "{ls:?}"); } #[test] fn short_display_equations_stay_on_one_line() { let mp = MathProps::default(); let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp); assert_eq!(lines(&one).len(), 1); let plain = lay_in(&Math::default(), "a+b=c", false, 400.0, &mp); assert!(one.width < 400.0 && (one.width - plain.width).abs() < 0.01, "{} vs {}", one.width, plain.width); } #[test] fn fraction_is_stacked_with_a_rule() { let flat = lay("ab", true); let f = lay("a/b", true); assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}"); assert!(f.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "fraction rule"); assert!(f.width < flat.width * 1.2, "stacked, not side by side"); } #[test] fn superscript_is_raised_and_smaller() { let m = lay("x^2", false); let sizes: Vec<(f32, f32)> = m .items .iter() .filter_map(|i| match i { MItem::Glyphs { size, glyphs, .. } => glyphs.first().map(|g| (*size, g.2)), _ => None, }) .collect(); assert_eq!(sizes.len(), 2, "{m:?}"); assert!(sizes[1].0 < sizes[0].0 && sizes[1].1 > 2.0, "{sizes:?}"); } #[test] fn operators_get_spaces_and_unary_minus_does_not() { let tight = lay("ab", false); let spaced = lay("a+b", false); let unary = lay("βˆ’b", false); let plus_w = lay("+", false).width; assert!(spaced.width > tight.width + plus_w + 2.0, "medium spaces around +"); assert!(unary.width < lay("b", false).width + plus_w + 1.0, "no space after a unary minus"); } #[test] fn display_operators_are_big_with_limits_over_and_under() { let d = lay("βˆ‘_(i=1)^nβ–’i", true); let t = lay("βˆ‘_(i=1)^nβ–’i", false); assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller"); assert!(d.width < t.width + 4.0, "limits stacked in display"); } #[test] fn delimiters_grow_around_tall_content() { let small = lay("(x)", true); let big = lay("((a)/(b))", true); let h = |m: &MathLayout| m.ascent + m.descent; assert!(h(&big) > h(&small) * 1.4, "{} vs {}", h(&big), h(&small)); } #[test] fn every_structure_lays_out() { use wordcraft_doc::math::*; let x = || vec![MNode::Run(MRun::new("x"))]; let nodes = vec![ MNode::Acc { chr: '\u{302}', e: x() }, MNode::Acc { chr: '\u{305}', e: x() }, MNode::Bar { top: false, e: x() }, MNode::BorderBox { hide: [false; 4], strike: [true; 4], e: x() }, MNode::Boxed { e: x() }, MNode::GroupChr { chr: '⏟', top: false, e: x() }, MNode::EqArr { rows: vec![vec![MNode::Run(MRun::new("x&=1"))], vec![MNode::Run(MRun::new("y&=22"))]] }, MNode::Matrix { rows: vec![vec![x(), x()], vec![x(), Vec::new()]], col_jc: vec![] }, MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: true, e: x() }, MNode::Lim { upper: false, e: vec![MNode::Run(MRun::plain("lim"))], lim: x() }, MNode::Rad { deg: x(), deg_hide: false, e: x() }, MNode::Script { kind: ScriptKind::Pre, base: x(), sub: x(), sup: x() }, MNode::Frac { kind: FracKind::Skewed, num: x(), den: x() }, MNode::Frac { kind: FracKind::Linear, num: x(), den: x() }, MNode::Frac { kind: FracKind::NoBar, num: x(), den: x() }, MNode::Delim { beg: None, end: Some('|'), sep: Some('|'), grow: true, shp_match: true, elems: vec![x(), x()] }, MNode::Run(MRun { text: "text".into(), nor: true, ..Default::default() }), MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }), ]; for n in nodes { let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.width > 0.0 && m.ascent + m.descent > 0.0, "{n:?} β†’ {m:?}"); for it in &m.items { if let MItem::Glyphs { glyphs, .. } = it { assert!(glyphs.iter().all(|g| g.1.is_finite() && g.2.is_finite())); } } } } /// #1273: an n-ary operator, delimiter, accent or group character the math font lacks is /// drawn from a fallback font, not as a missing-glyph box. #[test] fn characters_the_math_font_lacks_fall_back() { let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math"); let Some(c) = ['⨌', 'β«Ώ', 'βŸ…', 'γ‚’', 'δΈ­', 'κ°€'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some()) else { return; // Every candidate is in the math font, or no fallback font is installed. }; let x = || vec![MNode::Run(MRun::new("x"))]; let nodes = [ MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: true, sup_hide: true, sub: vec![], sup: vec![], e: x() }, MNode::Delim { beg: Some(c), end: Some(c), sep: None, grow: true, shp_match: false, elems: vec![x()] }, MNode::Acc { chr: c, e: x() }, MNode::GroupChr { chr: c, top: true, e: x() }, ]; for display in [false, true] { for n in &nodes { let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), display, 400.0, &MathProps::default(), &mut 0); for it in &m.items { if let MItem::Glyphs { glyphs, .. } = it { assert!(glyphs.iter().all(|g| g.0 != 0), "{c:?} drew a missing glyph in {n:?}"); } } } } } #[test] fn caret_slots_cover_every_position() { use wordcraft_doc::math_edit::{MathPos, move_right, units}; let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+β– (a&b@c&d)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); // Walking the caret through the equation, every position has a slot. let mut p = MathPos::default(); let mut seen = 0; loop { let s = m.slots.iter().find(|s| s.path == p.path && s.off == p.off); assert!(s.is_some(), "no slot for {p:?}"); seen += 1; match move_right(&math.nodes, &p) { Some(n) => p = n, None => break, } assert!(seen < 500); } assert_eq!(p.off, units(&math.nodes)); // Hit testing finds the denominator below the bar. let den = m.slots.iter().find(|s| s.path == vec![(1, 1)]).unwrap(); let hit = m.hit(den.x + 1.0, den.y).unwrap(); assert_eq!(hit.path, vec![(1, 1)]); } #[test] fn an_arguments_extent_spans_its_caret_positions() { let math = Math { nodes: wordcraft_doc::math::parse_linear("y=(a+b)/c"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); let (nx0, nx1, nup, ndown) = m.arg_extent(&[(1, 0)]).unwrap(); let (dx0, dx1, dup, ddown) = m.arg_extent(&[(1, 1)]).unwrap(); // The numerator sits above the baseline and the denominator below it, both inside the equation. assert!(nx0 > 0.0 && nx1 > nx0 && nx1 <= m.width, "{nx0} {nx1} {}", m.width); assert!(dx1 > dx0 && dx1 <= m.width); assert!(-ndown > 0.0 && nup <= m.ascent + 0.01, "numerator {nup} {ndown}"); assert!(dup < -ndown && ddown > 0.0 && ddown <= m.descent + 0.01, "denominator {dup} {ddown}"); // The whole equation's argument is as wide as the equation. let (x0, x1, ..) = m.arg_extent(&[]).unwrap(); assert!(x0.abs() < 0.01 && (x1 - m.width).abs() < 0.01, "{x0} {x1} {}", m.width); assert!(m.arg_extent(&[(9, 0)]).is_none()); } #[test] fn empty_slots_show_placeholders_on_screen_only() { let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items); assert!(m.slots.iter().any(|s| s.path == vec![(0, 0)] && s.off == 0)); let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(empty.width > 10.0 && empty.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_)))); } #[test] fn numbered_equations_fill_the_line_with_the_number_at_the_right() { let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!((m.width - 400.0).abs() < 0.01, "{}", m.width); let xs: Vec = m.items.iter().filter_map(|i| if let MItem::Glyphs { glyphs, .. } = i { glyphs.first().map(|g| g.1) } else { None }).collect(); let max = xs.iter().cloned().fold(0.0f32, f32::max); assert!(max > 350.0, "number at the right margin: {xs:?}"); let min = xs.iter().cloned().fold(f32::MAX, f32::min); assert!(min > 120.0 && min < 220.0, "equation centred: {xs:?}"); // Automatic numbers count. let auto = Math { nodes: wordcraft_doc::math::parse_linear("a=b#"), ..Default::default() }; let mut n = 4; let _ = layout_equation(&auto, "", &rc(), true, 400.0, &MathProps::default(), &mut n); assert_eq!(n, 5); assert_eq!(auto_numbers(&auto, true), 1); } #[test] fn math_alphabets() { assert_eq!(math_alnum('x', false, true, MScr::Roman), 'π‘₯'); assert_eq!(math_alnum('h', false, true, MScr::Roman), 'β„Ž'); assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ'); assert_eq!(math_alnum('Ξ±', false, true, MScr::Roman), '𝛼'); assert_eq!(math_alnum('2', false, true, MScr::Roman), '2'); assert_eq!(math_alnum('A', true, false, MScr::Roman), '𝐀'); } #[test] fn eqarr_rows_without_an_align_marker_are_centred() { use wordcraft_doc::math::*; // A row without `&` must not widen the alignment columns; it centres across the // array instead of right-aligning at the first column's edge. let rows: Vec> = vec![vec![MNode::Run(MRun::new("mmm"))], vec![MNode::Run(MRun::new("x&yyyyyyyy"))]]; let math = Math { nodes: vec![MNode::EqArr { rows }], ..Default::default() }; let ls = lines(&lay_in(&math, "", true, 400.0, &MathProps::default())); assert_eq!(ls.len(), 2, "{ls:?}"); let left: Vec = ls.iter().map(|(_, gs)| gs.iter().map(|(x, _)| *x).fold(f32::MAX, |a, b| if b < a { b } else { a })).collect(); assert!(left.iter().all(|x| *x >= -0.5), "nothing hangs left of the array: {left:?}"); // The short row starts right of the aligned row's first segment. assert!(ls[0].1.first().is_some_and(|(x, _)| *x > left[1] + 1.0), "short row centred, not flush left: {ls:?}"); } #[test] fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() { use wordcraft_doc::math::*; // The `&`-less row is wider than the aligned columns: it must not hang left of the // origin, the array is as wide as it, and the aligned rows centre under it. let rows: Vec> = vec![vec![MNode::Run(MRun::new("bbbbbbbbbb"))], vec![MNode::Run(MRun::new("a&c"))]]; let math = Math { nodes: vec![MNode::EqArr { rows }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); assert_eq!(ls.len(), 2, "{ls:?}"); let left: Vec = ls.iter().map(|(_, gs)| gs.iter().map(|(x, _)| *x).fold(f32::MAX, f32::min)).collect(); assert!(left.iter().all(|x| *x >= -0.5), "nothing hangs left of the array: {left:?}"); assert!(left[0].abs() < 0.5, "the wide row starts at the origin: {left:?}"); let narrow = lay_in( &Math { nodes: vec![MNode::EqArr { rows: vec![vec![MNode::Run(MRun::new("a&c"))]] }], ..Default::default() }, "", true, 400.0, &MathProps::default(), ); assert!(m.width > narrow.width + 1.0, "the array is as wide as its widest row: {} vs {}", m.width, narrow.width); assert!(left[1] > 1.0, "the aligned row is centred under the wide one: {left:?}"); } #[test] fn hostile_trees_never_panic() { use wordcraft_doc::math::*; // Deep nesting. let mut n = vec![MNode::Run(MRun::new("x"))]; for _ in 0..500 { n = vec![MNode::Frac { kind: FracKind::Bar, num: n.clone(), den: Vec::new() }]; } let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); // Empty everything. let empty = vec![ MNode::Nary { chr: '\0', lim_loc: None, grow: true, sub_hide: false, sup_hide: false, sub: vec![], sup: vec![], e: vec![] }, MNode::Delim { beg: Some('\u{10FFFF}'), end: None, sep: None, grow: true, shp_match: false, elems: vec![] }, MNode::Matrix { rows: vec![vec![]], col_jc: vec![] }, MNode::EqArr { rows: vec![] }, MNode::Run(MRun { text: String::new(), size: Some(f32::NAN), ..Default::default() }), ]; let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.width.is_finite() && m.ascent.is_finite()); // Breaks everywhere, odd wrap settings, no room at all. let runs: Vec = (0..2000).map(|i| MNode::Run(MRun { brk: (i % 3 == 0).then_some((i % 256) as u8), ..MRun::new("βˆ’+=") })).collect(); for avail in [0.0, f32::NAN, 1e9] { for brk_bin in [BrkBin::Before, BrkBin::After, BrkBin::Repeat] { let mp = MathProps { brk_bin, brk_bin_sub: BrkBinSub::PlusMinus, wrap_indent: f32::INFINITY, wrap_right: avail == 0.0 }; let m = layout_equation(&Math { nodes: runs.clone(), ..Default::default() }, "", &rc(), true, avail, &mp, &mut 0); assert!(m.width.is_finite() && m.descent.is_finite()); } } } /// Finding 23: each character's boundary in a run of text was found by walking the run's /// glyphs from the first. #[test] fn a_long_text_run_lays_out_in_linear_time() { use crate::tests::linear::assert_linear_steps; use wordcraft_doc::math::*; let eq = |n: usize| Math { nodes: vec![MNode::Run(MRun { nor: true, ..MRun::new("a".repeat(n)) })], ..Default::default() }; let lay = |m: &Math| drop(std::hint::black_box(lay_in(m, "", false, 1e6, &MathProps::default()))); assert_linear_steps("math text run", 400, eq, lay); }