diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 9d122367..6128b7e7 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -1665,5 +1665,87 @@ public void TestErrorSurrogates() { Assert.Equal(expected.Replace("\r", null), actual); } } + [Theory] + [InlineData("plain", 0, (int)LaTeXTokenKind.RawText, 0, 5, "plain")] + [InlineData("x \r\n", 1, (int)LaTeXTokenKind.Whitespace, 1, 3, " \r\n")] + [InlineData("\\", 0, (int)LaTeXTokenKind.ControlSymbol, 0, 1, "\\")] + [InlineData(@"\alpha+", 0, (int)LaTeXTokenKind.ControlWord, 0, 6, @"\alpha")] + [InlineData(@"\@name!", 0, (int)LaTeXTokenKind.ControlSymbol, 0, 2, @"\@")] + [InlineData(@"\%", 0, (int)LaTeXTokenKind.ControlSymbol, 0, 2, @"\%")] + [InlineData("{", 0, (int)LaTeXTokenKind.GroupOpen, 0, 1, "{")] + [InlineData("}", 0, (int)LaTeXTokenKind.GroupClose, 0, 1, "}")] + [InlineData("$", 0, (int)LaTeXTokenKind.InlineMathDelimiter, 0, 1, "$")] + [InlineData("$$", 0, (int)LaTeXTokenKind.DisplayMathDelimiter, 0, 2, "$$")] + [InlineData("$$$$", 0, (int)LaTeXTokenKind.InvalidDollarRun, 0, 4, "$$$$")] + [InlineData("\U0001F600\\alpha", 2, (int)LaTeXTokenKind.ControlWord, 2, 6, @"\alpha")] + public void SharedLexerReadAtPreservesUtf16Spans(string source, int offset, + int kind, int start, int length, string text) { + var token = LaTeXTokenizer.ReadAt(source, offset); + + Assert.Equal((LaTeXTokenKind)kind, token.Kind); + Assert.Equal(start, token.Start); + Assert.Equal(length, token.Length); + Assert.Equal(start + length, token.End); + Assert.Equal(text, token.Text); + Assert.Same(source, token.Source); + } + + [Theory] + [InlineData("\\", 1)] + [InlineData(@"\@name+", 2)] + [InlineData(@"\alpha@beta+", 6)] + [InlineData(@"\alpha**", 6)] + [InlineData(@"\alpha==", 6)] + [InlineData(@"\alpha''", 6)] + [InlineData(@"\@*'", 2)] + [InlineData(@"\*alpha", 2)] + public void SharedLexerUsesTeXControlSequenceBoundaries(string source, int expectedLength) => + Assert.Equal(expectedLength, LaTeXTokenizer.ReadCommandLength(source.AsSpan())); + + [Fact] + public void SharedLexerTokenizeIteratesEveryUtf16CodeUnit() { + var source = "\U0001F600raw \t\\@cmd*\\${}$$ $$$\\"; + var expected = new[] { + (LaTeXTokenKind.RawText, 0, 5, "\U0001F600raw"), + (LaTeXTokenKind.Whitespace, 5, 2, " \t"), + (LaTeXTokenKind.ControlSymbol, 7, 2, @"\@"), + (LaTeXTokenKind.RawText, 9, 4, "cmd*"), + (LaTeXTokenKind.ControlSymbol, 13, 2, @"\$"), + (LaTeXTokenKind.GroupOpen, 15, 1, "{"), + (LaTeXTokenKind.GroupClose, 16, 1, "}"), + (LaTeXTokenKind.DisplayMathDelimiter, 17, 2, "$$"), + (LaTeXTokenKind.Whitespace, 19, 1, " "), + (LaTeXTokenKind.InvalidDollarRun, 20, 3, "$$$"), + (LaTeXTokenKind.ControlSymbol, 23, 1, "\\"), + }; + + var tokens = LaTeXTokenizer.Tokenize(source); + + Assert.Equal(expected.Length, tokens.Count); + var nextStart = 0; + for (var i = 0; i < tokens.Count; i++) { + var token = tokens[i]; + Assert.Equal(expected[i].Item1, token.Kind); + Assert.Equal(expected[i].Item2, token.Start); + Assert.Equal(expected[i].Item3, token.Length); + Assert.Equal(expected[i].Item4, token.Text); + Assert.Equal(nextStart, token.Start); + nextStart = token.End; + } + Assert.Equal(source.Length, nextStart); + Assert.Empty(LaTeXTokenizer.Tokenize(string.Empty)); + } + + [Fact] + public void MathParserPreservesEscapedDollarBracesAndNestedGroups() { + var list = ParseLaTeX(@"x{{\$\{\alpha\}}}"); + + Assert.Collection(list, + CheckAtom("x"), + CheckAtom("$"), + CheckAtom("{"), + CheckAtom("α"), + CheckAtom("}")); + } } } diff --git a/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs b/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs index 05b8459d..c71fb55f 100644 --- a/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs +++ b/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs @@ -503,5 +503,32 @@ public void Error(string badInput, string expected) { Assert.Null(atom); Assert.Equal(expected.Replace("\r", null), actual); } + + [Theory] + [InlineData(@"\@name", @"\@", 3)] + [InlineData(@"\notacommand@beta", @"\notacommand", 13)] + [InlineData(@"\notacommand*", @"\notacommand", 13)] + [InlineData(@"\notacommand=", @"\notacommand", 13)] + [InlineData(@"\notacommand'", @"\notacommand", 13)] + public void TextParserReportsSharedControlSequenceBoundariesAtNonzeroOffsets( + string inputCommand, string expectedCommand, int position) { + var source = "x" + inputCommand + "+"; + + var (atom, error) = TextLaTeXParser.TextAtomFromLaTeX(source); + + Assert.Null(atom); + Assert.Equal($"Error: Invalid command {expectedCommand}\n{source}\n{new string(' ', position - 1)}\u2191 (pos {position})", error); + } + + [Theory] + [InlineData(@"x\alpha@beta+", @"x\alpha @beta+")] + [InlineData(@"x\alpha*+", @"x\alpha *+")] + [InlineData(@"x\alpha=+", @"x\alpha =+")] + [InlineData(@"x\alpha'+", @"x\alpha '+")] + public void TextParserLeavesControlWordSuffixesForText(string source, string expected) { + var atom = Parse(source); + + Assert.Equal(expected, TextLaTeXParser.TextAtomToLaTeX(atom).ToString()); + } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextLaTeXParser.cs b/CSharpMath.Rendering/Text/TextLaTeXParser.cs index 569f92ed..1da51f59 100644 --- a/CSharpMath.Rendering/Text/TextLaTeXParser.cs +++ b/CSharpMath.Rendering/Text/TextLaTeXParser.cs @@ -43,7 +43,7 @@ public static Result TextAtomFromLaTeX(string latexSource) { if (string.IsNullOrEmpty(latexSource)) return new TextAtom.List(Array.Empty()); int endAt = 0; - bool? displayMath = null; + var mode = LaTeXMode.Text; var mathLaTeX = new StringBuilder(); bool backslashEscape = false; bool afterCommand = false; // ignore spaces after command @@ -60,42 +60,28 @@ public static Result TextAtomFromLaTeX(string latexSource) { breaker.AddBreakingEngine(engine); breaker.BreakWords(latexSource); + Result TransitionMathMode(LaTeXModeBoundary boundary, int mathEndAt, ref int errorEndAt, TextAtomListBuilder atoms) { + var currentMode = mode; + if (LaTeXModeTransition.TryTransition(currentMode, boundary, out var nextMode) is string error) + return error; + if (currentMode != LaTeXMode.Text && nextMode == LaTeXMode.Text) { + if (atoms.Math(mathLaTeX.ToString(), currentMode == LaTeXMode.DisplayMath, mathEndAt, ref errorEndAt).Error is string mathError) + return mathError; + mathLaTeX.Clear(); + } + mode = nextMode; + return Ok(); + } Result CheckDollarCount(int startAt, ref int endAt, TextAtomListBuilder atoms) { switch (dollarCount) { case 0: break; case 1: dollarCount = 0; - switch (displayMath) { - case true: - return "Cannot close display math mode with $"; - case false: - if (atoms.Math(mathLaTeX.ToString(), false, startAt, ref endAt).Error is string error) - return error; - mathLaTeX.Clear(); - displayMath = null; - break; - case null: - displayMath = false; - break; - } - break; + return TransitionMathMode(LaTeXModeBoundary.InlineDollar, startAt, ref endAt, atoms); case 2: dollarCount = 0; - switch (displayMath) { - case true: - if (atoms.Math(mathLaTeX.ToString(), true, startAt - 1, ref endAt).Error is string error) - return error; - mathLaTeX.Clear(); - displayMath = null; - break; - case false: - return "Cannot close inline math mode with $$"; - case null: - displayMath = true; - break; - } - break; + return TransitionMathMode(LaTeXModeBoundary.DisplayDollar, startAt - 1, ref endAt, atoms); default: return "Invalid number of $: " + dollarCount; } @@ -126,6 +112,13 @@ Result ReadArgumentAtom(ReadOnlySpan latexInput) { return BuildBreakList(latexInput, argAtoms, ++i, true, '\0') .Bind(index => { i = index; return argAtoms.Build(); }); } + var sharedCommandEnd = endAt; + var sharedCommandName = textSection.ToString(); + if (backslashEscape && startAt > 0 && latexSource[startAt - 1] == '\\') { + var sharedCommand = LaTeXTokenizer.ReadAt(latexSource, startAt - 1); + sharedCommandName = latexSource.Substring(startAt, sharedCommand.Length - 1); + sharedCommandEnd = sharedCommand.End; + } SpanResult ReadArgumentString(ReadOnlySpan latexInput, ref ReadOnlySpan section) { afterCommand = false; if (!NextSection(latexInput, ref section) || section.IsNot('{')) return Err("Missing {"); @@ -157,7 +150,7 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se atoms.TextLength = startAt; if (textSection.Is('$')) { if (backslashEscape) - if (displayMath != null) mathLaTeX.Append(@"\$"); + if (mode != LaTeXMode.Text) mathLaTeX.Append(@"\$"); else atoms.Text("$"); else { dollarCount++; @@ -166,8 +159,9 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se backslashEscape = false; } else { { if (CheckDollarCount(startAt, ref endAt, atoms).Error is string error) return error; } - switch (backslashEscape, displayMath) { - case (false, { }): + switch (backslashEscape, mode) { + case (false, LaTeXMode.InlineMath): + case (false, LaTeXMode.DisplayMath): //Unescaped text section, inside display/inline math mode switch (textSection) { case var _ when textSection.Is('$'): @@ -181,7 +175,7 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se } afterCommand = false; break; - case (false, null): + case (false, LaTeXMode.Text): //Unescaped text section, not inside display/inline math mode switch (textSection) { case var _ when stopChar > 0 && textSection[0] == stopChar: @@ -245,50 +239,27 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se } afterCommand = false; break; - case (true, { }): + case (true, LaTeXMode.InlineMath): + case (true, LaTeXMode.DisplayMath): //Escaped text section but in inline/display math mode switch (textSection) { case var _ when textSection.Is('$'): throw new InvalidCodePathException("The $ case should have been accounted for."); case var _ when textSection.Is('('): - return displayMath switch { - true => "Cannot open inline math mode in display math mode", - false => "Cannot open inline math mode in inline math mode", - null => throw new InvalidCodePathException("displayMath is null. This switch should not be hit."), - }; + if (TransitionMathMode(LaTeXModeBoundary.InlineCommandOpen, startAt, ref endAt, atoms).Error is string inlineOpenError) + return inlineOpenError; + break; case var _ when textSection.Is(')'): - switch (displayMath) { - case true: - return "Cannot close inline math mode in display math mode"; - case false: - if (atoms.Math(mathLaTeX.ToString(), false, startAt, ref endAt).Error is string mathError) - return mathError; - mathLaTeX.Clear(); - displayMath = null; - break; - case null: - throw new InvalidCodePathException("displayMath is null. This switch should not be hit."); - } + if (TransitionMathMode(LaTeXModeBoundary.InlineCommandClose, startAt, ref endAt, atoms).Error is string inlineError) + return inlineError; break; case var _ when textSection.Is('['): - return displayMath switch { - true => "Cannot open display math mode in display math mode", - false => "Cannot open display math mode in inline math mode", - null => throw new InvalidCodePathException("displayMath is null. This switch should not be hit."), - }; + if (TransitionMathMode(LaTeXModeBoundary.DisplayCommandOpen, startAt, ref endAt, atoms).Error is string displayOpenError) + return displayOpenError; + break; case var _ when textSection.Is(']'): - switch (displayMath) { - case true: - if (atoms.Math(mathLaTeX.ToString(), true, startAt, ref endAt).Error is string mathError) - return mathError; - mathLaTeX.Clear(); - displayMath = null; - break; - case false: - return "Cannot close display math mode in inline math mode"; - default: - throw new InvalidCodePathException("displayMath is null. This switch should not be hit."); - } + if (TransitionMathMode(LaTeXModeBoundary.DisplayCommandClose, startAt, ref endAt, atoms).Error is string displayError) + return displayError; break; default: mathLaTeX.Append('\\').Append(textSection); @@ -296,23 +267,29 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se } backslashEscape = false; break; - case (true, null): + case (true, LaTeXMode.Text): //Escaped text section and not in inline/display math mode afterCommand = true; - switch (textSection.ToString()) { + switch (sharedCommandName) { case var _ when wordKind == WordKind.Whitespace: //control space atoms.ControlSpace(); break; case "(": - displayMath = false; + if (TransitionMathMode(LaTeXModeBoundary.InlineCommandOpen, startAt, ref endAt, atoms).Error is string inlineOpenError) + return inlineOpenError; break; case ")": - return "Cannot close inline math mode outside of math mode"; + if (TransitionMathMode(LaTeXModeBoundary.InlineCommandClose, startAt, ref endAt, atoms).Error is string inlineCloseError) + return inlineCloseError; + break; case "[": - displayMath = true; + if (TransitionMathMode(LaTeXModeBoundary.DisplayCommandOpen, startAt, ref endAt, atoms).Error is string displayOpenError) + return displayOpenError; break; case "]": - return "Cannot close display math mode outside of math mode"; + if (TransitionMathMode(LaTeXModeBoundary.DisplayCommandClose, startAt, ref endAt, atoms).Error is string displayCloseError) + return displayCloseError; + break; case "\\": atoms.Break(); break; @@ -416,9 +393,8 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se atoms.Text(replaceResult); break; case var command: - if (displayMath != null) mathLaTeX.Append(command); //don't eat the command when parsing math - else return $@"Invalid command \{command}"; - break; + endAt = sharedCommandEnd; + return $@"Invalid command \{command}"; } backslashEscape = false; break; @@ -435,7 +411,7 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se if (error != null) return LaTeXParser.HelpfulErrorMessage(error, latexSource, endAt); error = CheckDollarCount(latexSource.Length, ref endAt, globalAtoms).Error; if (error != null) return LaTeXParser.HelpfulErrorMessage(error, latexSource, endAt); - if (displayMath != null) return LaTeXParser.HelpfulErrorMessage("Math mode was not terminated", latexSource, endAt); + if (mode != LaTeXMode.Text) return LaTeXParser.HelpfulErrorMessage("Math mode was not terminated", latexSource, endAt); return globalAtoms.Build(); } public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = null) { @@ -504,4 +480,4 @@ public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = nu } } } -} \ No newline at end of file +} diff --git a/CSharpMath/AssemblyInfo.cs b/CSharpMath/AssemblyInfo.cs new file mode 100644 index 00000000..f662d346 --- /dev/null +++ b/CSharpMath/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CSharpMath.Rendering")] +[assembly: InternalsVisibleTo("CSharpMath.Core.Tests")] diff --git a/CSharpMath/Atom/Dictionary.cs b/CSharpMath/Atom/Dictionary.cs index f250483f..772d7a4e 100644 --- a/CSharpMath/Atom/Dictionary.cs +++ b/CSharpMath/Atom/Dictionary.cs @@ -93,15 +93,7 @@ public IEnumerator> GetEnumerator() => // https://tug.org/texinfohtml/latex2e.html#g_t_005cmakeatletter_0026-_005cmakeatother static bool IsAsciiLetter(char c) => 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z'; - static int SplitCommand(ReadOnlySpan chars) { - System.Diagnostics.Debug.Assert(chars[0] == '\\'); - var splitIndex = 1; - if (splitIndex < chars.Length) - if (IsAsciiLetter(chars[splitIndex])) { - do splitIndex++; while (splitIndex < chars.Length && IsAsciiLetter(chars[splitIndex])); - } else splitIndex++; - return splitIndex; - } + static int SplitCommand(ReadOnlySpan chars) => LaTeXTokenizer.ReadCommandLength(chars); /// Tries to find a command at the beginning of s, returning the /// corresponding to the command Key, and the length of the command. public Result<(TValue Result, int SplitIndex)> TryLookup(ReadOnlySpan chars) { diff --git a/CSharpMath/Atom/LaTeXToken.cs b/CSharpMath/Atom/LaTeXToken.cs new file mode 100644 index 00000000..c25d9d27 --- /dev/null +++ b/CSharpMath/Atom/LaTeXToken.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; + +namespace CSharpMath.Atom { + /// The lexical categories shared by the math and text LaTeX readers. + internal enum LaTeXTokenKind { + RawText, + Whitespace, + ControlWord, + ControlSymbol, + GroupOpen, + GroupClose, + InlineMathDelimiter, + DisplayMathDelimiter, + InvalidDollarRun, + } + + /// A source token. Start and Length are UTF-16 code-unit offsets. + internal readonly struct LaTeXToken { + internal LaTeXToken(LaTeXTokenKind kind, int start, int length, string source) { + Kind = kind; + Start = start; + Length = length; + Source = source; + } + internal LaTeXTokenKind Kind { get; } + internal int Start { get; } + internal int Length { get; } + internal int End => Start + Length; + internal string Source { get; } + internal string Text => Source.Substring(Start, Length); + public override string ToString() => $"{Kind}({Start}, {Length}): {Text}"; + } + + /// Tokenizes LaTeX without interpreting commands or consuming terminators. + internal static class LaTeXTokenizer { + internal static int ReadCommandLength(ReadOnlySpan source) { + if (source.IsEmpty || source[0] != '\\') throw new ArgumentException("A command must start with \\", nameof(source)); + var end = 1; + if (end == source.Length) return end; + if (IsAsciiLetter(source[end])) { + do end++; while (end < source.Length && IsAsciiLetter(source[end])); + } else end++; + return end; + } + internal static LaTeXToken ReadAt(string source, int offset) { + if (source is null) throw new ArgumentNullException(nameof(source)); + if (offset < 0 || offset >= source.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + var ch = source[offset]; var end = offset + 1; + if (ch == '\\') { + var length = ReadCommandLength(source.AsSpan(offset)); + return new LaTeXToken(length > 1 && IsAsciiLetter(source[offset + 1]) ? LaTeXTokenKind.ControlWord : LaTeXTokenKind.ControlSymbol, offset, length, source); + } + if (ch == '$') { + while (end < source.Length && source[end] == '$') end++; + var count = end - offset; + return new LaTeXToken(count == 1 ? LaTeXTokenKind.InlineMathDelimiter : count == 2 ? LaTeXTokenKind.DisplayMathDelimiter : LaTeXTokenKind.InvalidDollarRun, offset, count, source); + } + if (ch == '{') return new LaTeXToken(LaTeXTokenKind.GroupOpen, offset, 1, source); + if (ch == '}') return new LaTeXToken(LaTeXTokenKind.GroupClose, offset, 1, source); + if (char.IsWhiteSpace(ch)) { while (end < source.Length && char.IsWhiteSpace(source[end])) end++; return new LaTeXToken(LaTeXTokenKind.Whitespace, offset, end - offset, source); } + while (end < source.Length && !IsSpecial(source[end])) end++; + return new LaTeXToken(LaTeXTokenKind.RawText, offset, end - offset, source); + } + internal static IReadOnlyList Tokenize(string source) { + if (source is null) throw new ArgumentNullException(nameof(source)); + var result = new List(); + for (var i = 0; i < source.Length;) { var token = ReadAt(source, i); result.Add(token); i = token.End; } + return result; + } + + private static bool IsAsciiLetter(char c) => c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'; + private static bool IsSpecial(char c) => c == '\\' || c == '$' || c == '{' || c == '}' || char.IsWhiteSpace(c); + } + + internal enum LaTeXMode { Text, InlineMath, DisplayMath } + + internal enum LaTeXModeBoundary { + InlineDollar, + DisplayDollar, + InlineCommandOpen, + InlineCommandClose, + DisplayCommandOpen, + DisplayCommandClose, + } + + /// Defines the mode changes shared by text-mode math delimiters. + internal static class LaTeXModeTransition { + internal static string? TryTransition(LaTeXMode current, LaTeXModeBoundary boundary, out LaTeXMode next) { + next = current; + switch (current, boundary) { + case (LaTeXMode.Text, LaTeXModeBoundary.InlineDollar): + case (LaTeXMode.Text, LaTeXModeBoundary.InlineCommandOpen): + next = LaTeXMode.InlineMath; + return null; + case (LaTeXMode.InlineMath, LaTeXModeBoundary.InlineDollar): + case (LaTeXMode.InlineMath, LaTeXModeBoundary.InlineCommandClose): + next = LaTeXMode.Text; + return null; + case (LaTeXMode.Text, LaTeXModeBoundary.DisplayDollar): + case (LaTeXMode.Text, LaTeXModeBoundary.DisplayCommandOpen): + next = LaTeXMode.DisplayMath; + return null; + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.DisplayDollar): + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.DisplayCommandClose): + next = LaTeXMode.Text; + return null; + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.InlineDollar): + return "Cannot close display math mode with $"; + case (LaTeXMode.InlineMath, LaTeXModeBoundary.DisplayDollar): + return "Cannot close inline math mode with $$"; + case (LaTeXMode.InlineMath, LaTeXModeBoundary.InlineCommandOpen): + return "Cannot open inline math mode in inline math mode"; + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.InlineCommandOpen): + return "Cannot open inline math mode in display math mode"; + case (LaTeXMode.Text, LaTeXModeBoundary.InlineCommandClose): + return "Cannot close inline math mode outside of math mode"; + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.InlineCommandClose): + return "Cannot close inline math mode in display math mode"; + case (LaTeXMode.InlineMath, LaTeXModeBoundary.DisplayCommandOpen): + return "Cannot open display math mode in inline math mode"; + case (LaTeXMode.DisplayMath, LaTeXModeBoundary.DisplayCommandOpen): + return "Cannot open display math mode in display math mode"; + case (LaTeXMode.Text, LaTeXModeBoundary.DisplayCommandClose): + return "Cannot close display math mode outside of math mode"; + case (LaTeXMode.InlineMath, LaTeXModeBoundary.DisplayCommandClose): + return "Cannot close display math mode in inline math mode"; + default: + throw new ArgumentOutOfRangeException(nameof(boundary)); + } + } + } +}