Files
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

76 lines
3.3 KiB
Rust

//! Round trips over equaSHion's corpus (storytold/craft-libs#29): its typed LaTeX equations
//! (`corpus/equations.tsv`), the MathLive-style LaTeX of `corpus/latex.tsv` and its OMML samples
//! from WordCraft and DeckCraft files (`corpus/omml/`).
use craft_math::{Math, math, math_latex, mathml, omml};
const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus");
fn rows(file: &str) -> Vec<Vec<String>> {
let text = std::fs::read_to_string(format!("{DIR}/{file}")).unwrap();
text.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).map(|l| l.split('\t').map(str::to_string).collect()).collect()
}
/// The equations of `equations.tsv`: (id, tree, display).
fn equations() -> Vec<(String, math::Arg, bool)> {
let v: Vec<_> = rows("equations.tsv").into_iter().map(|f| (f[0].clone(), math_latex::parse_latex(&f[1]), f[2].trim() == "1")).collect();
assert!(v.len() >= 40, "{} rows", v.len());
v
}
#[test]
fn every_corpus_equation_reads_without_unknown_commands() {
for (id, tree, _) in equations() {
let lin = math::to_linear(&tree);
assert!(!tree.is_empty() && !lin.contains('\\'), "{id}: {lin}");
}
for f in rows("latex.tsv") {
let lin = math::to_linear(&math_latex::parse_latex(&f[0]));
assert!(!lin.contains('\\'), "{}: {lin}", f[0]);
}
}
#[test]
fn every_corpus_equation_survives_omml() {
for (id, tree, display) in equations() {
let eq = Math { nodes: tree.clone(), ..Default::default() };
let xml = omml::write_str("", display, &eq, None);
let back = omml::read_str(&xml).unwrap_or_else(|| panic!("{id}: {xml}"));
// The writer states matrix column alignment the parser left implicit; the text is the same.
assert_eq!(math::to_linear(&back.nodes), math::to_linear(&tree), "{id}: {xml}");
// And the OMML written from what was read is the same OMML.
let again = omml::write_str("", display, &Math { omml: String::new(), ..back }, None);
assert_eq!(again, xml, "{id}");
}
}
#[test]
fn every_corpus_equation_survives_mathml_and_latex() {
for (id, tree, display) in equations() {
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}");
let tex = math_latex::to_latex(&tree);
assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}");
}
}
#[test]
fn omml_samples_read_and_write_back() {
let mut n = 0;
for e in std::fs::read_dir(format!("{DIR}/omml")).unwrap().flatten() {
let xml = std::fs::read_to_string(e.path()).unwrap();
let name = e.file_name();
let m = omml::read_str(&xml).unwrap_or_else(|| panic!("{name:?}"));
assert!(!m.nodes.is_empty() && m.omml.starts_with("<m:oMath"), "{name:?}");
// Kept: the source goes back as it was read.
let kept = omml::write_str("", false, &m, None);
assert_eq!(kept, m.omml, "{name:?}");
// Rewritten from the tree, it reads back to the same tree.
let fresh = omml::write_str("", false, &Math { omml: String::new(), ..m.clone() }, None);
assert_eq!(omml::read_str(&fresh).map(|b| b.nodes), Some(m.nodes.clone()), "{name:?}: {fresh}");
n += 1;
}
assert_eq!(n, 6);
}