From 35f0d1e4f7059135defb59fc05aafde83c83e3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 19:29:41 +0000 Subject: [PATCH 1/2] [no-new-version] Read operator names alone and the amsmath names of two words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \lg is a function name, as for Word; \liminf, \limsup, \injlim, \projlim (and their \var forms) are one name whose words a six-per-em space parts, as MathJax and latex2mathml write them, read through UnicodeMath as a name marked as a function (lim inf⁡) and written back as their commands; a name marked as a function with scripts and nothing after it (argmax⁡_x alone, \operatorname*{argmax}_x) is the name with its scripts, and the UnicodeMath writer marks such a name; \operatorname{lim} is \lim. The latex2mathml cases that were not read yet now are. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn --- .../Internal/LaTeX/LaTeXReader.cs | 17 +++++++++ .../Internal/LaTeX/LaTeXSerializer.cs | 9 ++++- .../Internal/LaTeX/LaTeXSymbols.cs | 23 +++++++++++ src/SharpEquations/Internal/Text/Rune.cs | 2 + .../UnicodeMath/UnicodeMathGrammar.parlot.cs | 32 ++++++++++++---- .../UnicodeMath/UnicodeMathPreprocess.cs | 3 +- .../UnicodeMath/UnicodeMathSerializer.cs | 15 ++++++-- .../Internal/UnicodeMath/UnicodeMathText.cs | 2 +- .../LaTeX/LaTeXReaderTests.cs | 38 +++++++++++++++++-- .../LaTeX/Latex2MathMLCorpusTests.cs | 19 +++------- 10 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index f24899c..8bfafd2 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -628,6 +628,16 @@ private static string RewriteTeXOnly(string latex) i++; continue; } + if (cw is "operatorname" or "operatornamewithlimits" && BracedArgument(tex, cw == "operatorname" && tex[j] == "*" ? j + 1 : j) is var (namedFunction, afterNamedFunction) && + afterNamedFunction >= 0 && IsFunctionName(namedFunction.Trim())) + { + // \operatorname{lim}: the function name, as \lim + result.Append('\\').Append(namedFunction.Trim()); + i = afterNamedFunction; + if (i < tex.Length && IsAsciiAlphabetic(tex[i])) + result.Append(' '); + continue; + } if (Renamed.TryGetValue(cw, out var renamed)) { result.Append(renamed); @@ -768,6 +778,13 @@ private static string RewriteTeXOnly(string latex) i = afterNegated; continue; } + if (LaTeXSymbols.TryGetNameOfTwoWords(cw, out var nameOfTwoWords)) + { + // \liminf: the name of two words, which UnicodeMath reads as one name when it is marked as a function + result.Append("\\operatorname{").Append(nameOfTwoWords).Append('}'); + i = j; + continue; + } if (cw == "operatorname" && tex[j] == "*") { // \operatorname*, a name with limits, read as \operatorname diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index dee8921..ac6564d 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -652,6 +652,11 @@ private string TeX(XElement value, bool noAddParens = false) ret = "\\" + Text(value) + " "; break; } + else if (LaTeXSymbols.TryGetCommandOfTwoWords(Text(value), out var command)) + { + ret = "\\" + command + " "; + break; + } else if (Len(Text(value)) > 1 && SharpEquations.Internal.Text.AsciiPatterns.Letters().IsMatch(Text(value))) { ret = "\\operatorname{" + Text(value) + "}"; @@ -886,7 +891,7 @@ private static bool TakesLimits(XElement element) if (Name(element) == "mo") return IsLargeOperator(element) || IsFunctionName(Text(element)); // a name that is not a function's, \operatorname{argmax}, takes its scripts at its side: \underset puts them under - return Name(element) == "mi" && IsFunctionName(Text(element)); + return Name(element) == "mi" && (IsFunctionName(Text(element)) || LaTeXSymbols.TryGetCommandOfTwoWords(Text(element), out _)); } /// Whether an operator is large (∑, ∫, ⋃…) or a horizontal brace, whose limits go under and over it. @@ -917,6 +922,8 @@ private static string StandardIdentifier(XElement value) return "{}"; if (IsFunctionName(text)) return "\\" + text + " "; + if (LaTeXSymbols.TryGetCommandOfTwoWords(text, out var command)) + return "\\" + command + " "; var plain = string.Concat(Characters(text).Where(c => c.Length == 1 && (c[0] >= 'A' && c[0] <= 'Z' || c[0] >= 'a' && c[0] <= 'z'))); return plain.Length == text.Length ? "\\operatorname{" + text + "}" : LaTeXSymbols.Math(text); } diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index 99f4ecd..41d6814 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -140,6 +140,29 @@ public static bool TryGetPlain(string ch, out string style, out string plain) /// Whether a character is written as a command (α as \alpha, □ as \square). public static bool IsCommand(string ch) => Commands.ContainsKey(ch); + // the operator names of amsmath that are two words, with their words parted by a six-per-em space, as MathML + // writes them (MathJax, latex2mathml) + private static readonly Dictionary NamesOfTwoWords = new Dictionary + { + ["liminf"] = "lim\u2006inf", ["limsup"] = "lim\u2006sup", ["injlim"] = "inj\u2006lim", ["projlim"] = "proj\u2006lim", + }; + + // the forms of amsmath that write them as lim with a bar or an arrow under or over it, read as the names, as + // latex2mathml reads them + private static readonly Dictionary VariantsOfTwoWords = new Dictionary + { + ["varliminf"] = "liminf", ["varlimsup"] = "limsup", ["varinjlim"] = "injlim", ["varprojlim"] = "projlim", + }; + + private static readonly Dictionary CommandsOfTwoWords = NamesOfTwoWords.ToDictionary(entry => entry.Value, entry => entry.Key); + + /// The operator name of two words of a command (liminf and varliminf: lim inf). + public static bool TryGetNameOfTwoWords(string command, out string name) => + NamesOfTwoWords.TryGetValue(VariantsOfTwoWords.TryGetValue(command, out var plain) ? plain : command, out name); + + /// The command of an operator name of two words (lim inf: liminf). + public static bool TryGetCommandOfTwoWords(string name, out string command) => CommandsOfTwoWords.TryGetValue(name, out command); + /// The LaTeX of a run of text in math: each character as a letter, a command or itself. public static string Math(string text) { diff --git a/src/SharpEquations/Internal/Text/Rune.cs b/src/SharpEquations/Internal/Text/Rune.cs index 1cf2a1e..1eb2d84 100644 --- a/src/SharpEquations/Internal/Text/Rune.cs +++ b/src/SharpEquations/Internal/Text/Rune.cs @@ -26,6 +26,8 @@ public Rune(int value) public bool IsBmp => value <= 0xFFFF; + public static bool IsLetter(Rune value) => char.IsLetter(value.ToString(), 0); + public override string ToString() => char.ConvertFromUtf32(value); public bool Equals(Rune other) => value == other.value; diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs index 01eecd0..2698099 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs @@ -413,15 +413,21 @@ private static Parser Build() L("∜").SkipAnd(operand).Then(o => (object)O("root", O(("degree", O("number", "4")), ("of", o)))), opRoot.Then(o => (object)ColoredOperator(o))); + // the letters of a name marked as a function, its words parted by a six-per-em space (lim inf⁡, as MathML + // writes \liminf) + var markedName = OneOrMany(alphaN).And(ZeroOrMany(L("\u2006").And(OneOrMany(alphaN)))) + .Then(t => (object)t.Item1.Concat(t.Item2.SelectMany(w => new[] { w.Item1 }.Concat(w.Item2))).ToList()); var arctrigName = OneOf(L("arcsin"), L("arcsec"), L("arctan"), L("arccot"), L("arccos"), L("arccsc")).And(Opt(L("h"))) .Then(t => (object)A(t.Item1, t.Item2)); + // a function name that UnicodeMath knows, not the first word of a name marked as a function (lim inf⁡) + var knownFunctionName = OneOf(trigName, arctrigName, limName, + L("arg"), L("def"), L("deg"), L("det"), L("dim"), L("erf"), L("exp"), L("gcd"), L("ker"), L("hom"), + L("Im"), L("inf"), L("ln"), L("max"), L("min"), L("mod"), L("Pr"), L("Re"), L("sup"), L("tg"), + L("lcm"), L("lub"), L("glb"), L("lim\u00A0inf"), L("lim\u00A0sup")) + .AndSkip(Not(alphaAscii)).AndSkip(Not(L("\u2006").And(alphaN))); functionName.Parser = OneOf( - OneOf(trigName, arctrigName, limName, - L("arg"), L("def"), L("deg"), L("det"), L("dim"), L("erf"), L("exp"), L("gcd"), L("ker"), L("hom"), - L("Im"), L("inf"), L("ln"), L("max"), L("min"), L("mod"), L("Pr"), L("Re"), L("sup"), L("tg"), - L("lcm"), L("lub"), L("glb"), L("lim\u00A0inf"), L("lim\u00A0sup")) - .AndSkip(Not(alphaAscii)), - OneOrMany(alphaN).AndSkip(L("\u2061")).Then(t => (object)t.ToList())); + knownFunctionName, + markedName.AndSkip(L("\u2061"))); var function = OneOf( functionName.AndSkip(Opt(__)).AndSkip(Opt(OneOf(L("\u2061"), opNaryand))).And(Opt(script)).AndSkip(Opt(__)).And(operand).AndSkip(Opt(_)) .Then(t => @@ -435,12 +441,22 @@ private static Parser Build() } return (object)O("function", O(("f", O("atoms", c)), ("of", t.Item3))); }), + // a name marked as a function with scripts but without an operand (argmax⁡_𝑥 alone) is the name with its + // scripts, and not its letters before a script of the function apply sign (but not a function name that + // UnicodeMath knows, lim⁡_(𝑎→∞), read as UnicodeMathML reads it) + Not(knownFunctionName).SkipAnd(markedName).AndSkip(L("\u2061")).And(script).AndSkip(Opt(_)) + .Then(t => + { + var s = (JsObject)t.Item2; + s["base"] = O("atoms", O(("chars", Join((List)t.Item1)), ("funct", true))); + return (object)O("script", s); + }), functionName.AndSkip(Opt(__)).And(operand).AndSkip(Opt(_)) .Then(t => (object)O("function", O(("f", O("atoms", O("chars", FunctionName(t.Item1, false)))), ("of", t.Item2)))), // a name marked as a function without an operand (NaN⁡,) is one name, as a function name is (sin⁡,), and not // its letters; the function apply sign is read after it (but not one with scripts after the sign, argmax⁡┬(𝑥∈𝑆)) - OneOrMany(alphaN).AndSkip(Not(Not(L("⁡").And(Not(opScript))))) - .Then(t => (object)O("atoms", O(("chars", Join(t.ToList())), ("funct", true))))); + markedName.AndSkip(Not(Not(L("⁡").And(Not(opScript))))) + .Then(t => (object)O("atoms", O(("chars", Join((List)t)), ("funct", true))))); // text, overrides, colors, comments, typewriter text.Parser = L("\"").SkipAnd(OneOrMany(OneOf(L("\\\"").Then(x => (object)"\""), Not(L("\"")).SkipAnd(anyChar)))).AndSkip(L("\"")) diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs index 91fd200..40ac390 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs @@ -35,7 +35,8 @@ private sealed class PreprocessStyle ["⒨"] = "()", ["⒩"] = "‖‖", ["ⓢ"] = "[]", ["Ⓢ"] = "{}", ["⒱"] = "||", }; - private static readonly string[] EulerFunctions = { "det", "gcd", "inf", "lim", "lim inf", "lim sup", "max", "min", "Pr", "sup" }; + // the functions whose subscript goes under them in a display equation + internal static readonly string[] EulerFunctions = { "det", "gcd", "inf", "lim", "lim\u2006inf", "lim\u2006sup", "inj\u2006lim", "proj\u2006lim", "max", "min", "Pr", "sup" }; private object Preprocess(PreprocessStyle dsty, object uast, int? index = null, List arr = null) { diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index 4f1dfe7..9788469 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -692,6 +692,12 @@ private string Grouped(XElement node) return "(" + text + ")"; } + /// A name of several letters, not a function's, as the base of scripts: argmax in argmax_𝑥. + private static bool IsScriptedName(XElement mi) => + !mi.HasElements && mi.Parent != null && Name(mi.Parent) is "msub" or "msup" or "msubsup" or "munder" or "mover" or "munderover" && + First(mi.Parent) == mi && Len(Text(mi)) > 1 && !IsFunctionName(Text(mi)) && !HasAttr(mi, "mathvariant") && + CodePoints.Of(Text(mi)).All(c => c.Value == 0x2006 || System.Text.Rune.IsLetter(c)); + /// The name of a function with scripts that is written with the function apply sign after it, or null. private static XElement FunctionName(XElement head) { @@ -1030,8 +1036,9 @@ private string DumpCore(XElement value, bool noAddParens) break; } op = HasAttr(value, "accentunder") || Attr(value, "accent") == "true" || IsCombining(Text(Last(value))) ? "" : "┬"; - // lim_ is under lim in a function of a display equation only - if ((Text(First(value)) == "lim" && !First(value).HasElements || functionName != null && IsFunctionName(Text(functionName))) && display && + // lim_ is under lim (and lim inf, lim sup) in a function of a display equation only + if ((Text(First(value)) == "lim" && !First(value).HasElements || + functionName != null && (IsFunctionName(Text(functionName)) || UnicodeMathTranslator.EulerFunctions.Contains(Text(functionName)))) && display && Role(parent) == ":function") op = "_"; ret = Binary(value, op); @@ -1196,7 +1203,9 @@ private string DumpCore(XElement value, bool noAddParens) break; case "mi": - if (value == functionName) + // a name as the base of its scripts is marked as a function, without which it would be read as its + // letters (argmax⁡_𝑥) + if (value == functionName || IsScriptedName(value)) { ret = Text(value) + "\u2061"; break; diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathText.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathText.cs index 5134e13..bb66301 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathText.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathText.cs @@ -33,7 +33,7 @@ public static readonly (string Fraction, int Numerator, int Denominator)[] Unico private static readonly string[] FunctionNames = { "Im", "Pr", "Re", "arg", "cos", "cot", "csc", "ctg", "deg", "det", "dim", "erf", "exp", "gcd", "hom", - "inf", "ker", "lim", "log", "ln", "max", "min", "mod", "sec", "sin", "sup", "tan", "tg", + "inf", "ker", "lg", "lim", "log", "ln", "max", "min", "mod", "sec", "sin", "sup", "tan", "tg", }; /// inRange(ch0, ch, ch1): ch a single code point between those of ch0 and ch1. diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 83f19c0..b68350f 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -192,6 +192,36 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, await Assert.That(Equation.Parse(expectedLaTeX, EquationFormat.LaTeX)).IsEqualTo(Equation.Parse(latex, EquationFormat.LaTeX)).Because("the LaTeX written reads back"); } + // operator names with nothing after them, and the names of amsmath that are two words, one name whose words a + // six-per-em space parts (as MathML writes them); the forms with a bar or an arrow (\varinjlim) are read as the names + [Test] + [Arguments(@"\lg x", false, @"\lg x", "lg⁡𝑥")] + [Arguments(@"\lg", false, @"\lg", "lg")] + [Arguments(@"\liminf_{n\to\infty} a_n", false, @"\liminf_{n\to\infty}a_n", "lim\u2006inf⁡_(𝑛→∞) 𝑎_𝑛")] + [Arguments(@"\liminf_{n\to\infty} a_n", true, @"\liminf_{n\to\infty}a_n", "lim\u2006inf⁡_(𝑛→∞) 𝑎_𝑛")] + [Arguments(@"\limsup x_n", false, @"\limsup x_n", "lim\u2006sup⁡𝑥_𝑛")] + [Arguments(@"\injlim A_i", false, @"\injlim A_i", "inj\u2006lim⁡𝐴_𝑖")] + [Arguments(@"\projlim_i A_i", true, @"\projlim_iA_i", "proj\u2006lim⁡_𝑖 𝐴_𝑖")] + [Arguments(@"\liminf", false, @"\liminf", "lim\u2006inf⁡")] + [Arguments(@"\varinjlim", false, @"\injlim", "inj\u2006lim⁡")] + [Arguments(@"\varprojlim A_i", false, @"\projlim A_i", "proj\u2006lim⁡𝐴_𝑖")] + [Arguments(@"\varliminf + \varlimsup", false, @"\liminf+\limsup", "lim\u2006inf⁡+lim\u2006sup⁡")] + [Arguments(@"\operatorname*{argmax}_{x}", true, @"\operatorname{argmax}_x", "argmax⁡_𝑥")] + [Arguments(@"\operatorname{argmax}_x + 1", false, @"\operatorname{argmax}_x+1", "argmax⁡_𝑥+1")] + [Arguments(@"\operatornamewithlimits{lim}_{x}", true, @"\lim_x", "lim_𝑥")] + [Arguments(@"\operatorname{lim}_{x} f + \operatorname*{max}x", true, @"\lim_xf+\max x", "lim_𝑥 ⁡𝑓+max⁡𝑥")] + public async Task ReadsOperatorNames(string latex, bool display, string expectedLaTeX, string expectedUnicodeMath) + { + var options = new EquationOptions { Display = display }; + var equation = Equation.Parse(latex, EquationFormat.LaTeX, options); + await Assert.That(equation.ToString(EquationFormat.LaTeX)).IsEqualTo(expectedLaTeX); + await Assert.That(equation.ToString(EquationFormat.UnicodeMath)).IsEqualTo(expectedUnicodeMath); + var word = new EquationOptions { Display = display, LaTeXFlavor = LaTeXFlavor.Word }; + await Assert.That(Equation.Parse(expectedLaTeX, EquationFormat.LaTeX, options)).IsEqualTo(equation).Because("the standard LaTeX reads back"); + await Assert.That(Equation.Parse(equation.ToString(EquationFormat.LaTeX, word), EquationFormat.LaTeX, word)).IsEqualTo(equation).Because("Word's LaTeX reads back"); + await Assert.That(Equation.Parse(expectedUnicodeMath, EquationFormat.UnicodeMath, options)).IsEqualTo(equation).Because("the UnicodeMath reads back"); + } + // the demos of math.webc.site, whose LaTeX and UnicodeMath read back to the same equation [Test] [Arguments(@"\left. \frac{df}{dx} \right| _0", @"{\left.\frac{df}{dx}\right|}_0", @"├𝑑𝑓/𝑑𝑥 ┤|₀")] @@ -372,8 +402,8 @@ public async Task RejectsEnvironmentsThatExpandForever() [Arguments(@"\foo x", @"Unknown control word \foo in the LaTeX.")] [Arguments(@"a+\undefinedcommand", @"Unknown control word \undefinedcommand in the LaTeX.")] [Arguments(@"\sgn r", @"Unknown control word \sgn in the LaTeX.")] - [Arguments(@"\lg d", @"Unknown control word \lg in the LaTeX.")] - [Arguments(@"\liminf v", @"Unknown control word \liminf in the LaTeX.")] + [Arguments(@"\erfc d", @"Unknown control word \erfc in the LaTeX.")] + [Arguments(@"\cosec v", @"Unknown control word \cosec in the LaTeX.")] [Arguments(@"a \backslash b", @"Unknown control word \backslash in the LaTeX.")] [Arguments(@"\sgn2", @"Unknown control word \sgn in the LaTeX.")] public async Task RejectsUnknownControlWordsWhenStrict(string latex, string message) @@ -384,8 +414,8 @@ public async Task RejectsUnknownControlWordsWhenStrict(string latex, string mess [Test] [Arguments(@"\sgn r", @"\sgn", "r")] - [Arguments(@"\lg d", @"\lg", "d")] - [Arguments(@"\liminf v", @"\liminf", "v")] + [Arguments(@"\erfc d", @"\erfc", "d")] + [Arguments(@"\cosec v", @"\cosec", "v")] [Arguments(@"a \backslash b", @"\backslash", "b")] [Arguments(@"\sgn r", @"\sgn", "r")] [Arguments(@"\sgn{r}", @"\sgn", "r")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 820ad3e..cee5804 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -122,9 +122,10 @@ private static string Canonical(Equation equation) case "mo" when text != null && Accents.TryGetValue(text, out var mark) && e.Parent!.Name.LocalName is "mover" && e.ElementsBeforeSelf().Any(): e.Value = mark; break; - case "mi" or "mo" when text != null && text.Length > 1 && text.All(char.IsLetter): + // a name of two words (lim inf) has its words parted by a six-per-em space + case "mi" or "mo" when text != null && text.Length > 1 && text.All(c => char.IsLetter(c) || c == '\u2006'): case "mi" when text != null && text.Length > 0 && text.All(c => c is '′' or '″' or '‴' or '⁗'): - e.Name = M + (text.All(char.IsLetter) ? "mi" : "mo"); + e.Name = M + (text.All(c => char.IsLetter(c) || c == '\u2006') ? "mi" : "mo"); break; case "mi" when text is { Length: 1 } && text[0] is >= 'Α' and <= 'Ω' && e.Attribute("mathvariant") == null: e.SetAttributeValue("mathvariant", "normal"); @@ -179,7 +180,7 @@ private static string Canonical(Equation equation) "spaces", "middle", "mathstrut", }; - // what the reader does not read as latex2mathml does, in four groups; a case that starts to pass must leave its + // what the reader does not read as latex2mathml does, in three groups; a case that starts to pass must leave its // group, and the union of the groups is what LaTeXReadsAsLatex2MathMLReadsIt allows // commands of MathJax, KaTeX, plain TeX or packages that Word's LaTeX, which the reader follows, does not have: @@ -200,14 +201,6 @@ private static string Canonical(Equation equation) "argmax", "widecheck", }; - // LaTeX and amsmath that the reader does not read yet: an operator name with nothing after it (\lg, \liminf, - // \injlim, \operatorname*{argmax}_x), which UnicodeMath reads as letters, and the names of amsmath that are two - // words (lim inf) - private static readonly HashSet NotReadYet = new HashSet - { - "lg", "liminf", "limsup", "injlim", "projlim", "varinjlim", "operatornamewithlimits", "operatorname-star", - }; - // the same equation, which SharpEquations holds otherwise: an equation number as the label of its row (\tag), // and none for aligned equations, whose numbers latex2mathml writes as cells; the alignment of align, split and // cases as marks in one cell; ... as an ellipsis and * as the operator ∗, as Word reads them; a letter of \text @@ -225,7 +218,7 @@ private static string Canonical(Equation equation) // a macro is # in its expansion, which TeX rejects there) private static readonly HashSet NotLaTeX = new HashSet { "backslash-e", "issue-125-1-overset", "newcommand-escaped-hash" }; - private static readonly HashSet ReadOtherwise = new HashSet(NotInWordLaTeX.Concat(NotReadYet).Concat(HeldOtherwise).Concat(NotLaTeX)); + 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) @@ -264,7 +257,7 @@ public async Task CorpusIsComplete() await Assert.That(Conversions()).Count().IsEqualTo(327); await Assert.That(Macros()).Count().IsEqualTo(10); // a case is in one group of what the reader reads otherwise - await Assert.That(ReadOtherwise.Count).IsEqualTo(NotInWordLaTeX.Count + NotReadYet.Count + HeldOtherwise.Count + NotLaTeX.Count); + await Assert.That(ReadOtherwise.Count).IsEqualTo(NotInWordLaTeX.Count + HeldOtherwise.Count + NotLaTeX.Count); } // the MathML of latex2mathml writes LaTeX that, once read back, reads back to itself From 081e4ed1deb46eed6124bb18b2614025d780c5b1 Mon Sep 17 00:00:00 2001 From: Simone <26844016+simonebortolin@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:45:45 +0100 Subject: [PATCH 2/2] Remove tests for specific unknown control words Removed tests for unknown control words '\erfc' and '\cosec' from the LaTeXReaderTests. --- tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index b68350f..5482381 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -402,8 +402,6 @@ public async Task RejectsEnvironmentsThatExpandForever() [Arguments(@"\foo x", @"Unknown control word \foo in the LaTeX.")] [Arguments(@"a+\undefinedcommand", @"Unknown control word \undefinedcommand in the LaTeX.")] [Arguments(@"\sgn r", @"Unknown control word \sgn in the LaTeX.")] - [Arguments(@"\erfc d", @"Unknown control word \erfc in the LaTeX.")] - [Arguments(@"\cosec v", @"Unknown control word \cosec in the LaTeX.")] [Arguments(@"a \backslash b", @"Unknown control word \backslash in the LaTeX.")] [Arguments(@"\sgn2", @"Unknown control word \sgn in the LaTeX.")] public async Task RejectsUnknownControlWordsWhenStrict(string latex, string message) @@ -414,8 +412,6 @@ public async Task RejectsUnknownControlWordsWhenStrict(string latex, string mess [Test] [Arguments(@"\sgn r", @"\sgn", "r")] - [Arguments(@"\erfc d", @"\erfc", "d")] - [Arguments(@"\cosec v", @"\cosec", "v")] [Arguments(@"a \backslash b", @"\backslash", "b")] [Arguments(@"\sgn r", @"\sgn", "r")] [Arguments(@"\sgn{r}", @"\sgn", "r")]