diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index adf23e4..f7753e1 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -37,7 +37,7 @@ private LaTeXReader(IDictionary macros, bool display) /// /// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def - /// or \newcommand go to , where later conversions find them. In a display equation the + /// or \newcommand, 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. /// @@ -1161,9 +1161,145 @@ private static (string Text, int Next) BracedArgument(Text tex, int i) return (null, -1); } + /// + /// The environments of \newenvironment{name}[n][default]{begin}{end} (and \renewenvironment) applied: the + /// definitions removed and kept in the macros under {name}, where later conversions find them, and each + /// \begin{name}{arguments}...\end{name} replaced by the begin code with its arguments (#1...), the body and the + /// end code, as TeX expands them. + /// + private string NewEnvironments(string latex) + { + var tex = new Text(latex); + var result = new StringBuilder(); + int expansions = 0; + for (int i = 0; i < tex.Length;) + { + var cw = tex[i] == "\\" ? ControlWord(tex, i + 1) : ""; + int j = i + 1 + cw.Length; + switch (cw) + { + case "newenvironment" or "renewenvironment" when BracedArgument(tex, j) is var (name, afterName) && afterName >= 0 && + EnvironmentDefinition(tex, afterName) is var (_, _, _, _, afterDefinition) && afterDefinition >= 0: + macros["{" + name.Trim() + "}"] = tex.Sub(afterName, afterDefinition); + i = afterDefinition; + continue; + case "begin" when BracedArgument(tex, j) is var (name, afterName) && afterName >= 0 && + macros.TryGetValue("{" + name.Trim() + "}", out var definition) && + EnvironmentDefinition(new Text(definition), 0) is var (count, optional, begin, end, _) && + EnvironmentArguments(tex, afterName, count, optional) is var (arguments, afterArguments) && afterArguments >= 0 && + EnvironmentEnd(tex, afterArguments, name.Trim()) is var (bodyEnd, afterEnd) && bodyEnd >= 0: + if (++expansions > 1000) + throw new FormatException("The LaTeX environment " + name.Trim() + " does not stop expanding."); + // the expansion read again, for the environments it uses + var expansion = new StringBuilder(); + foreach (var part in new[] { Substitute(begin, arguments), tex.Sub(afterArguments, bodyEnd), end, tex.Sub(afterEnd) }) + Join(expansion, part); + tex = new Text(expansion.ToString()); + i = 0; + if (ContinuesControlWord(result, expansion.ToString())) + result.Append(' '); + continue; + default: + var next = cw.Length > 0 ? j : i + (tex[i] == "\\" ? 2 : 1); + result.Append(tex.Sub(i, next)); + i = next; + continue; + } + } + return result.ToString(); + } + + /// The [n][default]{begin}{end} of an environment definition at the index; Next -1 if there is none. + private static (int Count, string Optional, string Begin, string End, int Next) EnvironmentDefinition(Text tex, int i) + { + var (countText, afterCount) = OptionalArgument(tex, i); + int count = 0; + if (countText != null && (!int.TryParse(countText.Trim(), out count) || count < 0 || count > 9)) + return (0, null, null, null, -1); + var (optional, afterOptional) = countText == null ? (null, afterCount) : OptionalArgument(tex, afterCount); + var (begin, afterBegin) = BracedArgument(tex, afterOptional); + var (end, afterEnd) = afterBegin < 0 ? (null, -1) : BracedArgument(tex, afterBegin); + return (count, optional, begin, end, afterEnd); + } + + /// The arguments of an environment at the index (the first one optional when it has a default); Next -1 if missing. + private static (List Arguments, int Next) EnvironmentArguments(Text tex, int i, int count, string optional) + { + var arguments = new List(); + if (optional != null && count > 0) + { + var (given, afterGiven) = OptionalArgument(tex, i); + arguments.Add(given ?? optional); + i = afterGiven; + } + return Arguments(tex, i, count - arguments.Count) is var (rest, next) ? (arguments.Concat(rest).ToList(), next) : (null, -1); + } + + /// The index of the \end{name} that closes an environment whose body starts at the index, and the index after it; -1 if none. + private static (int End, int Next) EnvironmentEnd(Text tex, int i, string name) + { + int depth = 1; + for (; i < tex.Length; i++) + { + if (tex[i] != "\\") + continue; + var cw = ControlWord(tex, i + 1); + if (cw is "begin" or "end" && BracedArgument(tex, i + 1 + cw.Length) is var (environment, after) && after >= 0 && environment.Trim() == name) + { + depth += cw == "begin" ? 1 : -1; + if (depth == 0) + return (i, after); + } + i += Math.Max(1, cw.Length); + } + return (-1, -1); + } + + /// The begin code of an environment with its #1... replaced by the arguments, and ## by #. + private static string Substitute(string code, List arguments) + { + var tex = new Text(code); + var result = new StringBuilder(); + for (int i = 0; i < tex.Length; i++) + { + switch (tex[i]) + { + case "#" when tex[i + 1] == "#": + result.Append('#'); + i++; + break; + case "#" when tex[i + 1] is { Length: 1 } digit && digit[0] is >= '1' and <= '9' && digit[0] - '1' < arguments.Count: + result.Append(arguments[digit[0] - '1']); + i++; + break; + default: + result.Append(tex[i]); + break; + } + } + return result.ToString(); + } + + /// A part of an expansion appended, after a space if it would continue the control word before it (\alpha x). + private static void Join(StringBuilder expansion, string part) + { + if (ContinuesControlWord(expansion, part)) + expansion.Append(' '); + expansion.Append(part); + } + + private static bool ContinuesControlWord(StringBuilder before, string after) + { + var text = new Text(before.ToString()); + int i = text.Length; + while (i > 0 && IsAsciiAlphabetic(text[i - 1])) + i--; + return i < text.Length && text[i - 1] == "\\" && IsAsciiAlphabetic(new Text(after)[0]); + } + private string Convert(string latex) { - var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(latex)))); + var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(NewEnvironments(latex))))); var cwPrev = ""; bool needMacroPass = false; var uniTeX = new StringBuilder(); diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index be1d89d..9dede48 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -332,6 +332,27 @@ public async Task ExpandsMacrosOfTheOptions() await Assert.That(Equation.Parse(@"\half x\in\R", EquationFormat.LaTeX, options).ToString(EquationFormat.LaTeX)).IsEqualTo(@"\frac{1}{2}x\in\mathbb{R}"); } + [Test] + [Arguments(@"\newenvironment{wrapper}{a +}{+ c} \begin{wrapper} b \end{wrapper}", @"a+b+c")] + [Arguments(@"\newenvironment{paren}[1]{\left( #1 +}{\right)} \begin{paren}{x} y \end{paren}", @"\left(x+y\right)")] + [Arguments(@"\newenvironment{e}[2][1]{#1+#2}{} \begin{e}{a} b\end{e}", @"1+ab")] + [Arguments(@"\newenvironment{e}[2][1]{#1+#2}{} \begin{e}[2]{a}\end{e}", @"2+a")] + [Arguments(@"\newenvironment{g}{(}{)} \begin{g} a \begin{g} b \end{g} \end{g}", @"(a(b))")] + [Arguments(@"\newenvironment{al}{\alpha}{\beta}\begin{al}x\end{al}y", @"\alpha x\beta y")] + [Arguments(@"\newenvironment{e}{a}{} \renewenvironment{e}{b}{} \begin{e}\end{e}", @"b")] + [Arguments(@"\newenvironment{m}{\begin{pmatrix}}{\end{pmatrix}} \begin{m} 1 & 2 \end{m}", @"\begin{pmatrix}1&2\end{pmatrix}")] + [Arguments(@"\newenvironment{h}{\newcommand{\f}[1]{##1^2}}{} \begin{h}\f{x}\end{h}", @"x^2")] + public async Task ExpandsEnvironments(string latex, string equivalent) + { + await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX)).IsEqualTo(Equation.Parse(equivalent, EquationFormat.LaTeX)); + } + + [Test] + public async Task RejectsEnvironmentsThatExpandForever() + { + await Assert.That(Equation.TryParse(@"\newenvironment{r}{\begin{r}}{\end{r}} \begin{r} x \end{r}", EquationFormat.LaTeX, out _)).IsFalse(); + } + [Test] [Arguments(@"\foo x", @"Unknown control word \foox in the LaTeX.")] [Arguments(@"a+\undefinedcommand", @"Unknown control word \undefinedcommand in the LaTeX.")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index bca3f97..4a5878e 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -198,13 +198,11 @@ private static string Canonical(Equation equation) // 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); \middle between empty delimiters and bars with nothing between them (|\,|); \newenvironment - // and ## in a macro + // words (lim inf); \middle between empty delimiters and bars with nothing between them (|\,|) private static readonly HashSet NotReadYet = new HashSet { "lg", "liminf", "limsup", "injlim", "projlim", "varinjlim", "operatornamewithlimits", "operatorname-star", "middle", "spaces", - "newenvironment-multi-node", "newcommand-escaped-hash", }; // the same equation, which SharpEquations holds otherwise: an equation number as the label of its row (\tag), @@ -220,17 +218,17 @@ private static string Canonical(Equation equation) "textsf", "texttt", "emph", }; - // what is no LaTeX: \e, which no package defines, and ntext, a mistyped \text - private static readonly HashSet NotLaTeX = new HashSet { "backslash-e", "issue-125-1-overset" }; + // what is no LaTeX: \e, which no package defines, ntext, a mistyped \text, and a # in math mode (## in the body of + // 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)); // the macros the reader does not expand as LaTeX does: \DeclareMathOperator, the default of an optional argument, - // an empty body (which leaves an empty group), \newenvironment + // 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", "newenvironment", - "newenvironment-with-args", + "declare-math-operator", "newcommand-optional-default", "newcommand-empty-body", }; private static string? Check(Func check)