From c685a8ab48bb23a449a37c98d2abb7bfccfcbbfa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 19:38:06 +0000 Subject: [PATCH] [no-new-version] Expand \DeclareMathOperator, optional arguments with a default and empty macro bodies \DeclareMathOperator{\name}{text} (and \DeclareMathOperator\name, starred or not) defines \name as \operatorname{text} (\operatorname*{text}); a macro of \newcommand{\f}[n][default] takes its first argument optional, [given] or the default, as \newenvironment does; an empty body (\newcommand{\e}{}, \def\e{}) expands to nothing, not to an empty group. The latex2mathml macro cases that were not expanded now are, but for newcommand-optional-default, where latex2mathml takes the group after a macro whose one argument is optional as that argument: LaTeX gives it its default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn --- .../Internal/LaTeX/LaTeXReader.cs | 42 +++++++++++++++++-- .../LaTeX/LaTeXReaderTests.cs | 9 ++++ .../LaTeX/Latex2MathMLCorpusTests.cs | 11 ++--- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 8bfafd2..b8230bf 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -36,8 +36,8 @@ private LaTeXReader(IDictionary macros, bool display) } /// - /// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def - /// or \newcommand, and environments defined with \newenvironment, go to , where later conversions find them. In a display equation the + /// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def, + /// \newcommand or \DeclareMathOperator, and environments defined with \newenvironment, go to , where later conversions find them. In a display equation the /// scripts of a large operator other than an integral (\sum, \prod, \bigcup...) go under and over it, as TeX /// puts them. The \ce and \pu of mhchem are rewritten as plain LaTeX first (). /// @@ -605,6 +605,15 @@ private static string RewriteTeXOnly(string latex) i = end; continue; } + if (cw == "DeclareMathOperator" && Argument(tex, tex[j] == "*" ? j + 1 : j) is var (operatorName, afterOperatorName) && afterOperatorName >= 0 && + BracedArgument(tex, afterOperatorName) is var (operatorBody, afterOperatorBody) && afterOperatorBody >= 0) + { + // \DeclareMathOperator{\name}{text}: \name defined as \operatorname{text}, \operatorname*{text} when starred + result.Append("\\newcommand{").Append(operatorName.Trim()).Append("}{") + .Append(RewriteTeXOnly("\\operatorname" + (tex[j] == "*" ? "*" : "") + "{" + operatorBody + "}")).Append('}'); + i = afterOperatorBody; + continue; + } if (cw == "renewcommand") { result.Append("\\newcommand"); @@ -1394,6 +1403,9 @@ private string Convert(string latex) var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(NewEnvironments(latex))))); var cwPrev = ""; bool needMacroPass = false; + // whether a macro was named and its body is next, and where that body starts: an empty body expands to nothing + bool macroNamed = false; + int macroBody = -1; var uniTeX = new StringBuilder(); for (int i = 0; i < tex.Length;) @@ -1440,6 +1452,7 @@ private string Convert(string latex) i++; needMacroPass = true; macros[cw] = ""; + macroNamed = true; continue; } cwPrev = cw; @@ -1517,10 +1530,16 @@ private string Convert(string latex) i++; continue; } + + if (macroNamed) + { + macroNamed = false; + macroBody = i; + } uniTeX.Append(tex[i++]); break; case "}": - if (i > 0 && tex[i - 1] == "{") + if (i > 0 && tex[i - 1] == "{" && i - 1 != macroBody) uniTeX.Append('⬚'); uniTeX.Append(tex[i++]); break; @@ -1659,12 +1678,20 @@ private static (string, int) ApplyMacro(Text tex, int i, bool accentsAfter) { int k; int cArg = 0; + string optional = null; if (tex[i] == "[") { cArg = Js.CodeAt(tex[i + 1], 0) - '0'; if (tex[i + 2] != "]" || cArg < 0 || cArg > 9) return ("", i); k = i + 3; + // [n][default]: the first argument optional, with that default + if (tex[k] == "[" && cArg > 0) + { + (optional, k) = OptionalArgument(tex, k); + if (optional == null) + return ("", i); + } } else { @@ -1692,6 +1719,13 @@ private static (string, int) ApplyMacro(Text tex, int i, bool accentsAfter) var arg = ""; while (tex[i] == " ") i++; + if (j == 0 && optional != null) + { + var (given, afterGiven) = OptionalArgument(tex, i); + args.Add(given ?? optional); + i = afterGiven; + continue; + } if (tex[i] == "{") { k = FindClosingBrace(tex, i + 1); @@ -1712,7 +1746,7 @@ private static (string, int) ApplyMacro(Text tex, int i, bool accentsAfter) args.Add(arg); } while (args.Count < cArg) - args.Add(""); + args.Add(args.Count == 0 && optional != null ? optional : ""); var val = new StringBuilder(); // an accent passed as an argument (\hat, a combining mark) goes on the character after it, as in LaTeX diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 5482381..4f3b12a 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -365,6 +365,15 @@ public async Task ReadsDelimitersAsDisplay(string latex, bool display) [Arguments(@"\def\R{\mathbb{R}} x\in\R", @"x\in\mathbb{R}")] [Arguments(@"\newcommand{\pd}[2]{\frac{\partial #1}{\partial #2}}\pd{f}{x}", @"\frac{\partial f}{\partial x}")] [Arguments(@"\newcommand{\N}{\mathbb{N}}\N", @"\mathbb{N}")] + [Arguments(@"\newcommand{\f}[2][y]{#1+#2} \f{a}+\f[b]{c}", @"y+a+b+c")] + [Arguments(@"\newcommand{\g}[1][Hello]{#1} \g{world}", @"Helloworld")] + [Arguments(@"\newcommand{\g}[1][a]{#1^2} \g", @"a^2")] + [Arguments(@"\newcommand{\e}{} \e x", @"x")] + [Arguments(@"\def\e{}\e x+\e y", @"x+y")] + [Arguments(@"\newcommand{\h}[2]{} \h{a}{b}x", @"x")] + [Arguments(@"\DeclareMathOperator{\Res}{Res} \Res_{z=0}", @"\operatorname{Res}_{z=0}")] + [Arguments(@"\DeclareMathOperator\Tr{Tr} \Tr A", @"\operatorname{Tr}A")] + [Arguments(@"\DeclareMathOperator*{\argmax}{argmax} \argmax_x f", @"\operatorname{argmax}_xf")] public async Task ExpandsMacros(string latex, string expected) { await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX).ToString(EquationFormat.LaTeX)).IsEqualTo(expected); diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index cee5804..43e1f29 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -220,12 +220,9 @@ private static string Canonical(Equation equation) private static readonly HashSet ReadOtherwise = new HashSet(NotInWordLaTeX.Concat(HeldOtherwise).Concat(NotLaTeX)); - // the macros the reader does not expand as LaTeX does: \DeclareMathOperator, the default of an optional argument, - // and an empty body (which leaves an empty group) - private static readonly HashSet NotExpanded = new HashSet - { - "declare-math-operator", "newcommand-optional-default", "newcommand-empty-body", - }; + // the macros that latex2mathml expands otherwise than LaTeX: \greeting{world}, whose one argument is optional, + // which LaTeX gives its default (Hello), leaving {world} after it, where latex2mathml takes world as the argument + private static readonly HashSet ExpandedOtherwise = new HashSet { "newcommand-optional-default" }; private static string? Check(Func check) { @@ -290,7 +287,7 @@ public async Task LaTeXReadsAsLatex2MathMLReadsIt() [Test] public async Task MacrosExpandToTheirEquivalent() { - var unexpected = Unexpected(Macros(), m => m.id, m => m.latex, NotExpanded, m => + var unexpected = Unexpected(Macros(), m => m.id, m => m.latex, ExpandedOtherwise, m => { var expected = Equation.Parse(m.equivalent, EquationFormat.LaTeX, Options(m.display)); var actual = Equation.Parse(m.latex, EquationFormat.LaTeX, Options(m.display));