From 22161d2d6a903d1c474e15939db2fdaba1141039 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:36:47 -0500 Subject: [PATCH] craft-math: operator names take their LaTeX scripts directly (\sin^2\theta round trip) (#1019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- crates/math/src/math_latex.rs | 26 +++++++++++++++++++++++++- crates/math/tests/corpus.rs | 10 ++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/math/src/math_latex.rs b/crates/math/src/math_latex.rs index 5f442f43..24c0d554 100644 --- a/crates/math/src/math_latex.rs +++ b/crates/math/src/math_latex.rs @@ -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}"); + } } diff --git a/crates/math/tests/corpus.rs b/crates/math/tests/corpus.rs index 90a7aaae..383b7778 100644 --- a/crates/math/tests/corpus.rs +++ b/crates/math/tests/corpus.rs @@ -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}"); } }