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}"); } }