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>
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:36:47 -05:00
committed by GitHub
co-authored by shuff57
parent 24903d320d
commit 22161d2d6a
2 changed files with 27 additions and 9 deletions
+25 -1
View File
@@ -982,7 +982,12 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o
braced(base, output, depth);
return;
}
braced(base, output, depth);
// 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);
@@ -993,6 +998,11 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o
}
}
/// 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() {
@@ -1289,4 +1299,18 @@ mod tests {
// 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}");
}
}
+2 -8
View File
@@ -18,10 +18,6 @@ fn equations() -> Vec<(String, math::Arg, bool)> {
v
}
/// Equations whose LaTeX this crate writes in a form it reads back differently (a function
/// raised to a power: `\sin^2\theta` comes back as `(\sin)^2 θ`). Known; kept visible here.
const LATEX_ROUND_TRIP_GAPS: &[&str] = &["func"];
#[test]
fn every_corpus_equation_reads_without_unknown_commands() {
for (id, tree, _) in equations() {
@@ -54,10 +50,8 @@ fn every_corpus_equation_survives_mathml_and_latex() {
let lin = math::to_linear(&tree);
let ml = mathml::to_mathml(&tree, display, "");
assert_eq!(mathml::parse(&ml).and_then(|m| mathml::read(&m)), Some((lin.clone(), display)), "{id}: {ml}");
if !LATEX_ROUND_TRIP_GAPS.contains(&id.as_str()) {
let tex = math_latex::to_latex(&tree);
assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}");
}
let tex = math_latex::to_latex(&tree);
assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}");
}
}