craft-math: latex_text, LaTeX math and the linear format converted as text

WordCraft's crates/formats/src/latex/math.rs (07f97737): the converter its
LaTeX document reader and writer use, which keeps the source's spacing.
It moves here so craft-math is the one place that converts LaTeX math;
craft-latex calls it, and its output does not change. Its tests (from
WordCraft's latex tests and cross-format tests) come along.
This commit is contained in:
Dylan M. Taylor
2026-11-12 01:12:07 +00:00
parent d56a2a1cb3
commit 606a1bf330
3 changed files with 633 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ Equations as one tree, and the syntaxes it is read from and written to:
| `math` | the tree: `MNode`, `MRun`, `Math` (an equation with its kept source OMML), modelled on the Office Math object model (ECMA-376 Part 1 §22.1) | | |
| `math_linear` | UnicodeMath, the linear format Word shows (`x=(-b±√(b^2-4ac))/2a`) | `math::parse_linear` | `math::to_linear` |
| `math_latex` | LaTeX math, including MathLive's spellings | `parse_latex` | `to_latex` |
| `latex_text` | LaTeX math as text, keeping the source's spacing (for LaTeX documents) | `latex_to_linear` | `linear_to_latex` |
| `omml` | Office Math Markup (`m:oMath`, DOCX/PPTX/XLSX) | `read_omath`, `read_str` | `write_equation`, `write_str` |
| `mathml` | presentation MathML (HTML, ODF formulas, the clipboard) | `parse`, `read`, `to_tree` | `to_mathml`, `linear_to_mathml` |
+629
View File
@@ -0,0 +1,629 @@
//! LaTeX math and the linear format converted as text, keeping the source's spacing: for LaTeX
//! documents, whose math should read back as it was written. [`crate::math_latex`] converts the
//! same two syntaxes through the equation tree.
/// Nesting limit for groups and arguments (hostile input).
const MAX_NEST: usize = 32;
// ---------------------------------------------------------------------------------------------
// Equations: WordCraft keeps them in the linear format (`x=(-b±√(b^2-4ac))/2a`).
/// Symbols with a LaTeX command: (character, command without the backslash).
const MATH_SYMBOLS: &[(char, &str)] = &[
('α', "alpha"),
('β', "beta"),
('γ', "gamma"),
('δ', "delta"),
('ϵ', "epsilon"),
('ε', "varepsilon"),
('ζ', "zeta"),
('η', "eta"),
('θ', "theta"),
('ϑ', "vartheta"),
('ι', "iota"),
('κ', "kappa"),
('λ', "lambda"),
('μ', "mu"),
('ν', "nu"),
('ξ', "xi"),
('π', "pi"),
('ϖ', "varpi"),
('ρ', "rho"),
('ϱ', "varrho"),
('σ', "sigma"),
('ς', "varsigma"),
('τ', "tau"),
('υ', "upsilon"),
('ϕ', "phi"),
('φ', "varphi"),
('χ', "chi"),
('ψ', "psi"),
('ω', "omega"),
('Γ', "Gamma"),
('Δ', "Delta"),
('Θ', "Theta"),
('Λ', "Lambda"),
('Ξ', "Xi"),
('Π', "Pi"),
('Σ', "Sigma"),
('Υ', "Upsilon"),
('Φ', "Phi"),
('Ψ', "Psi"),
('Ω', "Omega"),
('±', "pm"),
('∓', "mp"),
('×', "times"),
('÷', "div"),
('⋅', "cdot"),
('·', "cdot"),
('∘', "circ"),
('∗', "ast"),
('≤', "leq"),
('≥', "geq"),
('≠', "neq"),
('≈', "approx"),
('≡', "equiv"),
('∼', "sim"),
('≃', "simeq"),
('≅', "cong"),
('∝', "propto"),
('≪', "ll"),
('≫', "gg"),
('∈', "in"),
('∉', "notin"),
('∋', "ni"),
('⊂', "subset"),
('⊃', "supset"),
('⊆', "subseteq"),
('⊇', "supseteq"),
('∪', "cup"),
('∩', "cap"),
('∖', "setminus"),
('∅', "emptyset"),
('∀', "forall"),
('∃', "exists"),
('¬', "neg"),
('∧', "wedge"),
('∨', "vee"),
('⊕', "oplus"),
('⊗', "otimes"),
('→', "to"),
('←', "leftarrow"),
('↔', "leftrightarrow"),
('⇒', "Rightarrow"),
('⇐', "Leftarrow"),
('⇔', "Leftrightarrow"),
('↦', "mapsto"),
('↑', "uparrow"),
('↓', "downarrow"),
('∑', "sum"),
('∏', "prod"),
('∐', "coprod"),
('∫', "int"),
('∬', "iint"),
('∭', "iiint"),
('∮', "oint"),
('⋃', "bigcup"),
('⋂', "bigcap"),
('∞', "infty"),
('∂', "partial"),
('∇', "nabla"),
('ℏ', "hbar"),
('ℓ', "ell"),
('ℵ', "aleph"),
('ℜ', "Re"),
('ℑ', "Im"),
('∠', "angle"),
('⊥', "perp"),
('∥', "parallel"),
('′', "prime"),
('…', "ldots"),
('⋯', "cdots"),
('⋮', "vdots"),
('⋱', "ddots"),
('⟨', "langle"),
('⟩', "rangle"),
('⌊', "lfloor"),
('⌋', "rfloor"),
('⌈', "lceil"),
('⌉', "rceil"),
('°', "circ"),
('•', "bullet"),
('⊤', "top"),
];
/// Extra input spellings mapped to the same symbols.
const MATH_ALIASES: &[(&str, char)] = &[
("le", '≤'),
("ge", '≥'),
("ne", '≠'),
("rightarrow", '→'),
("gets", '←'),
("land", '∧'),
("lor", '∨'),
("lnot", '¬'),
("dots", '…'),
("dotsc", '…'),
("dotsb", '⋯'),
("varnothing", '∅'),
("implies", '⇒'),
("iff", '⇔'),
("degree", '°'),
("lbrace", '{'),
("rbrace", '}'),
("vert", '|'),
("mid", '|'),
("Vert", '‖'),
("lvert", '|'),
("rvert", '|'),
("bot", '⊥'),
("hslash", 'ℏ'),
("colon", ':'),
];
/// Operator names written upright in both formats.
const MATH_FUNCTIONS: &[&str] = &[
"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "coth", "log", "ln", "lg", "exp", "lim",
"liminf", "limsup", "min", "max", "sup", "inf", "det", "dim", "gcd", "deg", "arg", "ker", "Pr", "hom",
];
/// Control words an exported equation may carry besides the symbols and functions above (font
/// switches, delimiters, spacing).
const MATH_COMMANDS: &[&str] = &[
"mathbb",
"mathbf",
"mathcal",
"mathfrak",
"mathit",
"mathrm",
"mathsf",
"mathtt",
"boldsymbol",
"left",
"right",
"quad",
"qquad",
"displaystyle",
"textstyle",
"limits",
"nolimits",
"not",
];
/// May `\\name` from an equation's text be written to the exported file as a command? Only the
/// names known here. An equation's text comes from the document (a file someone else may have
/// made), and `\\input`, `\\write` or `\\def` in it would act on the machine of whoever compiles
/// the export.
fn known_math_command(name: &str) -> bool {
math_symbol(name).is_some() || MATH_FUNCTIONS.contains(&name) || MATH_COMMANDS.contains(&name)
}
fn math_symbol(name: &str) -> Option<char> {
MATH_SYMBOLS.iter().find(|(_, n)| *n == name).map(|(c, _)| *c).or_else(|| MATH_ALIASES.iter().find(|(n, _)| *n == name).map(|(_, c)| *c))
}
/// For every `l` in `chars`, the index of the `r` that closes it, found in one stack pass.
/// Looking groups up here keeps scanning linear: rescanning to the end of the input for every
/// unmatched `(` made long equations quadratic.
pub(super) fn closers(chars: &[char], l: char, r: char) -> Vec<Option<usize>> {
let mut out = vec![None; chars.len()];
let mut open = Vec::new();
for (i, c) in chars.iter().enumerate() {
if *c == l {
open.push(i);
} else if *c == r
&& let Some(o) = open.pop()
&& let Some(slot) = out.get_mut(o)
{
*slot = Some(i);
}
}
out
}
/// The balanced group opening at `open` (see [`closers`]): its content and the index after it.
pub(super) fn balanced(chars: &[char], closers: &[Option<usize>], open: usize) -> Option<(String, usize)> {
let close = (*closers.get(open)?)?;
Some((chars.get(open + 1..close).unwrap_or_default().iter().collect(), close + 1))
}
/// A linear-format equation as LaTeX math.
pub fn linear_to_latex(linear: &str) -> String {
linear_to_latex_at(linear, 0)
}
fn linear_to_latex_at(linear: &str, depth: usize) -> String {
let chars: Vec<char> = linear.chars().collect();
let parens = closers(&chars, '(', ')');
let mut o = String::new();
let mut i = 0;
let sub = |s: &str| if depth < MAX_NEST { linear_to_latex_at(s, depth + 1) } else { escape_math_text(s) };
while let Some(&c) = chars.get(i) {
// (a)/(b) → \frac{a}{b}
// (The `/` is looked for before the group is copied out: copying every group of a deeply
// nested equation only to find no `/` after it was quadratic.)
if c == '('
&& let Some(Some(close)) = parens.get(i)
&& chars.get(close + 1) == Some(&'/')
&& let Some((num, after)) = balanced(&chars, &parens, i)
&& let Some((den, end)) = balanced(&chars, &parens, after + 1)
{
o.push_str(&format!("\\frac{{{}}}{{{}}}", sub(&num), sub(&den)));
i = end;
continue;
}
match c {
'^' | '_' => {
// Never `^^`: TeX reads `^^5c` as a backslash, which would start a command.
if c == '^' && o.ends_with('^') {
o.push_str("{}");
}
o.push(c);
match balanced(&chars, &parens, i + 1) {
Some((inner, end)) => {
o.push_str(&format!("{{{}}}", sub(&inner)));
i = end;
}
None => i += 1,
}
continue;
}
'√' => {
match balanced(&chars, &parens, i + 1) {
Some((inner, end)) => {
match inner.split_once('&') {
Some((n, x)) => o.push_str(&format!("\\sqrt[{}]{{{}}}", sub(n), sub(x))),
None => o.push_str(&format!("\\sqrt{{{}}}", sub(&inner))),
}
i = end;
}
None => {
o.push_str("\\surd ");
i += 1;
}
}
continue;
}
'\\' => {
// Linear format control words (`\alpha`) are LaTeX's too.
let name: String = chars.iter().skip(i + 1).take_while(|c| c.is_ascii_alphabetic()).collect();
if name.is_empty() {
o.push_str("\\backslash ");
i += 1;
} else if !known_math_command(&name) {
// Not a command we know: written as the text it is.
o.push_str("\\backslash ");
o.push_str(&name);
i += 1 + name.len();
} else {
o.push('\\');
o.push_str(&name);
i += 1 + name.len();
if chars.get(i).is_some_and(char::is_ascii_alphabetic) {
o.push(' ');
}
}
continue;
}
'#'..='&' => {
o.push('\\');
o.push(c);
}
'{' => o.push_str("\\{"),
'}' => o.push_str("\\}"),
'~' => o.push_str("\\sim "),
c => match MATH_SYMBOLS.iter().find(|(s, _)| *s == c) {
Some((_, name)) => {
o.push('\\');
o.push_str(name);
// A control word needs a space only before a letter.
if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) {
o.push(' ');
}
}
None if c.is_control() => {}
None => o.push(c),
},
}
i += 1;
}
o.trim().to_string()
}
fn escape_math_text(s: &str) -> String {
let mut o = String::new();
for c in s.chars().filter(|c| !matches!(c, '{' | '}' | '\\' | '$' | '%' | '#' | '&') && !c.is_control()) {
// Never `^^` (see `linear_to_latex_at`).
if c == '^' && o.ends_with('^') {
continue;
}
o.push(c);
}
o
}
/// LaTeX math as a linear-format equation.
pub fn latex_to_linear(src: &str) -> String {
let toks = math_tokens(src);
let mut pos = 0;
let s = math_seq(&toks, &mut pos, 0, false);
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[derive(Clone, Debug, PartialEq)]
enum MTok {
Cmd(String),
Open,
Close,
OptOpen,
OptClose,
Char(char),
}
fn math_tokens(src: &str) -> Vec<MTok> {
let chars: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
match c {
'\\' => {
let name: String = chars.iter().skip(i + 1).take_while(|c| c.is_ascii_alphabetic()).collect();
if name.is_empty() {
if let Some(&n) = chars.get(i + 1) {
out.push(MTok::Cmd(n.to_string()));
}
i += 2;
} else {
i += 1 + name.len();
out.push(MTok::Cmd(name));
}
}
'{' => {
out.push(MTok::Open);
i += 1;
}
'}' => {
out.push(MTok::Close);
i += 1;
}
'[' => {
out.push(MTok::OptOpen);
i += 1;
}
']' => {
out.push(MTok::OptClose);
i += 1;
}
'%' => {
// A comment, to the end of the line.
while chars.get(i).is_some_and(|c| *c != '\n') {
i += 1;
}
}
c => {
out.push(MTok::Char(if c.is_whitespace() { ' ' } else { c }));
i += 1;
}
}
}
out
}
/// One argument: a `{…}` group or a single token.
fn math_arg(t: &[MTok], pos: &mut usize, depth: usize) -> String {
while t.get(*pos) == Some(&MTok::Char(' ')) {
*pos += 1;
}
match t.get(*pos) {
Some(MTok::Open) => {
*pos += 1;
let s = math_seq(t, pos, depth + 1, false);
if t.get(*pos) == Some(&MTok::Close) {
*pos += 1;
}
s
}
Some(MTok::Close) | None => String::new(),
Some(_) => {
let end = *pos + 1;
let mut p = *pos;
let s = math_seq(t.get(..end).unwrap_or_default(), &mut p, depth + 1, false);
*pos = end;
s
}
}
}
/// An optional `[…]` argument.
fn math_opt(t: &[MTok], pos: &mut usize, depth: usize) -> Option<String> {
if t.get(*pos) != Some(&MTok::OptOpen) {
return None;
}
*pos += 1;
let s = math_seq(t, pos, depth + 1, true);
if t.get(*pos) == Some(&MTok::OptClose) {
*pos += 1;
}
Some(s)
}
/// Parenthesise a fraction part or script unless it is a single symbol.
fn wrap_part(s: &str) -> String {
let s = s.trim();
if s.chars().count() == 1 || (!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) { s.to_string() } else { format!("({s})") }
}
fn math_seq(t: &[MTok], pos: &mut usize, depth: usize, in_opt: bool) -> String {
let mut o = String::new();
while let Some(tok) = t.get(*pos) {
match tok {
MTok::Close => break,
MTok::OptClose if in_opt => break,
MTok::Open => {
*pos += 1;
if depth < MAX_NEST {
o.push_str(&math_seq(t, pos, depth + 1, false));
}
if t.get(*pos) == Some(&MTok::Close) {
*pos += 1;
}
continue;
}
MTok::OptOpen => o.push('['),
MTok::OptClose => o.push(']'),
MTok::Char('^') | MTok::Char('_') => {
let c = if *tok == MTok::Char('^') { '^' } else { '_' };
*pos += 1;
let a = if depth < MAX_NEST { math_arg(t, pos, depth) } else { String::new() };
o.push(c);
o.push_str(&wrap_part(&a));
continue;
}
MTok::Char('&') => o.push(' '),
MTok::Char('~') => o.push(' '),
MTok::Char(c) => o.push(*c),
MTok::Cmd(name) => {
*pos += 1;
let name = name.as_str();
if depth >= MAX_NEST {
continue;
}
match name {
"frac" | "dfrac" | "tfrac" | "cfrac" => {
let a = math_arg(t, pos, depth);
let b = math_arg(t, pos, depth);
o.push_str(&format!("{}/{}", wrap_part(&a), wrap_part(&b)));
}
"binom" | "dbinom" | "tbinom" => {
let a = math_arg(t, pos, depth);
let b = math_arg(t, pos, depth);
o.push_str(&format!("({a}¦{b})"));
}
"sqrt" => {
let n = math_opt(t, pos, depth);
let a = math_arg(t, pos, depth);
match n {
Some(n) => o.push_str(&format!("√({n}&{a})")),
None => o.push_str(&format!("√({a})")),
}
}
"left" | "right" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "bigr" | "Bigl" | "Bigr" | "middle" => {
// Keep the delimiter, drop the sizing; `\left.` is no delimiter.
if t.get(*pos) == Some(&MTok::Char('.')) {
*pos += 1;
}
}
"text" | "textrm" | "textit" | "textbf" | "mathrm" | "mathit" | "mathbf" | "mathsf" | "mathtt" | "mathcal" | "mathbb"
| "mathfrak" | "boldsymbol" | "bm" | "pmb" | "operatorname" | "mbox" | "hbox" | "displaystyle" | "textstyle" | "scriptstyle" => {
if t.get(*pos) == Some(&MTok::Open) || !matches!(name, "displaystyle" | "textstyle" | "scriptstyle") {
let a = math_arg(t, pos, depth);
o.push_str(&a);
}
}
"overline" | "bar" => o.push_str(&format!("{}¯", wrap_part(&math_arg(t, pos, depth)))),
"hat" | "widehat" => o.push_str(&format!("{}̂", math_arg(t, pos, depth))),
"tilde" | "widetilde" => o.push_str(&format!("{}̃", math_arg(t, pos, depth))),
"vec" => o.push_str(&format!("{}⃗", math_arg(t, pos, depth))),
"dot" => o.push_str(&format!("{}̇", math_arg(t, pos, depth))),
"ddot" => o.push_str(&format!("{}̈", math_arg(t, pos, depth))),
"acute" => o.push_str(&format!("{}́", math_arg(t, pos, depth))),
"grave" => o.push_str(&format!("{}̀", math_arg(t, pos, depth))),
"breve" => o.push_str(&format!("{}̆", math_arg(t, pos, depth))),
"check" => o.push_str(&format!("{}̌", math_arg(t, pos, depth))),
"pmod" => {
let a = math_arg(t, pos, depth);
o.push_str(&format!(" (mod {a})"));
}
"bmod" => o.push_str(" mod "),
"begin" | "end" => {
// Inner environments (`matrix`, `cases`, `aligned`…): keep the content.
let _ = math_arg(t, pos, depth);
if name == "begin" {
let _ = math_opt(t, pos, depth);
}
}
"," | ";" | ":" | "quad" | "qquad" | " " | "enspace" | "thinspace" => o.push(' '),
"!" | "limits" | "nolimits" | "nonumber" | "notag" => {}
"\\" | "cr" => o.push(' '),
"{" | "}" | "%" | "#" | "&" | "$" | "_" => o.push_str(name),
"label" | "tag" => {
let _ = math_arg(t, pos, depth);
}
n if MATH_FUNCTIONS.contains(&n) => {
o.push_str(n);
o.push(' ');
}
n => match math_symbol(n) {
Some(c) => o.push(c),
// Linear format knows LaTeX-style control words too.
None => {
o.push('\\');
o.push_str(n);
o.push(' ');
}
},
}
continue;
}
}
*pos += 1;
}
o
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn math_conversions() {
for (tex, linear) in [
(r"\frac{1}{2}", "1/2"),
(r"x_{i}^{2}", "x_i^2"),
(r"x^{10}", "x^10"),
(r"e^{i\pi}", "e^(iπ)"),
(r"\sqrt[3]{x}", "√(3&x)"),
(r"\left( a \right)", "( a )"),
(r"\sin x + \text{if } y", "sin x + if y"),
(r"a \cdot b \times c \neq d", "a ⋅ b × c ≠ d"),
(r"a \bullet b", "a • b"),
(r"x \top y \bot z", "x ⊤ y ⊥ z"),
(r"f \colon X \to Y", "f : X → Y"),
(r"\hbar \hslash", "ℏ ℏ"),
(r"\bm{v}", "v"),
(r"\acute{x} \grave{y} \breve{z} \check{w}", "x́ ỳ z̆ w̌"),
(r"a \equiv b \pmod{m}", "a ≡ b (mod m)"),
(r"a \bmod b", "a mod b"),
(r"\unknowncmd", r"\unknowncmd"),
] {
assert_eq!(latex_to_linear(tex), linear, "{tex}");
}
for (linear, tex) in [
("x=(-b±√(b^2-4ac))/(2a)", r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}"),
("α ≤ β", r"\alpha \leq \beta"),
("αβ", r"\alpha\beta"),
("πr", r"\pi r"),
("a • b", r"a \bullet b"),
("x ⊤ y", r"x \top y"),
("a_(i+1)", r"a_{i+1}"),
("50% & #", r"50\% \& \#"),
("√(3&x)", r"\sqrt[3]{x}"),
] {
assert_eq!(linear_to_latex(linear), tex, "{linear}");
}
}
/// Unmatched and deeply nested groups convert in linear time; nothing the text says becomes a
/// TeX command (from WordCraft's cross-format tests).
#[test]
fn hostile_math_is_linear_and_carries_no_commands() {
assert_eq!(linear_to_latex(&"(".repeat(200_000)).len(), 200_000);
let nested = format!("{}x{}", "(".repeat(100_000), ")".repeat(100_000));
assert_eq!(linear_to_latex(&nested).len(), 200_001);
let tex = linear_to_latex("\\input secret.txt \\immediate\\write18");
assert!(!tex.contains("\\input") && !tex.contains("\\write") && tex.contains("\\backslash input"), "{tex}");
for linear in ["a^^5cinput", "x^(^^5cinput)"] {
assert!(!linear_to_latex(linear).contains("^^"), "{linear}");
}
}
}
+3
View File
@@ -14,6 +14,8 @@
//! ([`omml::write_equation`], [`omml::write_str`]).
//! - [`mathml`]: presentation MathML, written ([`mathml::to_mathml`]) and read
//! ([`mathml::parse`], [`mathml::read`], [`mathml::to_tree`]).
//! - [`latex_text`]: LaTeX math and the linear format converted as text, keeping the source's
//! spacing, for LaTeX documents ([`latex_text::latex_to_linear`], [`latex_text::linear_to_latex`]).
//! - [`math_tree`]: the arguments of a node and a walk over every run.
//! - [`symbol_fonts`]: text in symbol fonts (Symbol, Wingdings…) as Unicode, as OMML runs and
//! Word text need it.
@@ -41,6 +43,7 @@
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
pub mod latex_text;
pub mod math;
pub mod math_latex;
pub mod math_linear;