From c685a8ab48bb23a449a37c98d2abb7bfccfcbbfa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 19:38:06 +0000 Subject: [PATCH 1/4] [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)); From 7bacfc9af6fd0dd9aa01ad8387d01419cf35afa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:44:41 +0000 Subject: [PATCH 2/4] Merge branch 'claude/latex-operator-names' into claude/latex-macro-expansion From 5ae97d28c271dcf7df167732733dbeb6c9a29e38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:33:14 +0000 Subject: [PATCH 3/4] =?UTF-8?q?[no-new-version]=20Write=20back=20bars=20wi?= =?UTF-8?q?th=20nothing=20between=20them=20and=20brackets=20alone=20in=20a?= =?UTF-8?q?=20group=20Two=20bars=20with=20nothing=20between=20them=20(||,?= =?UTF-8?q?=20|=20|)=20are=20bars=20alone,=20but=20for=20two=20such=20pair?= =?UTF-8?q?s=20around=20something,=20a=20norm=20(||x||)=20as=20UnicodeMath?= =?UTF-8?q?=20reads=20it;=20one=20bar=20alone=20between=20brackets=20with?= =?UTF-8?q?=20an=20empty=20side=20holds=20the=20placeholder=20there=20({|}?= =?UTF-8?q?=20is=20{=E2=AC=9A=E2=94=82=E2=AC=9A},=20as=20\middle|=20is);?= =?UTF-8?q?=20the=20pairs=20of=20bars=20between=20\left=20and=20\right=20a?= =?UTF-8?q?re=20grouped,=20which=20UnicodeMath=20would=20otherwise=20close?= =?UTF-8?q?=20at=20\right=20(=E2=94=9C(x|a|b=E2=94=A4));=20{|},=20the=20or?= =?UTF-8?q?dinary=20bar=20of=20TeX,=20is=20a=20bar=20alone,=20and=20the=20?= =?UTF-8?q?writer=20writes=20so=20a=20bar=20alone=20before=20other=20bars?= =?UTF-8?q?=20that=20the=20reader=20would=20pair=20it=20with.=20A=20bracke?= =?UTF-8?q?t=20that=20a=20group=20opens=20and=20does=20not=20close=20(\pha?= =?UTF-8?q?ntom{(},=20\mathstrut)=20is=20escaped,=20where=20UnicodeMath=20?= =?UTF-8?q?closed=20it=20with=20an=20empty=20fence.=20An=20mrow=20between?= =?UTF-8?q?=20bars=20or=20brackets=20that=20are=20text=20has=20no=20defaul?= =?UTF-8?q?t=20intent=20(no=20absolute=20value=20of=20bars=20alone).=20The?= =?UTF-8?q?=20latex2mathml=20cases=20spaces,=20middle=20and=20mathstrut=20?= =?UTF-8?q?now=20write=20LaTeX=20that=20reads=20back.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn --- .../Internal/LaTeX/LaTeXReader.cs | 159 +++++++++++++++--- .../Internal/LaTeX/LaTeXSerializer.cs | 11 +- .../Internal/MathML/DefaultIntents.cs | 4 +- .../LaTeX/LaTeXReaderTests.cs | 15 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 9 +- 5 files changed, 166 insertions(+), 32 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index c8b6770..a84437d 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -1037,22 +1037,39 @@ private static (string Command, int Next) ScriptCommand(Text tex, int i) /// /// A bar (| or โ€–) left without its pair, as in ๐‘Ž|๐‘, escaped: in UnicodeMath a bar opens an absolute value or a /// norm that a second one closes. Bars are paired within their brackets; one bar alone between brackets (๐‘ƒ(๐‘|๐‘‘), - /// {๐‘ฅ|๐‘ฅ>0}) is the separator UnicodeMath makes of it, a bar before a closing angle with no opening one is a ket - /// (|๐‘ŸโŸฉ), and the bars of \left, \right and the like are delimiters. + /// {๐‘ฅ|๐‘ฅ>0}) is the separator UnicodeMath makes of it, with an empty side holding the placeholder ({โฌšโ”‚โฌš}), a bar + /// before a closing angle with no opening one is a ket (|๐‘ŸโŸฉ), and the bars of \left, \right and the like are + /// delimiters. Two bars with nothing between them are bars alone, but for two such pairs around something, which + /// UnicodeMath reads as a norm (||๐‘ฅ||). /// private static string EscapeOddBars(string latex) { var tex = new Text(latex); - var frames = new Stack>(); - frames.Push(new List()); + var frames = new Stack<(int Open, List Bars)>(); + frames.Push((-1, new List())); var escaped = new List(); var kets = new HashSet(); - bool ZeroSpacesBetween(int first, int second) + var placeholders = new HashSet(); + // the pairs of bars between \left and \right, grouped: UnicodeMath would close the bracket of the second bar + // at \right (โ”œ(๐‘ฅ|๐‘Ž|๐‘โ”ค)) + int lefts = 0; + var depths = new Dictionary(); + var groupStarts = new HashSet(); + var groupEnds = new HashSet(); + void Pair(int first, int last) + { + if (tex[first] != "โˆฃ" && depths[first] > 0 && depths[last] == depths[first]) + { + groupStarts.Add(first); + groupEnds.Add(last); + } + } + bool SpacesBetween(int first, int second, bool zeroSpaces) { bool zero = false; for (int k = first + 1; k < second; k++) { - if (tex[k] == "\\" && tex[k + 1] == "!") + if (zeroSpaces && tex[k] == "\\" && tex[k + 1] == "!") { zero = true; k++; @@ -1060,28 +1077,57 @@ bool ZeroSpacesBetween(int first, int second) else if (tex[k] != " ") return false; } - return zero; + return zero || !zeroSpaces; } - void Close(List bars, bool inBrackets) + void Close((int Open, List Bars) frame, bool inBrackets, int close) { foreach (var bar in new[] { "|", "โ€–", "โˆฃ" }) { - var some = bars.Where(b => tex[b] == bar).ToList(); + var some = frame.Bars.Where(b => tex[b] == bar).ToList(); if (inBrackets && some.Count == 1 && bar != "โ€–") + { + if (close >= 0 && SpacesBetween(frame.Open, some[0], false)) + placeholders.Add(some[0]); + if (close >= 0 && SpacesBetween(some[0], close, false)) + placeholders.Add(some[0] + 1); continue; + } // bars in pairs, but two with only zero spaces between them (|\!|, which UnicodeMath would read as // ||, one double bar): the first is then a bar alone + var doubles = new List(); for (int k = 0; k < some.Count; k++) { - if (k + 1 < some.Count && !ZeroSpacesBetween(some[k], some[k + 1])) + if (k + 1 < some.Count && !SpacesBetween(some[k], some[k + 1], true)) + { + if (SpacesBetween(some[k], some[k + 1], false)) + { + // two bars with nothing between them, || when they touch, which no other bar touches, + // the double bar of UnicodeMath; bars alone otherwise + if (some[k + 1] == some[k] + 1 && tex[some[k] - 1] != bar && tex[some[k + 1] + 1] != bar) + doubles.Add(some[k]); + else + escaped.AddRange(new[] { some[k], some[k + 1] }); + } + else + Pair(some[k], some[k + 1]); k++; + } else escaped.Add(some[k]); } + // double bars in pairs around something, a norm (||๐‘ฅ||); the others bars alone + for (int k = 0; k < doubles.Count; k++) + { + if (k + 1 < doubles.Count && !SpacesBetween(doubles[k] + 1, doubles[k + 1], false)) + Pair(doubles[k], doubles[++k] + 1); + else + escaped.AddRange(new[] { doubles[k], doubles[k] + 1 }); + } } } for (int i = 0; i < tex.Length; i++) { + int start = i; var ch = tex[i]; if (ch == "\\") { @@ -1100,6 +1146,12 @@ void Close(List bars, bool inBrackets) { while (tex[j] == " ") j++; + lefts += cw switch + { + "left" => 1, + "right" => -1, + _ => 0, + }; // the bar after \left, \right... is skipped with the command i = DelimiterCommands.Contains(cw) && (tex[j] == "|" || tex[j] == "โ€–") ? j : j - 1; continue; @@ -1109,33 +1161,98 @@ void Close(List bars, bool inBrackets) switch (ch) { case "(" or "[" or "โŸจ": - frames.Push(new List()); + frames.Push((i, new List())); break; case ")" or "]" or "โŸฉ" when frames.Count > 1: - Close(frames.Pop(), true); + Close(frames.Pop(), true, ch == "โŸฉ" ? -1 : start); break; - case "โŸฉ" when frames.Peek().Count > 0: + case "โŸฉ" when frames.Peek().Bars.Count > 0: // a ket (|๐‘ŸโŸฉ): its bar has no pair, and โˆฃ (\mid) there is the bar of the ket - var ket = frames.Peek()[frames.Peek().Count - 1]; - if (tex[ket] == "โˆฃ") - kets.Add(ket); - frames.Peek().RemoveAt(frames.Peek().Count - 1); + var bars = frames.Peek().Bars; + if (tex[bars[bars.Count - 1]] == "โˆฃ") + kets.Add(bars[bars.Count - 1]); + bars.RemoveAt(bars.Count - 1); + break; + case "|" when tex[i - 1] == "{" && tex[i - 2] != "\\" && tex[i + 1] == "}": + // {|}, the ordinary bar of TeX, a bar alone + escaped.Add(i); break; case "|" or "โ€–" or "โˆฃ": - frames.Peek().Add(i); + frames.Peek().Bars.Add(i); + depths[i] = lefts; break; } } while (frames.Count > 0) - Close(frames.Pop(), false); - if (escaped.Count == 0 && kets.Count == 0) + Close(frames.Pop(), false, -1); + if (escaped.Count == 0 && kets.Count == 0 && placeholders.Count == 0 && groupStarts.Count == 0) return latex; var result = new StringBuilder(); for (int i = 0; i < tex.Length; i++) { + if (placeholders.Contains(i)) + result.Append("{}"); + if (groupStarts.Contains(i)) + result.Append('{'); if (escaped.Contains(i)) result.Append('\\'); result.Append(kets.Contains(i) ? "|" : tex[i]); + if (groupEnds.Contains(i)) + result.Append('}'); + } + return result.ToString(); + } + + /// + /// A bracket that a group opens and does not close, as in \phantom{(}, escaped: UnicodeMath would close it with an + /// empty fence at the end of the group. The text of \text and the like is left as it is. + /// + private static string EscapeOpenBrackets(string latex) + { + var tex = new Text(latex); + var groups = new Stack>(); + var escaped = new List(); + for (int i = 0; i < tex.Length; i++) + { + switch (tex[i]) + { + case "\\": + { + var cw = ControlWord(tex, i + 1); + int j = i + 1 + Math.Max(1, cw.Length); + if (TextCommands.Contains(cw) && BracedArgument(tex, j) is var (_, afterText) && afterText >= 0) + { + i = afterText - 1; + break; + } + while (tex[j] == " ") + j++; + // the bracket after \left, \right... is skipped with the command + i = DelimiterCommands.Contains(cw) && tex[j] is "(" or "[" or ")" or "]" ? j : j - 1; + break; + } + case "{": + groups.Push(new List()); + break; + case "}" when groups.Count > 0: + escaped.AddRange(groups.Pop()); + break; + case "(" or "[" when groups.Count > 0: + groups.Peek().Add(i); + break; + case ")" or "]" when groups.Count > 0 && groups.Peek().Count > 0: + groups.Peek().RemoveAt(groups.Peek().Count - 1); + break; + } + } + if (escaped.Count == 0) + return latex; + var result = new StringBuilder(); + for (int i = 0; i < tex.Length; i++) + { + if (escaped.Contains(i)) + result.Append('\\'); + result.Append(tex[i]); } return result.ToString(); } @@ -1436,7 +1553,7 @@ private static bool ContinuesControlWord(StringBuilder before, string after) private string Convert(string latex) { - var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(NewEnvironments(latex))))); + var tex = new Text(EscapeOddBars(EscapeOpenBrackets(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 diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 220041b..2d2c11a 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -574,11 +574,16 @@ private string TeX(XElement value, bool noAddParens = false) ret = "\\$"; break; } - // a bar alone before another bar parted from it by \!, a zero space: the reader pairs bars with - // nothing between them, which UnicodeMath reads as one double bar + // a bar alone before the bar of an absolute value parted from it by \!, a zero space: the reader + // pairs bars with nothing between them, which UnicodeMath reads as one double bar if (val == "|" && Attr(value, "intent") == ":text" && - value.ElementsAfterSelf().FirstOrDefault()?.DescendantsAndSelf().FirstOrDefault(d => !d.HasElements) is { } nextLeaf && IsBar(nextLeaf) && Text(nextLeaf) == "|") + value.ElementsAfterSelf().FirstOrDefault()?.DescendantsAndSelf().FirstOrDefault(d => !d.HasElements) is { } nextLeaf && IsBar(nextLeaf) && + Text(nextLeaf) == "|" && Attr(nextLeaf, "intent") != ":text") return "|\\!"; + // a bar alone before other bars, which the reader would pair it with: {|}, the ordinary bar of TeX + if (val == "|" && Attr(value, "intent") == ":text" && value.ElementsAfterSelf().Concat(value.Ancestors().SelectMany(a => a.ElementsAfterSelf())) + .SelectMany(e => e.DescendantsAndSelf()).Any(d => IsBar(d) && Text(d) == "|")) + return "{|}"; if (Attr(value, "intent") == ":text" && !standard) { // a bracket or a bar that pairs with nothing: the reader escapes the brackets that close nothing and diff --git a/src/SharpEquations/Internal/MathML/DefaultIntents.cs b/src/SharpEquations/Internal/MathML/DefaultIntents.cs index b2f79bd..edd1757 100644 --- a/src/SharpEquations/Internal/MathML/DefaultIntents.cs +++ b/src/SharpEquations/Internal/MathML/DefaultIntents.cs @@ -79,8 +79,10 @@ private static bool IsNaryHead(XElement head) return ":function"; if (children.Count == 2 && IsNaryHead(children[0])) return ":largeop"; + // a bracket or a bar that is text pairs with nothing if (children[0].Name != Mml + "mo" || children[children.Count - 1].Name != Mml + "mo" || children[0].HasElements || - children[children.Count - 1].HasElements) + children[children.Count - 1].HasElements || (string?)children[0].Attribute("intent") == ":text" || + (string?)children[children.Count - 1].Attribute("intent") == ":text") return null; var open = children[0].Value; var close = children[children.Count - 1].Value; diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 0de9f83..aa33150 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -164,7 +164,8 @@ public async Task ReadsLessCommonCommands(string latex, string expectedLaTeX, st await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); } - // a slash and a bar are ordinary characters of LaTeX, which UnicodeMath reads as a fraction and an absolute value + // a slash and a bar are ordinary characters of LaTeX, which UnicodeMath reads as a fraction and an absolute value; + // so is a bracket that a group opens and does not close, which UnicodeMath would close [Test] [Arguments(@"a/b", @"a/b", @"๐‘Ž\/๐‘")] [Arguments(@"(a+b)/2", @"(a+b)/2", @"(๐‘Ž+๐‘)\/2")] @@ -175,6 +176,18 @@ public async Task ReadsLessCommonCommands(string latex, string expectedLaTeX, st [Arguments(@"|a|\\|b|", @"|a||b|", @"|๐‘Ž||๐‘|")] [Arguments(@"|\,|\\|\!|\,|", @"|\,||\!|\,|", "|\u2009|\\||\u2009|")] [Arguments(@"|a|~|b|", @"|a|~|b|", "|๐‘Ž|\u00A0|๐‘|")] + [Arguments(@"a||b", @"a{|}|b", @"๐‘Ž\|\|๐‘")] + [Arguments(@"| |", @"{|}|", @"\|\|")] + [Arguments(@"||x||", @"\|x\|", @"โ€–๐‘ฅโ€–")] + [Arguments(@"\left|||\right|", @"\left|{|}{|}\right|", @"|(\|\|)|")] + [Arguments(@"{|}a|b|", @"{|}a\left|b\right|", @"\|๐‘Ž|๐‘|")] + [Arguments(@"\left(x|a|b\right)", @"(x|a|b)", @"(๐‘ฅ|๐‘Ž|๐‘)")] + [Arguments(@"\{|\}", @"\{{}|{}\}", @"{โฌšโ”‚โฌš}")] + [Arguments(@"(a|)", @"(a|{})", @"(๐‘Žโ”‚โฌš)")] + [Arguments(@"\phantom{(}x", @"\phantom{(}x", @"โŸก\( ๐‘ฅ")] + [Arguments(@"\mathstrut", @"\vphantom{(}", @"โ‡ณ\(")] + [Arguments(@"\sqrt{(}", @"\sqrt{(}", @"โˆš\(")] + [Arguments(@"\text{(}", @"\text{(}", @"""(""")] [Arguments(@"\langle a | b \rangle | c", @"\langle a|b\rangle|c", @"โŸจ๐‘Žโ”‚๐‘โŸฉ\|๐‘")] [Arguments(@"a|b", @"a|b", @"๐‘Ž\|๐‘")] [Arguments(@"(a\|b)", @"(a\|b)", @"(๐‘Ž\โ€–๐‘)")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 0b5ee4f..8ca1764 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -172,12 +172,9 @@ private static string Canonical(Equation equation) return string.Concat(math.Elements().Select(e => e.ToString(SaveOptions.DisableFormatting))).Replace(" xmlns=\"" + M.NamespaceName + "\"", ""); } - // what does not read back as it was written, through the UnicodeMath that LaTeX is read as: bars with nothing - // between them (|\,|\:|, \{\middle|\}), a parenthesis alone in a phantom (\mathstrut, \phantom{(}) - private static readonly HashSet NotWrittenBack = new HashSet - { - "spaces", "middle", "mathstrut", - }; + // what does not read back as it was written, through the UnicodeMath that LaTeX is read as: nothing now; a case + // that fails MathMLWritesLaTeXThatReadsBack and cannot be fixed is listed here, with why + private static readonly HashSet NotWrittenBack = new HashSet(); // what the reader does not read as latex2mathml does, in four groups; a case that starts to pass must leave its // group, and the union of the groups is what LaTeXReadsAsLatex2MathMLReadsIt allows From 1b0770327f2b9ac30d97b21fa64433138daacce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:00:14 +0000 Subject: [PATCH 4/4] [no-new-version] Read brackets alone as scripts, bars in text, \vert and \bigm| as LaTeX means them - A bracket alone as a script: x_( and x^{)} are escaped, where UnicodeMath read the opening of what follows or lost the bracket (\) and \] would end the LaTeX: the closing brackets are held as noncharacters until the end); a script in brackets that close, Word's x^(a+b), is left as it is. The writer braces a script that is a bracket (x_{[}, not x_[). - The bars of \text are text, not escaped (\text{a|b}). - \vert outside angles is the bar |, which EscapeOddBars pairs or escapes (a\vert b threw). - \bigm| outside brackets is a relation, {|}, a bar alone that no other bar pairs with (|a \bigm| b|). - The writer writes {|} for a bar alone between brackets, which the reader would read as their separator. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn --- .../Internal/LaTeX/LaTeXReader.cs | 112 +++++++++++++++++- .../Internal/LaTeX/LaTeXSerializer.cs | 16 ++- .../LaTeX/LaTeXReaderTests.cs | 14 ++- 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index a84437d..cdb749d 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -52,6 +52,9 @@ private static readonly (string Escape, char Held, char Character)[] TextEscapes ("\\textasciicircum{}", '\uFDD7', '^'), ("\\textasciitilde{}", '\uFDD8', '~'), }; + // the closing brackets that EscapeOpenBrackets escapes (x^{)}), held as noncharacters, since \) and \] would end the LaTeX + private static readonly (char Held, char Character)[] HeldBrackets = { ('\uFDDA', ')'), ('\uFDDB', ']') }; + private static readonly HashSet TextCommands = new HashSet(StringComparer.Ordinal) { "text", "textrm", "textup", "textnormal", "textbf", "textit", "textsf", "texttt", "textmd", "mbox", "emph", @@ -70,6 +73,8 @@ private static string RestoreTextEscapes(string unicodemath) { foreach (var (_, held, character) in TextEscapes) unicodemath = unicodemath.Replace(held, character); + foreach (var (held, character) in HeldBrackets) + unicodemath = unicodemath.Replace(held, character); if (!Characters(unicodemath).Any(c => CodePointAt(c, 0) >= HeldMarks)) return unicodemath; return string.Concat(Characters(unicodemath).Select(c => CodePointAt(c, 0) >= HeldMarks ? char.ConvertFromUtf32(CodePointAt(c, 0) - HeldMarks) : c)); @@ -436,7 +441,7 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i) ["smallfrown"] = "โŒข", ["smallsmile"] = "โŒฃ", ["thicksim"] = "โˆผ", ["thickapprox"] = "โ‰ˆ", ["varsubsetneqq"] = "โซ‹", ["varsupsetneq"] = "โŠ‹", ["varsupsetneqq"] = "โซŒ", ["bigcirc"] = "\\โ—ฏ", ["centerdot"] = "โฌ", ["degree"] = "ยฐ", ["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\โ•ฑ", ["diagdown"] = "\\โ•ฒ", ["thickspace"] = "\\;", - ["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["idotsint"] = "\\โˆซโ‹ฏ\\โˆซ", + ["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["vert"] = "|", ["idotsint"] = "\\โˆซโ‹ฏ\\โˆซ", ["mathscr"] = "\\mathcal", ["operatornamewithlimits"] = "\\operatorname", ["mathstrut"] = "\\vphantom{\\(}", }; @@ -682,6 +687,20 @@ private static string RewriteTeXOnly(string latex) i = fractionArguments.Next; continue; } + if (cw is "bigm" or "Bigm" or "biggm" or "Biggm" && brackets == 0) + { + // \bigm|, a relation outside brackets: a bar alone, {|}, which no other bar pairs with (|๐‘Ž \bigm| ๐‘|) + int k = j; + while (tex[k] == " ") + k++; + var afterBar = tex[k] == "|" ? k + 1 : tex.Sub(k, k + 5) == "\\vert" && !IsAsciiAlphabetic(tex[k + 5]) ? k + 5 : -1; + if (afterBar >= 0) + { + result.Append("{|}"); + i = afterBar; + continue; + } + } if (Dropped.Contains(cw)) { // sizes and styles that MathML leaves to the renderer: \bigl(, \limits, \displaystyle @@ -1132,6 +1151,12 @@ void Close((int Open, List Bars) frame, bool inBrackets, int close) if (ch == "\\") { var cw = ControlWord(tex, i + 1); + // the text of \text and the like, whose bars are text (\text{a|b}) + if (TextCommands.Contains(cw) && BracedArgument(tex, i + 1 + cw.Length) is var (_, afterText) && afterText >= 0) + { + i = afterText - 1; + continue; + } // the brackets among the commands (\{, \}, \langle, \rangle) become the bracket they stand for; an // escaped character (\|) is skipped ch = cw switch @@ -1205,13 +1230,15 @@ void Close((int Open, List Bars) frame, bool inBrackets, int close) /// /// A bracket that a group opens and does not close, as in \phantom{(}, escaped: UnicodeMath would close it with an - /// empty fence at the end of the group. The text of \text and the like is left as it is. + /// empty fence at the end of the group; so are a closing bracket alone in a group (x^{)}) and a bracket + /// that is a script and does not close (x_(). The text of \text and the like is left as it is. /// private static string EscapeOpenBrackets(string latex) { var tex = new Text(latex); var groups = new Stack>(); var escaped = new List(); + var scripts = new HashSet(); for (int i = 0; i < tex.Length; i++) { switch (tex[i]) @@ -1240,9 +1267,31 @@ private static string EscapeOpenBrackets(string latex) case "(" or "[" when groups.Count > 0: groups.Peek().Add(i); break; - case ")" or "]" when groups.Count > 0 && groups.Peek().Count > 0: - groups.Peek().RemoveAt(groups.Peek().Count - 1); + case ")" or "]" when groups.Count > 0: + if (groups.Peek().Count > 0) + groups.Peek().RemoveAt(groups.Peek().Count - 1); + else if (Blank(tex, i, -1) == "{" && Blank(tex, i, 1) == "}") + { + // a group of a closing bracket alone (x^{)}) is escaped too; in others the bracket may close + // another (โŸจ๐‘Ž]) + escaped.Add(i); + } + break; + case "_" or "^": + { + // a bracket that is a script and closes nothing, x^), or opens what does not close, x_(: Word's LaTeX + // groups a script in brackets (x^(a+b)), which UnicodeMath would read as the opening of what follows + int k = i + 1; + while (tex[k] == " ") + k++; + if (tex[k] is ")" or "]" || tex[k] is "(" or "[" && !BracketCloses(tex, k)) + { + escaped.Add(k); + scripts.Add(k); + i = k; + } break; + } } } if (escaped.Count == 0) @@ -1250,13 +1299,64 @@ private static string EscapeOpenBrackets(string latex) var result = new StringBuilder(); for (int i = 0; i < tex.Length; i++) { + if (scripts.Contains(i)) + result.Append('{'); if (escaped.Contains(i)) - result.Append('\\'); - result.Append(tex[i]); + { + // a closing bracket held, since \) and \] would end the LaTeX + result.Append('\\').Append(tex[i] switch + { + ")" => "\uFDDA", + "]" => "\uFDDB", + var bracket => bracket, + }); + } + else + result.Append(tex[i]); + if (scripts.Contains(i)) + result.Append('}'); } return result.ToString(); } + /// The character before (step -1) or after (step 1) the index, past the blanks. + private static string Blank(Text tex, int i, int step) + { + do + i += step; + while (i >= 0 && i < tex.Length && tex[i] == " "); + return i >= 0 && i < tex.Length ? tex[i] : null; + } + + /// Whether the bracket at the index closes before the group around it ends. + private static bool BracketCloses(Text tex, int open) + { + int depth = 0, braces = 0; + for (int i = open; i < tex.Length; i++) + { + switch (tex[i]) + { + case "\\": + i++; + break; + case "{": + braces++; + break; + case "}" when braces == 0: + return false; + case "}": + braces--; + break; + case "(" or "[": + depth++; + break; + case ")" or "]" when --depth == 0: + return true; + } + } + return false; + } + /// The count arguments (braced or single characters) at the index, and the index after them; null if missing. private static (List Arguments, int Next)? Arguments(Text tex, int i, int count) { diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 2d2c11a..b9db6b2 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -76,12 +76,13 @@ private string CheckBracing(string str, bool isBase = false) { if (Len(str) == 0) return "{}"; - if (Len(str) == 1 || SingleCommand().IsMatch(str) || IsGroup(str) || isBase && Ch(str, 0) == "\\" && IsUnit(str)) + if (Len(str) == 1 && (isBase || !IsBracket(str)) || SingleCommand().IsMatch(str) || IsGroup(str) || isBase && Ch(str, 0) == "\\" && IsUnit(str)) return str; return "{" + str + "}"; } int length = Len(str); - if (length > 2 && !IsUnit(str) || length == 2) + // a script that is a bracket is braced, which the reader would otherwise read as the opening of what follows (x_{(}) + if (length > 2 && !IsUnit(str) || length == 2 || length == 1 && !isBase && IsBracket(str)) str = "{" + str + "}"; return str; } @@ -580,9 +581,12 @@ private string TeX(XElement value, bool noAddParens = false) value.ElementsAfterSelf().FirstOrDefault()?.DescendantsAndSelf().FirstOrDefault(d => !d.HasElements) is { } nextLeaf && IsBar(nextLeaf) && Text(nextLeaf) == "|" && Attr(nextLeaf, "intent") != ":text") return "|\\!"; - // a bar alone before other bars, which the reader would pair it with: {|}, the ordinary bar of TeX - if (val == "|" && Attr(value, "intent") == ":text" && value.ElementsAfterSelf().Concat(value.Ancestors().SelectMany(a => a.ElementsAfterSelf())) - .SelectMany(e => e.DescendantsAndSelf()).Any(d => IsBar(d) && Text(d) == "|")) + // a bar alone before other bars, which the reader would pair it with: {|}, the ordinary bar of TeX; + // or between brackets, where the reader would read it as their separator ({๐‘ฅโ”‚๐‘ฅ>0}) + if (val == "|" && Attr(value, "intent") == ":text" && (value.ElementsAfterSelf().Concat(value.Ancestors().SelectMany(a => a.ElementsAfterSelf())) + .SelectMany(e => e.DescendantsAndSelf()).Any(d => IsBar(d) && Text(d) == "|") || + value.Ancestors().Any(a => Name(a) == "mrow" && First(a) is { } open && open != value && Name(open) == "mo" && + IsOpenDelimiter(Text(open)) && Attr(open, "intent") != ":text"))) return "{|}"; if (Attr(value, "intent") == ":text" && !standard) { @@ -854,6 +858,8 @@ private string EscapedStructureOperator(string val) /// The operators that build a structure in UnicodeMath (โ–ญ, โˆš, ยฆโ€ฆ), escaped when they are alone: \โ–ญ. internal static bool IsStructureOperator(string text) => text != null && Len(text) == 1 && "โ–ญโ–โ–ขโ—ฏโŸŒโฌญโ•ฑโ•ฒโ•ณโˆšโˆ›โˆœโ’ญยฆโ’žโ’œโ“’โ–กโŸกโฌ„โ‡ณโฌโฌ†โฌ‡โฌŒโ– โ’จโ’ฉโ“ขโ“ˆโ’ฑโ–ˆโ“˜โ“โ“กโ“œโ““โ“‘โ–’".Contains(text); + private static bool IsBracket(string str) => str is "(" or ")" or "[" or "]"; + private static bool IsBar(XElement element) => element != null && Name(element) == "mo" && (Text(element) == "|" || Text(element) == "โ€–"); private static bool IsCombiningMark(string text) => diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index aa33150..1226a12 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -165,7 +165,8 @@ public async Task ReadsLessCommonCommands(string latex, string expectedLaTeX, st } // a slash and a bar are ordinary characters of LaTeX, which UnicodeMath reads as a fraction and an absolute value; - // so is a bracket that a group opens and does not close, which UnicodeMath would close + // so is a bracket that a group opens and does not close, which UnicodeMath would close, or a bracket alone as a + // script; a bar in text, after \bigm (a relation) or {|} is a bar alone, which no other bar pairs with [Test] [Arguments(@"a/b", @"a/b", @"๐‘Ž\/๐‘")] [Arguments(@"(a+b)/2", @"(a+b)/2", @"(๐‘Ž+๐‘)\/2")] @@ -188,6 +189,17 @@ public async Task ReadsLessCommonCommands(string latex, string expectedLaTeX, st [Arguments(@"\mathstrut", @"\vphantom{(}", @"โ‡ณ\(")] [Arguments(@"\sqrt{(}", @"\sqrt{(}", @"โˆš\(")] [Arguments(@"\text{(}", @"\text{(}", @"""(""")] + [Arguments(@"x_{[}", @"x_{[}", @"๐‘ฅ_\[")] + [Arguments(@"x_(", @"x_{(}", @"๐‘ฅ_\(")] + [Arguments(@"x^{)}", @"x^{)}", @"๐‘ฅ^\)")] + [Arguments(@"x^{ ] }", @"x^{]}", @"๐‘ฅ^\]")] + [Arguments(@"\text{a|b}", @"\text{a|b}", @"""a|b""")] + [Arguments(@"a\vert b", @"a|b", @"๐‘Ž\|๐‘")] + [Arguments(@"\vert x\vert", @"|x|", @"|๐‘ฅ|")] + [Arguments(@"a \Bigm\vert b", @"a|b", @"๐‘Ž\|๐‘")] + [Arguments(@"|a \bigm| b|", @"\left|a{|}b\right|", @"|(๐‘Ž\|๐‘)|")] + [Arguments(@"\{x \bigm| x>0\}", @"\{x|x>0\}", @"{๐‘ฅโ”‚๐‘ฅ>0}")] + [Arguments(@"\{x {|} x>0\}", @"\{x{|}x>0\}", @"{๐‘ฅ\|๐‘ฅ>0}")] [Arguments(@"\langle a | b \rangle | c", @"\langle a|b\rangle|c", @"โŸจ๐‘Žโ”‚๐‘โŸฉ\|๐‘")] [Arguments(@"a|b", @"a|b", @"๐‘Ž\|๐‘")] [Arguments(@"(a\|b)", @"(a\|b)", @"(๐‘Ž\โ€–๐‘)")]