Expand Unicode math rendering with accents, symbols, and delimiters (#46266)

## What changed

- Render `\hat`, `\bar`, `\tilde`, `\vec`, `\dot`, and `\ddot` on single visible graphemes, preserving support for following subscripts and superscripts.
- Add symbols for physics, relations, sets, logic, arrows, and integrals, including `\hbar`.
- Support named delimiters, including with `\left` and `\right`, plus angle brackets written as `\left<` and `\right>`.
- Preserve raw math when accent arguments are empty, invisible, or span multiple graphemes or layout rows.

## Testing

Add snapshots for accents, symbols, named delimiters, and the Schrödinger equation, plus rejection tests for ambiguous accent arguments and unsupported delimiters.

GitOrigin-RevId: 9dd4883cdf187835a4b4e873886c4299de488705
This commit is contained in:
Eric Traut
2026-09-17 17:35:27 +00:00
committed by copyberry
parent 16f49ccd7f
commit 608825d511
6 changed files with 216 additions and 4 deletions

View File

@@ -1,6 +1,8 @@
//! Bounded Unicode layout for a deliberately small TeX math subset.
//! Accents apply only to single graphemes so their scope survives terminal rendering.
use crate::width::display_width;
use unicode_segmentation::UnicodeSegmentation;
const MAX_ROWS: usize = 16;
const MAX_COLUMNS: usize = 256;
@@ -111,7 +113,9 @@ impl MathParser<'_> {
let atom = self.atom()?;
if !ch.is_whitespace() && ch != '^' && ch != '_' {
// Flattening a compound base would change the scope of a following script.
has_base = atom.single().is_some_and(|text| text.chars().count() == 1);
has_base = atom
.single()
.is_some_and(|text| text.graphemes(/*is_extended*/ true).count() == 1);
}
result = result.join(atom)?;
}
@@ -245,6 +249,26 @@ impl MathParser<'_> {
};
Some(Layout::text(text))
}
"hat" | "bar" | "tilde" | "vec" | "dot" | "ddot" => {
let arg = self.argument()?;
let text = arg.single()?;
if text.graphemes(/*is_extended*/ true).count() != 1
|| text.trim().is_empty()
|| display_width(text) == 0
{
return None;
}
let accent = match name {
"hat" => '\u{0302}',
"bar" => '\u{0304}',
"tilde" => '\u{0303}',
"vec" => '\u{20d7}',
"dot" => '\u{0307}',
"ddot" => '\u{0308}',
_ => unreachable!(),
};
Some(Layout::text(format!("{text}{accent}")))
}
"mathrm" | "mathbf" | "mathit" => self.argument(),
"text" | "operatorname" => {
self.remaining = self.remaining.trim_start().strip_prefix('{')?;
@@ -263,6 +287,19 @@ impl MathParser<'_> {
match self.take()? {
'.' => Some(Layout::text("")),
ch @ ('(' | ')' | '[' | ']' | '|') => Some(Layout::text(ch.to_string())),
'<' => Some(Layout::text("")),
'>' => Some(Layout::text("")),
'\\' => {
let length = self
.remaining
.bytes()
.take_while(u8::is_ascii_alphabetic)
.count()
.max(/*other*/ 1);
let (name, remaining) = self.remaining.split_at_checked(length)?;
self.remaining = remaining;
delimiter(name).map(Layout::text)
}
_ => None,
}
}
@@ -289,13 +326,17 @@ fn symbol(name: &str) -> Option<&'static str> {
"vartheta" => "ϑ",
"iota" => "ι",
"kappa" => "κ",
"varkappa" => "ϰ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"pi" => "π",
"varpi" => "ϖ",
"rho" => "ρ",
"varrho" => "ϱ",
"sigma" => "σ",
"varsigma" => "ς",
"tau" => "τ",
"upsilon" => "υ",
"phi" => "ϕ",
@@ -316,35 +357,103 @@ fn symbol(name: &str) -> Option<&'static str> {
"Omega" => "Ω",
"sum" => "",
"prod" => "",
"coprod" => "",
"int" => "",
"iint" => "",
"iiint" => "",
"oint" => "",
"infty" => "",
"partial" => "",
"nabla" => "",
"hbar" => "",
"ell" => "",
"Re" => "",
"Im" => "",
"aleph" => "",
"imath" => "ı",
"jmath" => "ȷ",
"prime" => "",
"angle" => "",
"dagger" => "",
"ddagger" => "",
"pm" => "±",
"mp" => "",
"times" => "×",
"cdot" => "·",
"div" => "÷",
"circ" => "",
"bullet" => "",
"oplus" => "",
"otimes" => "",
"odot" => "",
"le" | "leq" => "",
"ge" | "geq" => "",
"ne" | "neq" => "",
"approx" => "",
"propto" => "",
"sim" => "",
"simeq" => "",
"cong" => "",
"lesssim" => "",
"gtrsim" => "",
"ll" => "",
"gg" => "",
"perp" => "",
"parallel" => "",
"equiv" => "",
"in" => "",
"notin" => "", // codespell:ignore notin
"ni" => "",
"subset" => "",
"subseteq" => "",
"supset" => "",
"supseteq" => "",
"cup" => "",
"cap" => "",
"emptyset" => "",
"bigcup" => "",
"bigcap" => "",
"setminus" => "",
"emptyset" | "varnothing" => "",
"land" | "wedge" => "",
"lor" | "vee" => "",
"neg" | "lnot" => "¬",
"top" => "",
"bot" => "",
"forall" => "",
"exists" => "",
"nexists" => "",
"to" | "rightarrow" => "",
"leftarrow" => "",
"Rightarrow" => "",
"Leftrightarrow" => "",
"leftrightarrow" => "",
"mapsto" => "",
"uparrow" => "",
"downarrow" => "",
"updownarrow" => "",
"Leftarrow" | "impliedby" => "",
"Rightarrow" | "implies" => "",
"Leftrightarrow" | "iff" => "",
"ldots" | "dots" => "",
"cdots" => "",
"vdots" => "",
"ddots" => "",
_ => return delimiter(name),
})
}
fn delimiter(name: &str) -> Option<&'static str> {
Some(match name {
"langle" => "",
"rangle" => "",
"lbrace" | "{" => "{",
"rbrace" | "}" => "}",
"lbrack" => "[",
"rbrack" => "]",
"vert" | "lvert" | "rvert" => "|",
"Vert" | "lVert" | "rVert" | "|" => "",
"lfloor" => "",
"rfloor" => "",
"lceil" => "",
"rceil" => "",
_ => return None,
})
}

View File

@@ -56,6 +56,20 @@ $$\beta$$";
insta::assert_snapshot!(plain(source, /*width*/ 80));
}
#[test]
fn unicode_math_schrodinger_equation_snapshot() {
insta::assert_snapshot!(plain(
r"\[
i\hbar \frac{\partial}{\partial t}\Psi(\mathbf r,t)
=
\hat H\Psi(\mathbf r,t)
=
\left[-\frac{\hbar^2}{2m}\nabla^2+V(\mathbf r,t)\right]\Psi(\mathbf r,t).
\]",
/*width*/ 100
));
}
#[test]
fn unicode_math_narrow_layout_stays_meaningful() {
insta::assert_snapshot!(plain(

View File

@@ -31,6 +31,60 @@ fn unicode_math_inline_narrow_snapshot() {
));
}
#[test]
fn unicode_math_accents_and_symbols_snapshot() {
insta::assert_snapshot!(plain(
r"Operators: $\hat H^2 + \hbar^2$, $\ell$, $A\dagger$, $B\ddagger$.
Accents: $\bar{x}_1$, $\tilde{x}^2$, $\vec{v}_i$, $\dot{x}$, $\ddot{x}$.
Greek accents: $\hat{\Psi}^2$, $\bar{\varrho}$, $\tilde{\varsigma}$.
Other symbols: $\varkappa+\varpi$, $\Re z+\Im z$, $\aleph_0$, $\hat{\imath}$.
Relations: $a\propto b\sim c\simeq d\ll e\gg f$.
Geometry: $a\perp b\parallel c$, $\angle ABC\cong\angle DEF$.
Bounds: $a\lesssim b\gtrsim c$; operators: $a\oplus b\otimes c\odot d$.
Sets: $A\supseteq B\supset C\ni x$, $A\setminus B=\varnothing$.
Logic: $\neg P\land Q\lor R\implies S\iff T\impliedby U$, $\nexists x$.
Arrows: $a\leftrightarrow b\mapsto c\Leftarrow d$, $\uparrow\downarrow\updownarrow$.
Integrals: $\iint f$, $\iiint g$, $\oint h$.
Collections: $\coprod A$, $\bigcup B$, $\bigcap C$.
Punctuation: $x\prime$, $a\circ b\bullet c$, $\vdots\ddots$.",
/*width*/ 80
));
}
#[test]
fn unicode_math_named_delimiters_snapshot() {
insta::assert_snapshot!(plain(
r"Inner product: $\left\langle\hat H\right\rangle$.
Rounding: $\lfloor x\rfloor + \left\lceil y\right\rceil$.
Norms: $\left\lVert v\right\rVert$, $\lvert x\rvert$, $\left\|w\right\|$.
Sets: $\left\{x\right\}$, $\lbrace y\rbrace$, $\lbrack z\rbrack$.
Angles: $\left<x\right>$.",
/*width*/ 80
));
}
#[test]
fn unicode_math_accents_reject_ambiguous_arguments() {
for source in [
r"\hat{xy}",
r"\bar{x+y}",
r"\tilde{x^2}",
r"\vec{\frac{x}{y}}",
r"\dot{}",
r"\ddot{ }",
r"\hat",
"\\hat{\u{0302}}",
] {
for display in [false, true] {
assert_eq!(render(source, display), None, "{source}");
}
assert_eq!(
plain(&format!("\\({source}\\)"), /*width*/ 80),
format!("\\({source}\\)")
);
}
}
#[test]
fn unicode_math_preserves_markdown_contexts() {
for (source, expected) in [
@@ -77,6 +131,8 @@ fn unicode_math_bounds_and_unsupported_input() {
r"\sqrt[3]{x}",
r"\sqrt [3]{x}",
r"\left x",
r"\left\alpha x\right\rangle",
r"\left\ ",
r"\text{\alpha}",
r"\begin{matrix}a&b\end{matrix}",
] {

View File

@@ -0,0 +1,7 @@
---
source: tui/src/markdown_render/math_display_tests.rs
expression: "plain(r\"\\[\ni\\hbar \\frac{\\partial}{\\partial t}\\Psi(\\mathbf r,t)\n=\n\\hat H\\Psi(\\mathbf r,t)\n=\n\\left[-\\frac{\\hbar^2}{2m}\\nabla^2+V(\\mathbf r,t)\\right]\\Psi(\\mathbf r,t).\n\\]\",\n100)"
---
∂ ℏ²
iℏ ───Ψ(r,t) = ĤΨ(r,t) = [- ──∇²+V(r,t)]Ψ(r,t).
∂ t 2m

View File

@@ -0,0 +1,17 @@
---
source: tui/src/markdown_render/math_tests.rs
expression: "plain(r\"Operators: $\\hat H^2 + \\hbar^2$, $\\ell$, $A\\dagger$, $B\\ddagger$.\nAccents: $\\bar{x}_1$, $\\tilde{x}^2$, $\\vec{v}_i$, $\\dot{x}$, $\\ddot{x}$.\nGreek accents: $\\hat{\\Psi}^2$, $\\bar{\\varrho}$, $\\tilde{\\varsigma}$.\nOther symbols: $\\varkappa+\\varpi$, $\\Re z+\\Im z$, $\\aleph_0$, $\\hat{\\imath}$.\nRelations: $a\\propto b\\sim c\\simeq d\\ll e\\gg f$.\nGeometry: $a\\perp b\\parallel c$, $\\angle ABC\\cong\\angle DEF$.\nBounds: $a\\lesssim b\\gtrsim c$; operators: $a\\oplus b\\otimes c\\odot d$.\nSets: $A\\supseteq B\\supset C\\ni x$, $A\\setminus B=\\varnothing$.\nLogic: $\\neg P\\land Q\\lor R\\implies S\\iff T\\impliedby U$, $\\nexists x$.\nArrows: $a\\leftrightarrow b\\mapsto c\\Leftarrow d$, $\\uparrow\\downarrow\\updownarrow$.\nIntegrals: $\\iint f$, $\\iiint g$, $\\oint h$.\nCollections: $\\coprod A$, $\\bigcup B$, $\\bigcap C$.\nPunctuation: $x\\prime$, $a\\circ b\\bullet c$, $\\vdots\\ddots$.\",\n80)"
---
Operators: Ĥ² + ℏ², , A†, B‡.
Accents: x̄₁, x̃², v⃗ᵢ, ẋ, ẍ.
Greek accents: Ψ̂², ϱ̄, ς̃.
Other symbols: ϰ+ϖ, z+ z, ℵ₀, ı̂.
Relations: a∝ b c≃ d≪ e≫ f.
Geometry: a⊥ b∥ c, ∠ ABC≅∠ DEF.
Bounds: a≲ b≳ c; operators: a⊕ b⊗ c⊙ d.
Sets: A⊇ B⊃ C∋ x, A B=∅.
Logic: ¬ P∧ Q R⇒ S⇔ T⇐ U, ∄ x.
Arrows: a↔ b↦ c⇐ d, ↑↓↕.
Integrals: ∬ f, ∭ g, ∮ h.
Collections: ∐ A, B, ⋂ C.
Punctuation: x, a∘ b∙ c, ⋮⋱.

View File

@@ -0,0 +1,9 @@
---
source: tui/src/markdown_render/math_tests.rs
expression: "plain(r\"Inner product: $\\left\\langle\\hat H\\right\\rangle$.\nRounding: $\\lfloor x\\rfloor + \\left\\lceil y\\right\\rceil$.\nNorms: $\\left\\lVert v\\right\\rVert$, $\\lvert x\\rvert$, $\\left\\|w\\right\\|$.\nSets: $\\left\\{x\\right\\}$, $\\lbrace y\\rbrace$, $\\lbrack z\\rbrack$.\nAngles: $\\left<x\\right>$.\",\n80)"
---
Inner product: ⟨Ĥ⟩.
Rounding: ⌊ x⌋ + ⌈ y⌉.
Norms: ‖ v‖, | x|, ‖w‖.
Sets: {x}, { y}, [ z].
Angles: ⟨x⟩.