mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* 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>
1317 lines
49 KiB
Rust
1317 lines
49 KiB
Rust
//! 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)", "sinx+cosy", "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}");
|
||
}
|
||
}
|