Files
craft-libs/crates/math/src/math_latex.rs
T
Dylan M. Taylorandshuff57 22161d2d6a craft-math: operator names take their LaTeX scripts directly (\sin^2\theta round trip) (#1019)
* Add craft-math: the equation tree with OMML, MathML, LaTeX and UnicodeMath

The math tree, LaTeX, linear-format, symbol and tree-walk modules come
from craft-wordmodel (storytold/craft-libs#963, WordCraft's crates/doc at
07f97737) unchanged, with the run colour (Rgb) and the symbol-font tables
they use. MathML is WordCraft's crates/formats/src/mathml.rs and OMML is
WordCraft's crates/docx/src/read/math.rs and write/math.rs (06c2ae6f /
07f97737), moved onto craft-xml. The HTML-specific MathML tests stay in
WordCraft; the rest moved with the code.

mathml::parse and omml::parse/read_str/write_str are new: string entry
points for callers that have no tree of their own.

* craft-math: read MathLive's and other spellings of LaTeX math commands

\abs{...} (an absolute value), \lvert \rvert \lVert \rVert, \nparallel,
\backprime, \infinity, \cross, \differentialD, \exponentialE and
\imaginaryI (MathLive's d, upright e and i), \emph, \textsf, \hbox,
\mathnormal and \underleftarrow / \underrightarrow /
\underleftrightarrow were unknown and read as literal text. They are read
as equaSHion's typed-input parser reads them (storytold/craft-libs#29).

* craft-math: equaSHion's equation corpus as test data

corpus/equations.tsv, corpus/latex.tsv and the OMML samples of
storytold/craft-libs#29 (7a6ef6b9), unmodified.

* craft-math: corpus round trips, GridCraft's OMML cases, entry points and limits tested; README

The corpus equations go through OMML, MathML and LaTeX and back; the OMML
samples are written back as kept and from the tree. GridCraft's OMML to
LaTeX test cases (crates/xlsx/src/omml.rs) give LaTeX that reads back the
same through the tree. omml::parse and read_str are tested against other
prefixes, the strict namespace, kept OMML, junk, the element cap and deep
nesting.

* craft-math: an operator name with a script keeps applying to its argument in LaTeX

to_latex wrote \sin^2\theta as {\sin }^{2}{\theta }, which reads back as
(\sin)^2 followed by θ: the braces cut the name off from its argument. An
operator name written as a command now takes its scripts directly
(\sin ^{2}{\theta }). The corpus test no longer needs its exception.

---------

Co-authored-by: shuff57 <62350898+shuff57@users.noreply.github.com>
2026-11-11 20:36:47 -05:00

1317 lines
49 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! LaTeX ↔ math tree (Word's LaTeX equation input and conversion).
//!
//! Input covers the commands people type in equations: `\frac`, `\sqrt[n]{}`, scripts, big
//! operators with limits, `\left…\middle…\right`, matrix and `cases`/`aligned` environments,
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
use crate::math::{
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
};
use crate::math_linear::{FUNCTION_NAMES, map_args};
use crate::math_symbols::{name_of, symbol};
#[derive(Clone, Debug, PartialEq)]
enum Tok {
Cmd(String),
Open,
Close,
Sup,
Sub,
Amp,
Row,
Ch(char),
}
/// Commands whose `{…}` argument is text, where spaces are part of the content.
const TEXT_COMMANDS: &[&str] = &["text", "textrm", "mbox", "hbox", "textnormal", "textbf", "textit", "emph", "textsf"];
fn tokenize(s: &str) -> Vec<Tok> {
let cs: Vec<char> = s.chars().take(20_000).collect();
let mut out = Vec::new();
let mut i = 0;
// A text command was just read: its `{` starts text, where spaces count (#448).
let mut text_next = false;
// Brace depth inside a text argument.
let mut text_depth: Option<u32> = None;
while let Some(&c) = cs.get(i) {
i += 1;
if text_depth.is_some() && matches!(c, ' ' | '\t' | '\n' | '\r') {
// A run of white space is one space, as in LaTeX.
if out.last() != Some(&Tok::Ch(' ')) {
out.push(Tok::Ch(' '));
}
continue;
}
let was_text_next = std::mem::take(&mut text_next);
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
i += 1;
}
if i == start {
match cs.get(i) {
Some('\\') => {
i += 1;
out.push(Tok::Row);
}
Some(&c) => {
i += 1;
out.push(Tok::Cmd(c.to_string()));
}
None => {}
}
} else {
let name: String = cs.get(start..i).unwrap_or(&[]).iter().collect();
text_next = text_depth.is_none() && TEXT_COMMANDS.contains(&name.as_str());
out.push(Tok::Cmd(name));
}
}
'{' => {
text_depth = match text_depth {
Some(d) => Some(d.saturating_add(1)),
None if was_text_next => Some(0),
None => None,
};
out.push(Tok::Open);
}
'}' => {
text_depth = match text_depth {
Some(0) | None => None,
Some(d) => Some(d.saturating_sub(1)),
};
out.push(Tok::Close);
}
'^' => out.push(Tok::Sup),
'_' => out.push(Tok::Sub),
'&' => out.push(Tok::Amp),
// White space between a text command and its `{` doesn't end the wait.
' ' | '\t' | '\n' | '\r' => text_next = was_text_next,
'~' => out.push(Tok::Ch('\u{A0}')),
c => out.push(Tok::Ch(c)),
}
}
out
}
/// Parse LaTeX math into nodes. Never fails.
pub fn parse_latex(s: &str) -> Arg {
let mut p = Lp { t: tokenize(s), i: 0, depth: 0 };
let mut out = Vec::new();
while p.i < p.t.len() {
out.extend(p.seq(false));
// A stray `}` or `&`/`\\` at the top level.
match p.t.get(p.i) {
Some(Tok::Amp) => out.push(run("&")),
Some(Tok::Row) => {}
Some(Tok::Close) => {}
_ => {}
}
p.i += 1;
}
let out = merge_runs(out);
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
return vec![MNode::EqArr { rows: vec![out] }];
}
out
}
fn run(t: &str) -> MNode {
MNode::Run(MRun::new(t))
}
struct Lp {
t: Vec<Tok>,
i: usize,
depth: usize,
}
impl Lp {
fn peek(&self) -> Option<&Tok> {
self.t.get(self.i)
}
/// A sequence up to `}` (in a group), `&`, `\\`, `\right`, `\middle` or `\end`.
fn seq(&mut self, in_group: bool) -> Arg {
self.depth += 1;
let mut out: Arg = Vec::new();
if self.depth > MAX_DEPTH {
self.i = self.t.len();
self.depth -= 1;
return out;
}
while let Some(tok) = self.peek().cloned() {
match tok {
Tok::Close if in_group => break,
Tok::Close | Tok::Amp | Tok::Row => break,
Tok::Cmd(ref c) if matches!(c.as_str(), "right" | "middle" | "end") => break,
Tok::Sup | Tok::Sub => {
self.i += 1;
let s = self.arg();
if out.last().is_some_and(too_deep) {
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
out.extend(s);
} else {
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
}
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
self.i += 1;
if c == "nolimits"
&& let Some(MNode::Nary { lim_loc, .. }) = out.last_mut()
{
*lim_loc = Some(crate::math::LimLoc::SubSup);
}
if c == "limits"
&& let Some(MNode::Nary { lim_loc, .. }) = out.last_mut()
{
*lim_loc = Some(crate::math::LimLoc::UndOvr);
}
}
_ => {
let a = self.atom();
out.extend(a);
}
}
}
self.depth -= 1;
// Big operators take the following term as their operand.
nary_operands(out, self.depth)
}
/// A command argument: a `{group}` or one token.
fn arg(&mut self) -> Arg {
match self.peek() {
Some(Tok::Open) => {
self.i += 1;
let g = self.seq(true);
if self.peek() == Some(&Tok::Close) {
self.i += 1;
}
g
}
Some(_) => self.atom(),
None => Vec::new(),
}
}
/// `[optional]` argument.
fn opt(&mut self) -> Option<Arg> {
if self.peek() != Some(&Tok::Ch('[')) {
return None;
}
self.i += 1;
let mut toks = Vec::new();
let mut level = 0i32;
while let Some(t) = self.peek().cloned() {
self.i += 1;
match t {
Tok::Ch(']') if level == 0 => break,
Tok::Open => level += 1,
Tok::Close => level -= 1,
_ => {}
}
toks.push(t);
}
let mut p = Lp { t: toks, i: 0, depth: self.depth };
Some(p.seq(false))
}
/// The text of a `{…}` argument, verbatim.
fn raw_arg(&mut self) -> String {
let mut s = String::new();
if self.peek() != Some(&Tok::Open) {
if let Some(Tok::Ch(c)) = self.peek().cloned() {
self.i += 1;
s.push(c);
}
return s;
}
self.i += 1;
let mut level = 0i32;
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
let mut after_word = false;
while let Some(t) = self.peek().cloned() {
self.i += 1;
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
match t {
Tok::Close if level == 0 => break,
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
Tok::Open => {
level += 1;
s.push('{');
}
Tok::Close => {
level -= 1;
s.push('}');
}
Tok::Ch(c) => s.push(c),
Tok::Cmd(c) => {
if c.chars().all(|c| c.is_ascii_alphabetic()) {
match symbol(&c) {
Some(sym) => s.push(sym),
None => s.push_str(&c),
}
} else {
s.push_str(&c);
}
if c == " " {
s.push(' ');
}
}
Tok::Sup => s.push('^'),
Tok::Sub => s.push('_'),
Tok::Amp => s.push('&'),
Tok::Row => s.push('\n'),
}
}
s
}
/// A delimiter after `\left`, `\right`, `\middle`, `\big…`.
fn delim(&mut self) -> Option<char> {
let t = self.peek().cloned()?;
self.i += 1;
match t {
Tok::Ch('.') => None,
Tok::Ch(c) => Some(c),
Tok::Cmd(c) => match c.as_str() {
"{" => Some('{'),
"}" => Some('}'),
"|" => Some('‖'),
other => symbol(other),
},
_ => None,
}
}
fn atom(&mut self) -> Arg {
if self.depth > MAX_DEPTH {
self.i = self.t.len();
return Vec::new();
}
self.depth += 1;
let out = self.atom_inner();
self.depth -= 1;
out
}
fn atom_inner(&mut self) -> Arg {
let Some(t) = self.peek().cloned() else { return Vec::new() };
self.i += 1;
match t {
Tok::Open => {
let g = self.seq(true);
if self.peek() == Some(&Tok::Close) {
self.i += 1;
}
g
}
Tok::Ch(c) => vec![run(&c.to_string())],
Tok::Close | Tok::Amp | Tok::Row | Tok::Sup | Tok::Sub => Vec::new(),
Tok::Cmd(c) => self.command(&c),
}
}
fn command(&mut self, c: &str) -> Arg {
let styled = |a: Arg, sty: Option<MSty>, scr: MScr, nor: bool| -> Arg { restyle(a, sty, scr, nor) };
let one = |n: MNode| vec![n];
match c {
"frac" | "dfrac" | "tfrac" | "cfrac" => {
let num = self.arg();
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
one(MNode::Delim {
beg: Some('('),
end: Some(')'),
sep: None,
grow: true,
shp_match: false,
elems: vec![vec![MNode::Frac { kind: FracKind::NoBar, num: n, den: k }]],
})
}
"sqrt" => {
let deg = self.opt().unwrap_or_default();
let e = self.arg();
one(MNode::Rad { deg_hide: deg.is_empty(), deg, e })
}
"left" => {
let beg = self.delim();
let mut elems = vec![self.seq(false)];
let mut sep = None;
let mut end = None;
while let Some(Tok::Cmd(k)) = self.peek().cloned() {
self.i += 1;
if k == "middle" {
sep = self.delim().or(Some('|'));
elems.push(self.seq(false));
} else {
if k == "right" {
end = self.delim();
}
break;
}
}
one(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems })
}
"bigl" | "Bigl" | "biggl" | "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "big" | "Big" | "bigg" | "Bigg" => {
self.delim().map(|d| vec![run(&d.to_string())]).unwrap_or_default()
}
"begin" => {
let env = self.raw_arg();
self.environment(env.trim())
}
"text" | "textrm" | "mbox" | "hbox" | "textnormal" => {
let t = self.raw_arg();
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Plain), ..Default::default() }))
}
"textbf" => {
let t = self.raw_arg();
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Bold), ..Default::default() }))
}
"textit" | "emph" => {
let t = self.raw_arg();
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
}
"textsf" => {
let t = self.raw_arg();
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Plain), scr: MScr::SansSerif, ..Default::default() }))
}
// MathLive's absolute value, upright constants and differential.
"abs" => {
let e = self.arg();
one(MNode::Delim { beg: Some('|'), end: Some('|'), sep: None, grow: true, shp_match: false, elems: vec![e] })
}
"differentialD" => vec![run("d")],
"exponentialE" => vec![MNode::Run(MRun::plain("e"))],
"imaginaryI" => vec![MNode::Run(MRun::plain("i"))],
"operatorname" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let name = vec![MNode::Run(MRun::plain(t))];
if starred && self.peek() == Some(&Tok::Sub) {
self.i += 1;
let lim = self.arg();
vec![MNode::Lim { upper: false, e: name, lim }]
} else {
self.apply(name)
}
}
"tag" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
let mut r = MRun::new(num_str);
r.lit = false;
vec![MNode::Run(r)]
}
"mathrm" | "rm" => {
let a = self.arg();
styled(a, Some(MSty::Plain), MScr::Roman, false)
}
"mathbf" | "bf" => {
let a = self.arg();
styled(a, Some(MSty::Bold), MScr::Roman, false)
}
"mathnormal" => self.arg(),
"mathit" => {
let a = self.arg();
styled(a, Some(MSty::Italic), MScr::Roman, false)
}
"boldsymbol" | "bm" => {
let a = self.arg();
styled(a, Some(MSty::BoldItalic), MScr::Roman, false)
}
"mathbb" => {
let a = self.arg();
styled(a, None, MScr::DoubleStruck, false)
}
"mathcal" | "mathscr" => {
let a = self.arg();
styled(a, None, MScr::Script, false)
}
"mathfrak" => {
let a = self.arg();
styled(a, None, MScr::Fraktur, false)
}
"mathsf" => {
let a = self.arg();
styled(a, None, MScr::SansSerif, false)
}
"mathtt" => {
let a = self.arg();
styled(a, None, MScr::Monospace, false)
}
"overline" => one(MNode::Bar { top: true, e: self.arg() }),
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
let top = c.starts_with("over");
let chr = match c {
"overbrace" => '⏞',
"underbrace" => '⏟',
"overparen" => '⏜',
"underparen" => '⏝',
"overbracket" => '⎴',
_ => '⎵',
};
let e = self.arg();
let g = MNode::GroupChr { chr, top, e };
let want = if top { Tok::Sup } else { Tok::Sub };
if self.peek() == Some(&want) {
self.i += 1;
let lim = self.arg();
one(MNode::Lim { upper: top, e: vec![g], lim })
} else {
one(g)
}
}
"underleftarrow" | "underrightarrow" | "underleftrightarrow" => {
let chr = match c {
"underleftarrow" => '←',
"underrightarrow" => '→',
_ => '↔',
};
one(MNode::GroupChr { chr, top: false, e: self.arg() })
}
"stackrel" | "overset" => {
let over = self.arg();
let base = self.arg();
one(MNode::Lim { upper: true, e: base, lim: over })
}
"underset" => {
let under = self.arg();
let base = self.arg();
one(MNode::Lim { upper: false, e: base, lim: under })
}
"hat" | "widehat" | "tilde" | "widetilde" | "dot" | "ddot" | "dddot" | "check" | "breve" | "acute" | "grave" | "vec" | "bar"
| "overrightarrow" | "overleftarrow" | "overleftrightarrow" | "mathring" => {
let chr = match c {
"hat" | "widehat" => '\u{302}',
"tilde" | "widetilde" => '\u{303}',
"dot" => '\u{307}',
"ddot" => '\u{308}',
"dddot" => '\u{20DB}',
"check" => '\u{30C}',
"breve" => '\u{306}',
"acute" => '\u{301}',
"grave" => '\u{300}',
"bar" => '\u{305}',
"overleftarrow" => '\u{20D6}',
"overleftrightarrow" => '\u{20E1}',
"mathring" => '\u{30A}',
_ => '\u{20D7}',
};
one(MNode::Acc { chr, e: self.arg() })
}
"," | "thinspace" => vec![run("\u{2009}")],
":" | ">" | "medspace" => vec![run("\u{205F}")],
";" | "thickspace" => vec![run("\u{2005}")],
"!" | "negthinspace" => Vec::new(),
" " => vec![run(" ")],
"qquad" => vec![run("\u{2003}\u{2003}")],
"{" | "}" | "%" | "$" | "#" | "&" | "_" => {
// Escaped characters (a literal `#` must not number the equation).
let mut r = MRun::new(c);
r.lit = c == "#";
vec![MNode::Run(r)]
}
"|" => vec![run("‖")],
name if FUNCTION_NAMES.contains(&name) => {
let plain = vec![MNode::Run(MRun::plain(name))];
let limits = matches!(name, "lim" | "liminf" | "limsup" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "Pr");
let named = if limits && self.peek() == Some(&Tok::Sub) {
self.i += 1;
let lim = self.arg();
vec![MNode::Lim { upper: false, e: plain, lim }]
} else if matches!(self.peek(), Some(Tok::Sup) | Some(Tok::Sub)) {
let sup = self.peek() == Some(&Tok::Sup);
self.i += 1;
let s = self.arg();
vec![script(Some(MNode::Run(MRun::plain(name))), sup, s)]
} else {
plain
};
self.apply(named)
}
name => match symbol(name) {
Some(ch) if is_nary_char(ch) => vec![MNode::Nary {
chr: ch,
lim_loc: None,
grow: false,
sub_hide: true,
sup_hide: true,
sub: Vec::new(),
sup: Vec::new(),
e: Vec::new(),
}],
Some(ch) => vec![run(&ch.to_string())],
None => vec![run(&format!("\\{name}"))],
},
}
}
/// A function name applied to the next atom (with its scripts).
fn apply(&mut self, name: Arg) -> Arg {
let mut e = self.atom();
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
self.i += 1;
let s = self.arg();
if any_too_deep(&e) {
e.extend(s);
continue;
}
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
e.push(script(base, t == Tok::Sup, s));
}
if e.is_empty() {
return name;
}
vec![MNode::Func { name, e }]
}
fn environment(&mut self, env: &str) -> Arg {
let base = env.trim_end_matches('*');
let mut col_jc = Vec::new();
if base == "array" || base == "tabular" {
let _ = self.opt();
let spec = self.raw_arg();
for ch in spec.chars() {
match ch {
'l' | 'L' => col_jc.push(ColJc::Left),
'r' | 'R' => col_jc.push(ColJc::Right),
'c' | 'C' => col_jc.push(ColJc::Center),
_ => {}
}
}
} else if base == "aligned" {
let _ = self.opt();
}
// Rows of `&`-separated cells up to `\end{…}`.
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
loop {
let cell = self.seq(false);
if let Some(r) = rows.last_mut() {
r.push(cell);
}
match self.peek().cloned() {
Some(Tok::Amp) => self.i += 1,
Some(Tok::Row) => {
self.i += 1;
rows.push(vec![]);
}
Some(Tok::Cmd(k)) if k == "end" => {
self.i += 1;
let _ = self.raw_arg();
break;
}
Some(Tok::Close) => self.i += 1,
Some(_) => self.i += 1,
None => break,
}
}
// A trailing `\\` leaves an empty last row.
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
rows.pop();
}
let delims = match base {
"pmatrix" => Some((Some('('), Some(')'))),
"bmatrix" => Some((Some('['), Some(']'))),
"Bmatrix" => Some((Some('{'), Some('}'))),
"vmatrix" => Some((Some('|'), Some('|'))),
"Vmatrix" => Some((Some('‖'), Some('‖'))),
_ => None,
};
let wrap = |inner: MNode, beg: Option<char>, end: Option<char>| MNode::Delim {
beg,
end,
sep: None,
grow: true,
shp_match: false,
elems: vec![vec![inner]],
};
match base {
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
let m = MNode::Matrix { rows, col_jc };
match delims {
Some((b, e)) => vec![wrap(m, b, e)],
None => vec![m],
}
}
"cases" | "dcases" => {
let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() };
vec![wrap(eq, Some('{'), None)]
}
"rcases" => {
let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() };
vec![wrap(eq, None, Some('}'))]
}
_ => vec![MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() }],
}
}
}
/// Cells of an aligned row joined with `&` alignment marks.
fn join_cells(cells: Vec<Arg>) -> Arg {
let mut out = Vec::new();
for (i, c) in cells.into_iter().enumerate() {
if i > 0 {
out.push(run("&"));
}
out.extend(c);
}
merge_runs(out)
}
fn group(a: Arg) -> MNode {
MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![a] }
}
fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
match base {
Some(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup: sp, e }) if e.is_empty() => {
if sup {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide: false, sub, sup: s, e }
} else {
MNode::Nary { chr, lim_loc, grow, sub_hide: false, sup_hide, sub: s, sup: sp, e }
}
}
Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s },
Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp },
b => {
let base = b.map(|b| vec![b]).unwrap_or_default();
if sup {
MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s }
} else {
MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() }
}
}
}
}
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
/// (`\sum\sum\sum…` must not nest once per operator).
fn nary_operands(a: Arg, depth: usize) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
let mut it = a.into_iter().peekable();
while let Some(n) = it.next() {
match n {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
let mut body = Vec::new();
while let Some(next) = it.peek() {
let stop = match next {
MNode::Run(r) => r.text.chars().next().is_some_and(|c| {
matches!(math_class(c), crate::math::MClass::Rel | crate::math::MClass::Bin | crate::math::MClass::Punct) || c == '&'
}),
_ => false,
};
if stop {
break;
}
if let Some(x) = it.next() {
body.push(x);
}
}
let body = merge_runs(nary_operands(body, depth + 1));
if any_too_deep(&body) {
// No room to wrap the operand: it follows the operator.
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
out.extend(body);
} else {
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
}
}
other => out.push(other),
}
}
out
}
/// Set a style on every run inside.
fn restyle(a: Arg, sty: Option<MSty>, scr: MScr, nor: bool) -> Arg {
a.into_iter()
.map(|n| match n {
MNode::Run(mut r) => {
if sty.is_some() {
r.sty = sty;
}
if scr != MScr::Roman {
r.scr = scr;
}
r.nor |= nor;
MNode::Run(r)
}
other => map_args(other, &|a| restyle(a, sty, scr, nor)),
})
.collect()
}
// ---------------------------------------------------------------------------------------------
// Out.
/// The equation as LaTeX.
pub fn to_latex(nodes: &[MNode]) -> String {
let mut s = String::new();
arg(nodes, &mut s, 0);
s.trim().to_string()
}
fn arg(a: &[MNode], s: &mut String, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a {
node(n, s, depth + 1);
}
}
fn braced(a: &[MNode], s: &mut String, depth: usize) {
s.push('{');
arg(a, s, depth);
s.push('}');
}
fn chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' => {
s.push('\\');
s.push(c);
}
'\\' => s.push_str("\\backslash "),
'−' => s.push('-'),
c if c.is_ascii() => s.push(c),
c => match name_of(c) {
Some(n) if !matches!(n, "sqrt" | "matrix" | "of" | "above" | "below" | "box" | "rect") => {
s.push('\\');
s.push_str(n);
s.push(' ');
}
_ => s.push(c),
},
}
}
}
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
fn text_chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
s.push('\\');
s.push(c);
}
// `{}` ends the command name without adding a space (spaces count in text).
'\\' => s.push_str("\\backslash{}"),
c => s.push(c),
}
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
Some('{') => "\\{".into(),
Some('}') => "\\}".into(),
Some('‖') => "\\|".into(),
Some(c) if c.is_ascii() => c.to_string(),
Some(c) => name_of(c).map(|n| format!("\\{n} ")).unwrap_or_else(|| c.to_string()),
}
}
fn group_chr_tex(chr: char, top: bool) -> &'static str {
match chr {
'⏜' => "\\overparen",
'⏝' => "\\underparen",
'⎴' => "\\overbracket",
'⎵' => "\\underbracket",
_ if top => "\\overbrace",
_ => "\\underbrace",
}
}
fn node(node: &MNode, output: &mut String, depth: usize) {
match node {
MNode::Run(run) => run_node(run, output),
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
MNode::Nary { .. } => nary_node(node, output, depth),
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
MNode::Func { name, e } => {
arg(name, output, depth);
braced(e, output, depth);
}
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
MNode::Bar { top, e } => {
output.push_str(if *top { "\\overline" } else { "\\underline" });
braced(e, output, depth);
}
MNode::BorderBox { e, .. } => {
output.push_str("\\boxed");
braced(e, output, depth);
}
MNode::Boxed { e } => braced(e, output, depth),
MNode::GroupChr { chr, top, e } => {
output.push_str(group_chr_tex(*chr, *top));
braced(e, output, depth);
}
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
MNode::Phant { e, .. } => {
output.push_str("\\phantom");
braced(e, output, depth);
}
}
}
fn run_node(run: &MRun, output: &mut String) {
let (open, close) = if run.nor {
("\\text{", "}")
} else {
match (run.sty, run.scr) {
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
(_, MScr::Script) => ("\\mathcal{", "}"),
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
(_, MScr::Monospace) => ("\\mathtt{", "}"),
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
if FUNCTION_NAMES.contains(&run.text.as_str()) {
output.push('\\');
output.push_str(&run.text);
output.push(' ');
return;
}
("\\mathrm{", "}")
}
_ => ("", ""),
}
};
output.push_str(open);
if run.nor {
text_chars(&run.text, output)
} else {
chars(&run.text, output)
}
output.push_str(close);
}
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
match kind {
FracKind::NoBar => {
output.push_str("\\genfrac{}{}{0pt}{}");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
FracKind::Linear | FracKind::Skewed => {
braced(numerator, output, depth);
output.push('/');
braced(denominator, output, depth);
}
FracKind::Bar => {
output.push_str("\\frac");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
}
}
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
if kind == ScriptKind::Pre {
output.push_str("{}_");
braced(sub, output, depth);
output.push('^');
braced(sup, output, depth);
braced(base, output, depth);
return;
}
// An operator name takes its scripts directly (`\sin^{2}`): in braces it would no longer apply
// to what follows it when read back.
match base {
[MNode::Run(r)] if is_function_name(r) => run_node(r, output),
_ => braced(base, output, depth),
}
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
output.push('_');
braced(sub, output, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
output.push('^');
braced(sup, output, depth);
}
}
/// Is `r` an operator name the writer writes as a command (`\sin`, `\log`)?
fn is_function_name(r: &MRun) -> bool {
!r.nor && r.sty == Some(MSty::Plain) && r.scr == MScr::Roman && FUNCTION_NAMES.contains(&r.text.as_str())
}
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
output.push_str("\\sqrt");
if !degree_hidden && !degree.is_empty() {
output.push('[');
arg(degree, output, depth);
output.push(']');
}
braced(expression, output, depth);
}
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
match name_of(*chr) {
Some(name) => {
output.push('\\');
output.push_str(name);
}
None => output.push(*chr),
}
match lim_loc {
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
_ => {}
}
if !sub_hide && !sub.is_empty() {
output.push('_');
braced(sub, output, depth);
}
if !sup_hide && !sup.is_empty() {
output.push('^');
braced(sup, output, depth);
}
output.push(' ');
braced(e, output, depth);
}
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
output.push_str("\\left");
output.push_str(&delim_tex(begin));
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
for (index, element) in elements.iter().enumerate() {
if index > 0 {
output.push_str(&middle);
}
arg(element, output, depth);
}
output.push_str("\\right");
output.push_str(&delim_tex(end));
}
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
if simple && !upper {
arg(expression, output, depth);
output.push('_');
braced(limit, output, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
output.push_str(group_chr_tex(*chr, *top));
braced(inner, output, depth);
output.push(if upper { '^' } else { '_' });
braced(limit, output, depth);
} else {
output.push_str(if upper { "\\overset" } else { "\\underset" });
braced(limit, output, depth);
braced(expression, output, depth);
}
}
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
let command = match character {
'\u{302}' => "hat",
'\u{303}' => "tilde",
'\u{307}' => "dot",
'\u{308}' => "ddot",
'\u{20DB}' => "dddot",
'\u{30C}' => "check",
'\u{306}' => "breve",
'\u{301}' => "acute",
'\u{300}' => "grave",
'\u{305}' | '\u{AF}' => "bar",
'\u{20D6}' => "overleftarrow",
'\u{20E1}' => "overleftrightarrow",
_ => "vec",
};
output.push('\\');
output.push_str(command);
braced(expression, output, depth);
}
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
if rows.len() == 1
&& let Some(row) = rows.first()
&& crate::math::has_number_mark(row)
{
arg(row, output, depth);
return;
}
output.push_str("\\begin{aligned}");
for (index, row) in rows.iter().enumerate() {
if index > 0 {
output.push_str("\\\\");
}
for child_node in row {
match child_node {
MNode::Run(run) if !run.lit && run.text.contains('&') => {
for (index, part) in run.text.split('&').enumerate() {
if index > 0 {
output.push('&');
}
let mut piece = run.clone();
piece.text = part.to_string();
run_node(&piece, output);
}
}
other => node(other, output, depth + 1),
}
}
}
output.push_str("\\end{aligned}");
}
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
output.push_str("\\begin{matrix}");
for (row_index, row) in rows.iter().enumerate() {
if row_index > 0 {
output.push_str("\\\\");
}
for (column, cell) in row.iter().enumerate() {
if column > 0 {
output.push('&');
}
arg(cell, output, depth);
}
}
output.push_str("\\end{matrix}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::{LimLoc, MathJc};
#[test]
fn latex_structures() {
let n = parse_latex(r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}");
assert_eq!(crate::math::to_linear(&n), "x=(-b±√(b^2-4ac))/2a");
let n = parse_latex(r"\sum_{i=1}^{n} i^2 = \frac{n(n+1)(2n+1)}{6}");
let MNode::Nary { chr, sub, sup, e, .. } = &n[0] else { panic!("{n:#?}") };
assert_eq!((*chr, sub.len(), sup.len()), ('∑', 1, 1));
assert!(matches!(e.first(), Some(MNode::Script { .. })), "{e:#?}");
let n = parse_latex(r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}");
assert!(
matches!(&n[0], MNode::Delim { beg: Some('('), elems, .. } if matches!(elems[0][0], MNode::Matrix { ref rows, .. } if rows.len() == 2))
);
let n = parse_latex(r"f(x)=\begin{cases} x & x\ge 0\\ -x & x<0\end{cases}");
assert!(n.iter().any(|m| matches!(m, MNode::Delim { beg: Some('{'), end: None, .. })), "{n:#?}");
let n = parse_latex(r"\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n");
assert!(matches!(&n[0], MNode::Func { .. }), "{n:#?}");
let n = parse_latex(r"\mathbb{R}\hat{x}\text{ if }\overbrace{a+b}^{k}");
assert!(matches!(&n[0], MNode::Run(r) if r.scr == MScr::DoubleStruck));
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
let n = crate::math::parse_linear(lin);
let tex = to_latex(&n);
let back = parse_latex(&tex);
assert_eq!(crate::math::to_linear(&back), crate::math::to_linear(&n), "{lin} → {tex}");
}
}
#[test]
fn group_character_latex_roundtrip() {
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
let n = parse_latex(cmd);
let tex = to_latex(&n);
assert_eq!(tex, cmd);
}
}
#[test]
fn hostile_latex_never_panics() {
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
{
let _ = to_latex(&parse_latex(s));
}
let deep = "{".repeat(5000);
let _ = parse_latex(&deep);
let deep = "\\frac{".repeat(3000);
let _ = parse_latex(&deep);
let deep = "\\sqrt".repeat(5000);
let _ = parse_latex(&deep);
let deep = "\\hat".repeat(5000) + "x";
let _ = parse_latex(&deep);
}
#[test]
fn array_column_specs_and_aligned_optional_args() {
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
assert_eq!(rows.len(), 2);
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
}
#[test]
fn operatorname_starred_and_tag() {
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
let n = parse_latex(r"E=mc^2 \tag{1}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
}
#[test]
fn delim_preserves_custom_separator() {
let delim = MNode::Delim {
beg: Some('{'),
end: Some('}'),
sep: Some('‖'),
grow: true,
shp_match: false,
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
};
let tex = to_latex(&[delim]);
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
}
/// Commands MathLive and typed input use, from equaSHion (storytold/craft-libs#29).
#[test]
fn mathlive_and_other_spellings() {
let lin = |src: &str| crate::math::to_linear(&parse_latex(src));
assert_eq!(lin(r"\abs{x-1}"), lin(r"\left|x-1\right|"));
assert_eq!(lin(r"\lvert x\rvert+\lVert v\rVert"), "|x|+‖v‖");
assert_eq!(lin(r"a\nparallel b \backprime"), "a∦b‵");
assert_eq!(lin(r"\infinity\cross 2"), "∞×2");
assert_eq!(lin(r"\mathnormal{x}"), "x");
let runs = parse_latex(r"\exponentialE^{\imaginaryI\pi}\differentialD x");
assert!(
matches!(&runs[0], MNode::Script { base, .. } if matches!(&base[0], MNode::Run(r) if r.text == "e" && r.sty == Some(MSty::Plain))),
"{runs:?}"
);
for (src, sty, scr) in [
(r"\emph{so it is}", MSty::Italic, MScr::Roman),
(r"\textsf{so it is}", MSty::Plain, MScr::SansSerif),
(r"\hbox{so it is}", MSty::Plain, MScr::Roman),
] {
let a = parse_latex(src);
assert!(matches!(a.as_slice(), [MNode::Run(r)] if r.text == "so it is" && r.nor && r.sty == Some(sty) && r.scr == scr), "{src}: {a:?}");
}
for (src, want) in [(r"\underrightarrow{AB}", '→'), (r"\underleftarrow{AB}", '←'), (r"\underleftrightarrow{AB}", '↔')] {
assert!(matches!(parse_latex(src).as_slice(), [MNode::GroupChr { chr, top: false, .. }] if *chr == want), "{src}");
}
// The writer keeps its own names.
assert_eq!(to_latex(&parse_latex(r"\infinity")), to_latex(&parse_latex(r"\infty")));
}
/// An operator name with a script still applies to its argument when the LaTeX is read back.
#[test]
fn operator_names_take_their_scripts_directly() {
for src in [r"\sin^2\theta+\cos^2\theta=1", r"\log_2 x", r"\ln^{3}(x+1)"] {
let tree = parse_latex(src);
let tex = to_latex(&tree);
assert_eq!(crate::math::to_linear(&parse_latex(&tex)), crate::math::to_linear(&tree), "{src}: {tex}");
assert!(!tex.contains(r"{\sin }") && !tex.contains(r"{\log }"), "{tex}");
}
assert!(to_latex(&parse_latex(r"\sin^2\theta")).starts_with(r"\sin ^{2}"));
// Other bases keep their braces.
assert_eq!(to_latex(&parse_latex("x^2")), "{x}^{2}");
}
}