Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions src/SharpEquations/Internal/LaTeX/LaTeXReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private LaTeXReader(IDictionary<string, string> macros, bool display)

/// <summary>
/// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def
/// or \newcommand go to <paramref name="macros"/>, where later conversions find them. In a display equation the
/// or \newcommand, and environments defined with \newenvironment, go to <paramref name="macros"/>, 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.
/// </summary>
Expand Down Expand Up @@ -1161,9 +1161,145 @@ private static (string Text, int Next) BracedArgument(Text tex, int i)
return (null, -1);
}

/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>The [n][default]{begin}{end} of an environment definition at the index; Next -1 if there is none.</summary>
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);
}

/// <summary>The arguments of an environment at the index (the first one optional when it has a default); Next -1 if missing.</summary>
private static (List<string> Arguments, int Next) EnvironmentArguments(Text tex, int i, int count, string optional)
{
var arguments = new List<string>();
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);
}

/// <summary>The index of the \end{name} that closes an environment whose body starts at the index, and the index after it; -1 if none.</summary>
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);
}

/// <summary>The begin code of an environment with its #1... replaced by the arguments, and ## by #.</summary>
private static string Substitute(string code, List<string> 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();
}

/// <summary>A part of an expansion appended, after a space if it would continue the control word before it (\alpha x).</summary>
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();
Expand Down
21 changes: 21 additions & 0 deletions tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.")]
Expand Down
14 changes: 6 additions & 8 deletions tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> NotReadYet = new HashSet<string>
{
"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),
Expand All @@ -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<string> NotLaTeX = new HashSet<string> { "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<string> NotLaTeX = new HashSet<string> { "backslash-e", "issue-125-1-overset", "newcommand-escaped-hash" };

private static readonly HashSet<string> ReadOtherwise = new HashSet<string>(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<string> NotExpanded = new HashSet<string>
{
"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<string?> check)
Expand Down
Loading