diff --git a/library/src/math/ctx.rs b/library/src/math/ctx.rs
index fafb96f22..d682746e2 100644
--- a/library/src/math/ctx.rs
+++ b/library/src/math/ctx.rs
@@ -23,7 +23,7 @@ macro_rules! percent {
}
/// The context for math layout.
-pub(super) struct MathContext<'a, 'b, 'v> {
+pub struct MathContext<'a, 'b, 'v> {
pub vt: &'v mut Vt<'b>,
pub regions: Regions<'a>,
pub font: &'a Font,
diff --git a/library/src/math/lr.rs b/library/src/math/delimited.rs
similarity index 100%
rename from library/src/math/lr.rs
rename to library/src/math/delimited.rs
diff --git a/library/src/math/frac.rs b/library/src/math/frac.rs
index ebdb5c026..9f7fb9d0d 100644
--- a/library/src/math/frac.rs
+++ b/library/src/math/frac.rs
@@ -1,5 +1,7 @@
use super::*;
+const FRAC_AROUND: Em = Em::new(0.1);
+
/// # Fraction
/// A mathematical fraction.
///
@@ -130,7 +132,7 @@ fn layout(
let denom = ctx.layout_frame(denom)?;
ctx.unstyle();
- let around = Em::new(0.1).scaled(ctx);
+ let around = FRAC_AROUND.scaled(ctx);
let num_gap = (shift_up - axis - num.descent()).max(num_min + thickness / 2.0);
let denom_gap = (shift_down + axis - denom.ascent()).max(denom_min + thickness / 2.0);
diff --git a/library/src/math/fragment.rs b/library/src/math/fragment.rs
index fe9642a20..bef3f5780 100644
--- a/library/src/math/fragment.rs
+++ b/library/src/math/fragment.rs
@@ -1,7 +1,7 @@
use super::*;
#[derive(Debug, Clone)]
-pub(super) enum MathFragment {
+pub enum MathFragment {
Glyph(GlyphFragment),
Variant(VariantFragment),
Frame(FrameFragment),
@@ -118,7 +118,7 @@ impl From for MathFragment {
}
#[derive(Debug, Clone, Copy)]
-pub(super) struct GlyphFragment {
+pub struct GlyphFragment {
pub id: GlyphId,
pub c: char,
pub lang: Lang,
diff --git a/library/src/math/matrix.rs b/library/src/math/matrix.rs
index 527cf3159..5f448901b 100644
--- a/library/src/math/matrix.rs
+++ b/library/src/math/matrix.rs
@@ -1,7 +1,7 @@
use super::*;
const ROW_GAP: Em = Em::new(0.5);
-const COL_GAP: Em = Em::new(0.75);
+const COL_GAP: Em = Em::new(0.5);
const VERTICAL_PADDING: Ratio = Ratio::new(0.1);
/// # Vector
diff --git a/library/src/math/mod.rs b/library/src/math/mod.rs
index ab1fab136..4e60faca7 100644
--- a/library/src/math/mod.rs
+++ b/library/src/math/mod.rs
@@ -5,29 +5,29 @@ mod ctx;
mod accent;
mod align;
mod attach;
+mod delimited;
mod frac;
mod fragment;
-mod lr;
mod matrix;
mod op;
mod root;
mod row;
mod spacing;
-mod stack;
mod stretch;
mod style;
mod symbols;
+mod underover;
pub use self::accent::*;
pub use self::align::*;
pub use self::attach::*;
+pub use self::delimited::*;
pub use self::frac::*;
-pub use self::lr::*;
pub use self::matrix::*;
pub use self::op::*;
pub use self::root::*;
-pub use self::stack::*;
pub use self::style::*;
+pub use self::underover::*;
use ttf_parser::{GlyphId, Rect};
use typst::font::Font;
@@ -230,7 +230,7 @@ impl Layout for FormulaNode {
impl Inline for FormulaNode {}
#[capability]
-trait LayoutMath {
+pub trait LayoutMath {
fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()>;
}
diff --git a/library/src/math/root.rs b/library/src/math/root.rs
index d348ecc16..2d27bb114 100644
--- a/library/src/math/root.rs
+++ b/library/src/math/root.rs
@@ -1,5 +1,7 @@
use super::*;
+const MIN_INDEX_SHIFT: Em = Em::new(0.35);
+
/// # Square Root
/// A square root.
///
@@ -126,7 +128,7 @@ fn layout(
if let Some(index) = &index {
sqrt_offset = kern_before + index.width() + kern_after;
- shift_up.set_max(index.descent() + Em::new(0.35).scaled(ctx));
+ shift_up.set_max(index.descent() + MIN_INDEX_SHIFT.scaled(ctx));
ascent.set_max(shift_up + index.ascent());
}
diff --git a/library/src/math/row.rs b/library/src/math/row.rs
index d971ce221..e66fc18e8 100644
--- a/library/src/math/row.rs
+++ b/library/src/math/row.rs
@@ -2,8 +2,10 @@ use crate::layout::AlignNode;
use super::*;
+pub const TIGHT_LEADING: Em = Em::new(0.25);
+
#[derive(Debug, Default, Clone)]
-pub(super) struct MathRow(pub Vec);
+pub struct MathRow(pub Vec);
impl MathRow {
pub fn new() -> Self {
@@ -85,8 +87,12 @@ impl MathRow {
) -> Frame {
if self.0.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) {
let fragments = std::mem::take(&mut self.0);
+ let leading = if ctx.style.size >= MathSize::Text {
+ ctx.styles().get(ParNode::LEADING)
+ } else {
+ TIGHT_LEADING.scaled(ctx)
+ };
- let leading = ctx.styles().get(ParNode::LEADING) * ctx.style.size.factor(ctx);
let rows: Vec<_> = fragments
.split(|frag| matches!(frag, MathFragment::Linebreak))
.map(|slice| Self(slice.to_vec()))
diff --git a/library/src/math/spacing.rs b/library/src/math/spacing.rs
index 7083c5e10..fad1c863a 100644
--- a/library/src/math/spacing.rs
+++ b/library/src/math/spacing.rs
@@ -24,11 +24,8 @@ pub(super) fn spacing(
) -> Em {
use MathClass::*;
let script = style.size <= MathSize::Script;
- let (Some(l), Some(r)) = (left.class(), right.class()) else {
- return ZERO;
- };
-
- match (l, r) {
+ let class = |frag: &MathFragment| frag.class().unwrap_or(Special);
+ match (class(left), class(right)) {
// No spacing before punctuation; thin spacing after punctuation, unless
// in script size.
(_, Punctuation) => ZERO,
diff --git a/library/src/math/style.rs b/library/src/math/style.rs
index b3f3d8df7..da2e2313e 100644
--- a/library/src/math/style.rs
+++ b/library/src/math/style.rs
@@ -443,10 +443,6 @@ pub(super) fn styled_char(style: MathStyle, c: char) -> char {
'∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ'
));
- if c == '-' {
- return '−';
- }
-
if let Some(c) = latin_exception(c, variant, bold, italic) {
return c;
}
diff --git a/library/src/math/stack.rs b/library/src/math/underover.rs
similarity index 100%
rename from library/src/math/stack.rs
rename to library/src/math/underover.rs
diff --git a/src/model/eval.rs b/src/model/eval.rs
index 6e03d4405..1fbf41254 100644
--- a/src/model/eval.rs
+++ b/src/model/eval.rs
@@ -919,32 +919,26 @@ impl Eval for ast::FuncCall {
// field access and does not evaluate to a module.
let (callee, mut args) = if let ast::Expr::FieldAccess(access) = callee {
let target = access.target();
- let method = access.field();
- let method_span = method.span();
- let method = method.take();
- let point = || Tracepoint::Call(Some(method.clone()));
- if methods::is_mutating(&method) {
+ let field = access.field();
+ let field_span = field.span();
+ let field = field.take();
+ let point = || Tracepoint::Call(Some(field.clone()));
+ if methods::is_mutating(&field) {
let args = args.eval(vm)?;
- let value = target.access(vm)?;
-
- let value = if let Value::Module(module) = &value {
- module.get(&method).cloned().at(method_span)?
- } else {
- return methods::call_mut(value, &method, args, span)
+ let target = target.access(vm)?;
+ if !matches!(target, Value::Symbol(_) | Value::Module(_)) {
+ return methods::call_mut(target, &field, args, span)
.trace(vm.world, point, span);
- };
-
- (value, args)
+ }
+ (target.field(&field).at(field_span)?, args)
} else {
let target = target.eval(vm)?;
let args = args.eval(vm)?;
- let value = if let Value::Module(module) = &target {
- module.get(&method).cloned().at(method_span)?
- } else {
- return methods::call(vm, target, &method, args, span)
+ if !matches!(target, Value::Symbol(_) | Value::Module(_)) {
+ return methods::call(vm, target, &field, args, span)
.trace(vm.world, point, span);
- };
- (value, args)
+ }
+ (target.field(&field).at(field_span)?, args)
}
} else {
(callee.eval(vm)?, args.eval(vm)?)
diff --git a/src/syntax/ast.rs b/src/syntax/ast.rs
index 1cc29b04b..e844f6222 100644
--- a/src/syntax/ast.rs
+++ b/src/syntax/ast.rs
@@ -426,11 +426,14 @@ node! {
impl Shorthand {
/// A list of all shorthands.
pub const LIST: &[(&'static str, char)] = &[
+ // Text only.
("~", '\u{00A0}'),
("--", '\u{2013}'),
("---", '\u{2014}'),
("-?", '\u{00AD}'),
- ("...", '…'),
+ // Math only.
+ ("-", '\u{2212}'),
+ ("'", '′'),
("*", '∗'),
("!=", '≠'),
("<<", '≪'),
@@ -450,6 +453,8 @@ impl Shorthand {
("[|", '⟦'),
("|]", '⟧'),
("||", '‖'),
+ // Both.
+ ("...", '…'),
];
/// Get the shorthanded character.
diff --git a/src/syntax/lexer.rs b/src/syntax/lexer.rs
index 471e437a5..0bf7966fd 100644
--- a/src/syntax/lexer.rs
+++ b/src/syntax/lexer.rs
@@ -380,17 +380,16 @@ impl Lexer<'_> {
'\\' => self.backslash(),
'"' => self.string(),
- '*' => SyntaxKind::Shorthand,
'.' if self.s.eat_if("..") => SyntaxKind::Shorthand,
'|' if self.s.eat_if("->") => SyntaxKind::Shorthand,
'|' if self.s.eat_if("=>") => SyntaxKind::Shorthand,
- '!' if self.s.eat_if('=') => SyntaxKind::Shorthand,
'<' if self.s.eat_if("<<") => SyntaxKind::Shorthand,
- '<' if self.s.eat_if('<') => SyntaxKind::Shorthand,
'>' if self.s.eat_if(">>") => SyntaxKind::Shorthand,
- '>' if self.s.eat_if('>') => SyntaxKind::Shorthand,
'<' if self.s.eat_if("=>") => SyntaxKind::Shorthand,
'<' if self.s.eat_if("->") => SyntaxKind::Shorthand,
+ '!' if self.s.eat_if('=') => SyntaxKind::Shorthand,
+ '<' if self.s.eat_if('<') => SyntaxKind::Shorthand,
+ '>' if self.s.eat_if('>') => SyntaxKind::Shorthand,
'<' if self.s.eat_if('=') => SyntaxKind::Shorthand,
'>' if self.s.eat_if('=') => SyntaxKind::Shorthand,
'<' if self.s.eat_if('-') => SyntaxKind::Shorthand,
@@ -400,6 +399,7 @@ impl Lexer<'_> {
'[' if self.s.eat_if('|') => SyntaxKind::Shorthand,
'|' if self.s.eat_if(']') => SyntaxKind::Shorthand,
'|' if self.s.eat_if('|') => SyntaxKind::Shorthand,
+ '*' | '\'' | '-' => SyntaxKind::Shorthand,
'#' => SyntaxKind::Hashtag,
'_' => SyntaxKind::Underscore,
diff --git a/tests/ref/math/accent.png b/tests/ref/math/accent.png
new file mode 100644
index 000000000..c67e0912d
Binary files /dev/null and b/tests/ref/math/accent.png differ
diff --git a/tests/ref/math/accents.png b/tests/ref/math/accents.png
deleted file mode 100644
index cb167533d..000000000
Binary files a/tests/ref/math/accents.png and /dev/null differ
diff --git a/tests/ref/math/attach.png b/tests/ref/math/attach.png
new file mode 100644
index 000000000..27843eb43
Binary files /dev/null and b/tests/ref/math/attach.png differ
diff --git a/tests/ref/math/cases.png b/tests/ref/math/cases.png
new file mode 100644
index 000000000..87c358da6
Binary files /dev/null and b/tests/ref/math/cases.png differ
diff --git a/tests/ref/math/content.png b/tests/ref/math/content.png
new file mode 100644
index 000000000..5d222f929
Binary files /dev/null and b/tests/ref/math/content.png differ
diff --git a/tests/ref/math/delimited.png b/tests/ref/math/delimited.png
new file mode 100644
index 000000000..ea3ab6c29
Binary files /dev/null and b/tests/ref/math/delimited.png differ
diff --git a/tests/ref/math/frac.png b/tests/ref/math/frac.png
new file mode 100644
index 000000000..a3a9a3ae1
Binary files /dev/null and b/tests/ref/math/frac.png differ
diff --git a/tests/ref/math/matrix.png b/tests/ref/math/matrix.png
index 56a4db9c3..4a60b3ff1 100644
Binary files a/tests/ref/math/matrix.png and b/tests/ref/math/matrix.png differ
diff --git a/tests/ref/math/multiline.png b/tests/ref/math/multiline.png
new file mode 100644
index 000000000..d8b1d8470
Binary files /dev/null and b/tests/ref/math/multiline.png differ
diff --git a/tests/ref/math/op.png b/tests/ref/math/op.png
new file mode 100644
index 000000000..870250b7e
Binary files /dev/null and b/tests/ref/math/op.png differ
diff --git a/tests/ref/math/root.png b/tests/ref/math/root.png
new file mode 100644
index 000000000..8d9063949
Binary files /dev/null and b/tests/ref/math/root.png differ
diff --git a/tests/ref/math/spacing.png b/tests/ref/math/spacing.png
new file mode 100644
index 000000000..fcd0d6608
Binary files /dev/null and b/tests/ref/math/spacing.png differ
diff --git a/tests/ref/math/style.png b/tests/ref/math/style.png
index d9a71807f..04a282088 100644
Binary files a/tests/ref/math/style.png and b/tests/ref/math/style.png differ
diff --git a/tests/ref/math/syntax.png b/tests/ref/math/syntax.png
index 5a58df56d..442e8bfec 100644
Binary files a/tests/ref/math/syntax.png and b/tests/ref/math/syntax.png differ
diff --git a/tests/ref/math/underover.png b/tests/ref/math/underover.png
new file mode 100644
index 000000000..a585f59dd
Binary files /dev/null and b/tests/ref/math/underover.png differ
diff --git a/tests/ref/math/vec.png b/tests/ref/math/vec.png
new file mode 100644
index 000000000..20db15122
Binary files /dev/null and b/tests/ref/math/vec.png differ
diff --git a/tests/typ/math/accent.typ b/tests/typ/math/accent.typ
new file mode 100644
index 000000000..65ef9d1b5
--- /dev/null
+++ b/tests/typ/math/accent.typ
@@ -0,0 +1,22 @@
+// Test math accents.
+
+---
+// Test function call.
+$grave(a), acute(b), hat(f), tilde(§), macron(ä), diaer(a), ä, \
+ breve(\&), dot(!), circle(a), caron(@), arrow(Z), arrow.l(Z)$
+
+---
+// Test `accent` function.
+$accent(ö, .), accent(v, <-), accent(ZZ, \u{0303})$
+
+---
+// Test accent bounds.
+$sqrt(tilde(T)) + hat(f)/hat(g)$
+
+---
+// Test wide base.
+$arrow("ABC" + d), tilde(sum)$
+
+---
+// Test high base.
+$ tilde(integral), tilde(integral)_a^b, tilde(integral_a^b) $
diff --git a/tests/typ/math/accents.typ b/tests/typ/math/accents.typ
deleted file mode 100644
index 284e86f3d..000000000
--- a/tests/typ/math/accents.typ
+++ /dev/null
@@ -1,15 +0,0 @@
-// Test math accents.
-
----
-#set page(width: auto)
-
-$ grave(a),
- acute(a),
- hat(a),
- tilde(a),
- macron(a),
- breve(a),
- dot(a),
- diaer(a),
- caron(a),
- arrow(a) $
diff --git a/tests/typ/math/attach.typ b/tests/typ/math/attach.typ
new file mode 100644
index 000000000..cf3e9521b
--- /dev/null
+++ b/tests/typ/math/attach.typ
@@ -0,0 +1,34 @@
+// Test top and bottom attachments.
+
+---
+// Test basics.
+$f_x + t^b + V_1^2
+ + attach(A, top: alpha, bottom: beta)$
+
+---
+// Test text vs ident parsing.
+$pi_1(Y), a_f(x) != a_zeta(x)$
+
+---
+// Test associativity and scaling.
+$ 1/(V^2^3^4^5) $
+
+---
+// Test high subscript and superscript.
+$sqrt(a_(1/2)^zeta)$
+$sqrt(a_alpha^(1/2))$
+$sqrt(a_(1/2)^(3/4))$
+
+---
+// Test frame base.
+$ (-1)^n + (1/2 + 3)^(-1/2) $
+
+---
+// Test limit.
+$ lim_(n->infty \ n "grows") sum_(k=0 \ k in NN)^n k $
+
+---
+// Test forcing scripts and limits.
+$ limits(A)_1^2 != A_1^2 $
+$ scripts(sum)_1^2 != sum_1^2 $
+$ limits(integral)_a^b != integral_a^b $
diff --git a/tests/typ/math/cases.typ b/tests/typ/math/cases.typ
new file mode 100644
index 000000000..d591ae50e
--- /dev/null
+++ b/tests/typ/math/cases.typ
@@ -0,0 +1,9 @@
+// Test case distinction.
+
+---
+$ f(x, y) := cases(
+ 1 quad &"if" (x dot y)/2 <= 0,
+ 2 &"if" x divides 2,
+ 3 &"if" x in NN,
+ 4 &"else",
+) $
diff --git a/tests/typ/math/content.typ b/tests/typ/math/content.typ
new file mode 100644
index 000000000..271e00a52
--- /dev/null
+++ b/tests/typ/math/content.typ
@@ -0,0 +1,14 @@
+// Test arbitrary content in math.
+
+---
+// Test images and font fallback.
+#let monkey = move(dy: 0.2em, image("/res/monkey.svg", height: 1em))
+$ sum_(i=#emoji.apple)^#emoji.apple.red i + monkey/2 $
+
+---
+// Test table above fraction.
+$ x := #table(columns: 2)[x][y]/mat(1, 2, 3) $
+
+---
+// Test non-formula math directly in content.
+#math.attach($a$, top: [b])
diff --git a/tests/typ/math/delimited.typ b/tests/typ/math/delimited.typ
new file mode 100644
index 000000000..d22b76c0c
--- /dev/null
+++ b/tests/typ/math/delimited.typ
@@ -0,0 +1,38 @@
+// Test delimiter matching and scaling.
+
+---
+// Test automatic matching.
+$ (a) + {b/2} + |a|/2 + (b) $
+$f(x/2) < zeta(c^2 + |a + b/2|)$
+
+---
+// Test unmatched.
+$[1,2[ = [1,2) != zeta\(x/2\) $
+
+---
+// Test manual matching.
+$ [|a/b|] != lr(|]a/b|]) != [a/b) $
+$ lr(| ]1,2\[ + 1/2|) $
+
+---
+// Test fence confusion.
+$ |x + |y| + z/a| \
+ |x + lr(|y|) + z/a| $
+
+---
+// Test that symbols aren't matched automatically.
+$ bracket.l a/b bracket.r
+ = lr(bracket.l a/b bracket.r) $
+
+---
+// Test half LRs.
+$ lr(a/b\]) = a = lr(\{a/b) $
+
+---
+// Test manual scaling.
+$ lr(]sum_(x=1)^n x], size: #70%)
+ < lr((1, 2), size: #200%) $
+
+---
+// Test predefined delimiter pairings.
+$floor(x/2), ceil(x/2), abs(x), norm(x)$
diff --git a/tests/typ/math/frac.typ b/tests/typ/math/frac.typ
new file mode 100644
index 000000000..27c2ae45b
--- /dev/null
+++ b/tests/typ/math/frac.typ
@@ -0,0 +1,25 @@
+// Test fractions.
+
+---
+// Test that denominator baseline matches in the common case.
+$ x = 1/2 = a/(a h) = a/a = a/(1/2) $
+
+---
+// Test parenthesis removal.
+$ (|x| + |y|)/2 < [1+2]/3 $
+
+---
+// Test associativity.
+$ 1/2/3 = (1/2)/3 = 1/(2/3) $
+
+---
+// Test large fraction.
+$ x = (-b plus.minus sqrt(b^2 - 4a c))/(2a) $
+
+---
+// Test binomial.
+$ binom(circle, square) $
+
+---
+// Error: 8-13 missing argument: lower index
+$ binom(x^2) $
diff --git a/tests/typ/math/matrix.typ b/tests/typ/math/matrix.typ
index 3f65a6838..99e8fa19d 100644
--- a/tests/typ/math/matrix.typ
+++ b/tests/typ/math/matrix.typ
@@ -1,27 +1,26 @@
-// Test vectors, matrices, and cases.
+// Test matrices.
---
-$ v = vec(1, 2+3, 4) $
+// Test semicolon syntax.
+#set align(center)
+$mat() dot
+ mat(;) dot
+ mat(1, 2) dot
+ mat(1, 2;) \
+ mat(1; 2) dot
+ mat(1, 2; 3, 4) dot
+ mat(1 + &2, 1/2; &3, 4)$
---
-$ binom(n, 1) = 1/2 n (n-1) $
+// Test sparse matrix.
+$mat(
+ 1, 2, ..., 10;
+ 2, 2, ..., 10;
+ dots.v, dots.v, dots.down, dots.v;
+ 10, 10, ..., 10;
+)$
---
-#set math.vec(delim: "|")
-$ vec(1, 2) $
-
----
-$ f(x, y) := cases(
- 1 "if" (x dot y)/2 <= 0,
- 2 "if" x in NN,
- 3 "if" x "is even",
- 4 "else",
-) $
-
----
-// Error: 22-25 expected "(", "[", "{", "|", or "||"
-#set math.vec(delim: "%")
-
----
-// Error: 8-13 missing argument: lower index
-$ binom(x^2) $
+// Test alternative delimiter.
+#set math.mat(delim: "[")
+$ mat(1, 2; 3, 4) $
diff --git a/tests/typ/math/multiline.typ b/tests/typ/math/multiline.typ
new file mode 100644
index 000000000..b57d91666
--- /dev/null
+++ b/tests/typ/math/multiline.typ
@@ -0,0 +1,35 @@
+// Test multiline math.
+
+---
+// Test basic alignment.
+$ x &= x + y \
+ &= x + 2z \
+ &= sum x dot 2z $
+
+---
+// Test text before first alignment point.
+$ x + 1 &= a^2 + b^2 \
+ y &= a + b^2 \
+ z &= alpha dot beta $
+
+---
+// Test space between inner alignment points.
+$ a + b &= 2 + 3 &= 5 \
+ b &= c &= 3 $
+
+---
+// Test in case distinction.
+$ f := cases(
+ 1 + 2 &"iff" &x,
+ 3 &"if" &y,
+) $
+
+---
+// Test mixing lines with and some without alignment points.
+$ "abc" &= c \
+ &= d + 1 \
+ = x $
+
+---
+// Test multiline subscript.
+$ sum_(n in NN \ n <= 5) n = (5(5+1))/2 = 15 $
diff --git a/tests/typ/math/op.typ b/tests/typ/math/op.typ
new file mode 100644
index 000000000..c7b34c54a
--- /dev/null
+++ b/tests/typ/math/op.typ
@@ -0,0 +1,21 @@
+// Test text operators.
+
+---
+// Test predefined.
+$ max_(1<=n<=m) n $
+
+---
+// With or without parens.
+$ &sin x + log_2 x \
+ = &sin(x) + log_2(x) $
+
+---
+// Test scripts vs limits.
+#set text("Latin Modern Roman")
+Discuss $lim_(n->infty) 1/n$ now.
+$ lim_(n->infty) 1/n = 0 $
+
+---
+// Test custom operator.
+$ op("myop", limits: #false)_(x:=1) x \
+ op("myop", limits: #true)_(x:=1) x $
diff --git a/tests/typ/math/root.typ b/tests/typ/math/root.typ
new file mode 100644
index 000000000..8d3e2de02
--- /dev/null
+++ b/tests/typ/math/root.typ
@@ -0,0 +1,38 @@
+// Test roots.
+
+---
+// Test root with more than one character.
+$A = sqrt(x + y) = c$
+
+---
+// Test root size with radicals containing attachments.
+$ sqrt(a) quad
+ sqrt(f) quad
+ sqrt(q) quad
+ sqrt(a^2) \
+ sqrt(n_0) quad
+ sqrt(b^()) quad
+ sqrt(b^2) quad
+ sqrt(q_1^2) $
+
+---
+// Test precomposed vs constructed roots.
+// 3 and 4 are precomposed.
+$sqrt(x)$
+$root(2, x)$
+$root(3, x)$
+$root(4, x)$
+$root(5, x)$
+
+---
+// Test large bodies
+$ sqrt([|x|]^2 + [|y|]^2) < [|z|] $
+$ v = sqrt((1/2) / (4/5))
+ = root(3, (1/2/3) / (4/5/6))
+ = root(4, ((1/2) / (3/4)) / ((1/2) / (3/4))) $
+
+---
+// Test large index.
+$ root(2, x) quad
+ root(3/(2/1), x) quad
+ root(1/11, x) $
diff --git a/tests/typ/math/shorthand.typ b/tests/typ/math/shorthand.typ
deleted file mode 100644
index d37c938f0..000000000
--- a/tests/typ/math/shorthand.typ
+++ /dev/null
@@ -1,4 +0,0 @@
-// Test math shorthands.
-
----
-$ f : NN <=> RR, n |-> sqrt(n) $
diff --git a/tests/typ/math/simple.typ b/tests/typ/math/simple.typ
deleted file mode 100644
index 1b63cbfc9..000000000
--- a/tests/typ/math/simple.typ
+++ /dev/null
@@ -1,24 +0,0 @@
-// Test math formulas.
-
----
-The sum of $a$ and $b$ is $a + b$.
-
----
-We will show that:
-$ a^2 + b^2 = c^2 $
-
----
-Prove by induction:
-$ sum_(k=0)^n k = (n(n+1))/2 $
-
----
-We know that:
-$ floor(x/2) <= ceil(x/2) $
-
----
-// Test that blackboard style looks nice.
-$ f: NN -> RR $
-
----
-// Error: 1:3 expected dollar sign
-$a
diff --git a/tests/typ/math/spacing.typ b/tests/typ/math/spacing.typ
new file mode 100644
index 000000000..6f2d6b443
--- /dev/null
+++ b/tests/typ/math/spacing.typ
@@ -0,0 +1,31 @@
+// Test spacing in math formulas.
+
+---
+// Test spacing cases.
+$ä, +, c, (, )$ \
+$=), (+), {times}$
+$⟧<⟦, |-|, [=$ \
+$a=b, a==b$ \
+$-a, +a$ \
+$a not b$ \
+$a+b, a*b$ \
+$sum x, sum(x)$ \
+$sum prod x$ \
+$f(x), zeta(x), "frac"(x)$
+
+---
+// Test ignored vs non-ignored spaces.
+$f (x), f(x)$ \
+$[a|b], [a | b]$ \
+$a"is"b, a "is" b$
+
+---
+// Test predefined spacings.
+$a thin b, a med b, a thick b, a quad b$ \
+$a = thin b$ \
+$a - b ident c quad (mod 2)$
+
+---
+// Test spacing for set comprehension.
+#set page(width: auto)
+$ { x in RR | x "is natural" and x < 10 } $
diff --git a/tests/typ/math/style.typ b/tests/typ/math/style.typ
index 38d6e2d5d..228519001 100644
--- a/tests/typ/math/style.typ
+++ b/tests/typ/math/style.typ
@@ -1,15 +1,30 @@
-#let part = $ a B pi Delta $
-#let kinds = (math.serif, math.sans, math.cal, math.frak, math.mono, math.bb)
-#let modifiers = (v => v, math.italic, math.bold, v => math.italic(math.bold(v)))
+// Test text styling in math.
-#let cells = (sym.triangle.nested, [--], [`italic`], [`bold`], [both])
-#for kk in kinds {
- cells.push(raw(repr(kk).trim("")))
- for mm in modifiers {
- cells.push($ mm(kk(part)) $)
- }
-}
+---
+// Test italic defaults.
+$a, A, delta, ϵ, diff, Delta, ϴ$
-#set page(width: auto)
-#set align(center)
-#table(columns: 1 + modifiers.len(), ..cells)
+---
+// Test forcing a specific style.
+$A, italic(A), upright(A), bold(A), bold(upright(A)), \
+ serif(A), sans(A), cal(A), frak(A), mono(A), bb(A), \
+ italic(diff), upright(diff), \
+ bb("hello") + bold(cal("world")), \
+ mono("SQRT")(x) wreath mono(123 + 456)$
+
+---
+// Test a few style exceptions.
+$h, bb(N), frak(R), Theta, italic(Theta), sans(Theta), sans(italic(Theta))$
+
+---
+// Test font fallback.
+$ よ and 🏳️🌈 $
+
+---
+// Test text properties.
+$text(#red, "time"^2) + sqrt("place")$
+
+---
+// Test different font.
+#show math.formula: set text(family: "Fira Math")
+$ v := vec(1 + 2, 2 - 4, sqrt(3), arrow(x)) + 1 $
diff --git a/tests/typ/math/syntax.typ b/tests/typ/math/syntax.typ
index 8ffded298..26d3e4449 100644
--- a/tests/typ/math/syntax.typ
+++ b/tests/typ/math/syntax.typ
@@ -1,23 +1,18 @@
-#set page(width: auto)
-#show : it => table(
- columns: 2,
- inset: 8pt,
- ..it.text
- .split("\n")
- .map(line => (raw(line, lang: "typ"), text("Latin Modern Roman", eval(line))))
- .flatten()
-)
+// Test math syntax.
-```
-Let $x in NN$ be ...
-$ (1 + x/2)^2 $
-$ x arrow.l y $
-$ sum_(n=1)^mu 1 + (2pi(5 + n)) / k $
-$ { x in RR | x "is natural" and x < 10 } $
-$ sqrt(x^2) = frac(x, 1) $
-$ "profit" = "income" - "expenses" $
-$ x < #for i in range(5) [$ #i < $] y $
-$ 1 + 2 = #{1 + 2} $
-$ A subset.eq.not B $
-```
-
+---
+// Test Unicode math.
+$ ∑_(i=0)^ℕ a ∘ b = \u{2211}_(i=0)^NN a compose b $
+
+---
+// Test a few shorthands.
+$ underline(f' : NN -> RR) \
+ n |-> cases(
+ [|1|] &"if" n >>> 10,
+ 2 * 3 &"if" n != 5,
+ 1 - 0 thick &...,
+ ) $
+
+---
+// Error: 1:3 expected dollar sign
+$a
diff --git a/tests/typ/math/underover.typ b/tests/typ/math/underover.typ
new file mode 100644
index 000000000..f0f673083
--- /dev/null
+++ b/tests/typ/math/underover.typ
@@ -0,0 +1,21 @@
+// Test under/over things.
+
+---
+// Test braces.
+$ x = underbrace(
+ 1 + 2 + ... + 5,
+ underbrace("numbers", x + y)
+) $
+
+---
+// Test lines and brackets.
+$ x = overbracket(
+ overline(underline(x + y)),
+ 1 + 2 + ... + 5,
+) $
+
+---
+// Test brackets.
+$ underbracket([1, 2/3], "relevant stuff")
+ arrow.l.r.double.long
+ overbracket([4/5,6], "irrelevant stuff") $
diff --git a/tests/typ/math/vec.typ b/tests/typ/math/vec.typ
new file mode 100644
index 000000000..198a1d154
--- /dev/null
+++ b/tests/typ/math/vec.typ
@@ -0,0 +1,14 @@
+// Test vectors.
+
+---
+// Test wide cell.
+$ v = vec(1, 2+3, 4) $
+
+---
+// Test alternative delimiter.
+#set math.vec(delim: "[")
+$ vec(1, 2) $
+
+---
+// Error: 22-25 expected "(", "[", "{", "|", or "||"
+#set math.vec(delim: "%")
diff --git a/tools/test-helper/extension.js b/tools/test-helper/extension.js
index 335fcc150..a745916a5 100644
--- a/tools/test-helper/extension.js
+++ b/tools/test-helper/extension.js
@@ -108,6 +108,12 @@ function getWebviewContent(pngSrc, refSrc, stdout, stderr) {
}
.flex {
display: flex;
+ flex-wrap: wrap;
+ }
+ .flex > * {
+ flex-grow: 1;
+ flex-shrink: 0;
+ max-width: 100%;
}