diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index de5b6b9..bb3e5f0 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{\\(}", // \Re and \Im, the symbols ℜ and ℑ of LaTeX, which UnicodeMath reads as the function names Re and Im ["Re"] = "ℜ", ["Im"] = "ℑ", @@ -684,6 +689,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 @@ -1183,6 +1202,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 @@ -1256,13 +1281,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]) @@ -1291,9 +1318,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) @@ -1301,13 +1350,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 790211e..3065e61 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -89,12 +89,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; } @@ -597,9 +598,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) { @@ -877,6 +881,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 2672511..743da6c 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -171,7 +171,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")] @@ -194,6 +195,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)", @"(𝑎\‖𝑏)")]