diff --git a/AGENTS.md b/AGENTS.md
index 6f22b77..f546913 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,7 +55,8 @@ init-only record: `Display`, `Strict`, `PreserveFormatting`, `LaTeXFlavor` stand
`menclose notation="bottom"`. The default intents (`:fenced`, `:function`, `absolute-value`,
`binomial-coefficient`, the matrices...) are not kept, since they would make an equation differ by how it was
written (`\binom`, `⒞`, `(¦)`); `DefaultIntents.Restore` infers them again from the structure for the LaTeX and
- UnicodeMath writers.
+ UnicodeMath writers. Content MathML of the source (`apply`, `ci`, `cn`..., outside the annotations) is first rewritten
+ as presentation MathML by `ctop.xsl` (David Carlisle's, see `NOTICE`), keeping the presentation MathML around it.
- `Internal/Omml/`: OMML ↔ MathML with the XSLT 1.0 stylesheets of `Resources/` (from the TEI Stylesheets, see
`NOTICE`), run with `XslCompiledTransform`. OMML is written as Word writes it (plain letters with `m:sty`/`m:scr`,
underbars as `m:bar`), and an upright word of OMML (`max`) is read as one identifier.
diff --git a/NOTICE b/NOTICE
index 5e2c4be..47c6701 100644
--- a/NOTICE
+++ b/NOTICE
@@ -183,6 +183,38 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+----------------------------------------------------------------------------
+src/SharpEquations/Resources/ctop.xsl
+tests/data/contentmathml/ctop-*.mml
+
+From https://github.com/davidcarlisle/web-xslt (ctop/ctop.xsl), the XSLT 1.0
+stylesheet of Content MathML to presentation MathML, and its test equations
+(ctop/tests/ctopxslt.html). The web-xslt collection is distributed under the MIT
+licence; ctop.xsl also under the W3C Software Notice and License, Apache 2,
+MPL 1.1 or MPL 2.0. SharpEquations uses them under the MIT licence, the stylesheet
+modified as described in its header:
+
+Copyright David Carlisle 2001, 2002, 2008, 2009, 2013.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
----------------------------------------------------------------------------
tests/data/latex/latex2mathml-*.json
@@ -238,3 +270,33 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----------------------------------------------------------------------------
+src/SharpEquations/Internal/LaTeX/Mhchem.cs
+tests/data/latex/treeblood-mhchem.json
+
+The rules of mhchem's \ce in Mhchem.cs follow mhchem.go of https://github.com/Wyatt915/treeblood,
+rewritten in C# to write LaTeX rather than MathML; the test cases are those of its test/TESTCASES.yaml:
+
+MIT License
+
+Copyright (c) 2024 Wyatt Sheffield
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/README.md b/README.md
index 01bdc54..86e54f5 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ format) and LaTeX, in .NET, with MathML as the common representation.
| Format | Read | Write |
|---|---|---|
-| MathML | ✓ | ✓ |
+| MathML (presentation; Content MathML is read as the presentation it is written as) | ✓ | ✓ |
| OMML (Office Math Markup Language, the equations of `.docx`) | ✓ | ✓ |
| MTEF (MathType, Equation Editor 3.0) | ✓ | |
| UnicodeMath | ✓ | ✓ |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5fb83e5..9cfba58 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -135,6 +135,12 @@ also regroups an n-ary operation or a function application that normalization fl
### MathML (`Internal/MathML/`)
`ParseXml` reads the XML with DTDs prohibited and no resolver (no external entities), the undeclared prefixes `mml:`, `m:` and `w:` known; in strict mode `Validate` accepts presentation MathML only.
+Content MathML (`x1`), alone or inside presentation MathML, is rewritten
+first as the presentation MathML it is written as, by `ctop.xsl` (David Carlisle's stylesheet; the changes made
+to it are in its header): the white space between the elements and around the text
+of `ci` and `cn` goes before, and the limits it leaves empty (an integral without bounds) after. The Content MathML of
+an `annotation-xml` is left alone, and a `semantics` whose first child is Content MathML is read from its
+`MathML-Presentation` annotation when it has one. `tests/data/contentmathml` holds the test equations of ctop.
`MathMLSerializer` writes the normalized MathML as it is.
### OMML (`Internal/Omml/`)
diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs
index 2561710..30118c1 100644
--- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs
+++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs
@@ -37,12 +37,12 @@ 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.
+ /// puts them. The \ce and \pu of mhchem are rewritten as plain LaTeX first ().
///
public static string ToUnicodeMath(string tex, IDictionary macros = null, bool display = false) =>
- RestoreTextEscapes(new LaTeXReader(macros, display).Convert(tex));
+ RestoreTextEscapes(new LaTeXReader(macros, display).Convert(Mhchem.Rewrite(tex)));
// the characters that \text escapes ({, \_, ...), held as noncharacters through the passes, which would read them as LaTeX
private static readonly (string Escape, char Held, char Character)[] TextEscapes =
@@ -370,11 +370,16 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i)
{
["dfrac"] = "\\frac", ["tfrac"] = "\\frac", ["cfrac"] = "\\frac", ["dbinom"] = "\\binom", ["tbinom"] = "\\binom",
["overrightarrow"] = "\\vec", ["Vert"] = "‖", ["lVert"] = "‖", ["rVert"] = "‖", ["bmod"] = "\\operatorname{mod}",
- ["bm"] = "\\boldsymbol", ["square"] = "\\□", ["Box"] = "\\□",
- // the symbols of amsmath, amssymb and plain TeX that UnicodeMath has no control word for
+ ["bm"] = "\\boldsymbol",
+ // the symbols of LaTeX, amsmath, amssymb and plain TeX that UnicodeMath has no control word for (the others of
+ // LaTeXSymbols are read from it), and \square, \Box, \blacksquare, \surd and \oslash, which are symbols in LaTeX
+ // and build a box, a root or a fraction in UnicodeMath
+ ["oslash"] = "\\⊘", ["yen"] = "¥", ["circledR"] = "®", ["S"] = "§", ["P"] = "¶", ["mho"] = "℧", ["rightsquigarrow"] = "↝",
+ ["leftrightsquigarrow"] = "↭", ["measuredangle"] = "∡", ["sphericalangle"] = "∢", ["eqsim"] = "≂", ["doteqdot"] = "≑",
+ ["triangleq"] = "≜", ["square"] = "\\□", ["Box"] = "\\□", ["blacksquare"] = "\\■", ["surd"] = "{\\surd}",
["lt"] = "<", ["gt"] = ">", ["dotsb"] = "⋯", ["dotsi"] = "⋯", ["dotsm"] = "⋯", ["dotsc"] = "…", ["dotso"] = "…",
["ldotp"] = ".", ["gggtr"] = "⋙", ["llless"] = "⋘", ["gvertneqq"] = "≩", ["lvertneqq"] = "≨", ["ngeqq"] = "≱",
- ["nshortmid"] = "∤", ["nshortparallel"] = "∦", ["nsubseteqq"] = "⊈", ["omicron"] = "ο", ["S"] = "§", ["shortmid"] = "∣",
+ ["nshortmid"] = "∤", ["nshortparallel"] = "∦", ["nsubseteqq"] = "⊈", ["omicron"] = "ο", ["shortmid"] = "∣",
["smallfrown"] = "⌢", ["smallsmile"] = "⌣", ["thicksim"] = "∼", ["thickapprox"] = "≈", ["varsubsetneqq"] = "⫋",
["varsupsetneq"] = "⊋", ["varsupsetneqq"] = "⫌", ["bigcirc"] = "\\◯", ["centerdot"] = "⬝", ["degree"] = "°",
["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\╱", ["diagdown"] = "\\╲", ["thickspace"] = "\\;",
@@ -382,6 +387,11 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i)
["mathscr"] = "\\mathcal", ["operatornamewithlimits"] = "\\operatorname", ["mathstrut"] = "\\vphantom{\\(}",
};
+ private static readonly HashSet OpeningBrackets = new HashSet(StringComparer.Ordinal) { "(", "[", "{", "⟨", "⌈", "⌊" };
+ private static readonly HashSet ClosingBrackets = new HashSet(StringComparer.Ordinal) { ")", "]", "}", "⟩", "⌉", "⌋" };
+ private static readonly HashSet OpeningBracketCommands = new HashSet(StringComparer.Ordinal) { "langle", "lceil", "lfloor", "lbrace" };
+ private static readonly HashSet ClosingBracketCommands = new HashSet(StringComparer.Ordinal) { "rangle", "rceil", "rfloor", "rbrace" };
+
// the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ)
private static readonly Dictionary Stacks = new Dictionary(StringComparer.Ordinal)
{
@@ -418,6 +428,8 @@ private static string RewriteTeXOnly(string latex)
var result = new StringBuilder();
// between ⟨ and ⟩ a bar parts a bra from a ket (│), as in UnicodeMath: ⟨a│b⟩
int angles = 0;
+ // in brackets \mid is the separator ∣ of UnicodeMath, {x∣x>0}; outside them the relation, escaped
+ int brackets = 0;
for (int i = 0; i < tex.Length;)
{
if (tex[i] == "|" && angles > 0)
@@ -428,6 +440,8 @@ private static string RewriteTeXOnly(string latex)
}
if (tex[i] == "⟨" && AngleCloses(tex, i + 1) || tex[i] == "⟩")
angles = Math.Max(0, angles + (tex[i] == "⟨" ? 1 : -1));
+ if (OpeningBrackets.Contains(tex[i]) || ClosingBrackets.Contains(tex[i]))
+ brackets = Math.Max(0, brackets + (OpeningBrackets.Contains(tex[i]) ? 1 : -1));
// a slash of LaTeX is a slash, (a+b)/2, where UnicodeMath would build a fraction: the escaped one of UnicodeMath
if (tex[i] == "/")
{
@@ -477,6 +491,8 @@ private static string RewriteTeXOnly(string latex)
int j = i + 1 + cw.Length;
if (cw.Length == 0)
{
+ if (tex[i + 1] == "{" || tex[i + 1] == "}")
+ brackets = Math.Max(0, brackets + (tex[i + 1] == "{" ? 1 : -1));
// \| is the double bar ‖, not an escaped bar
if (tex[i + 1] == "|")
{
@@ -498,6 +514,14 @@ private static string RewriteTeXOnly(string latex)
}
if (cw == "langle" && AngleCloses(tex, j) || cw == "rangle")
angles = Math.Max(0, angles + (cw == "langle" ? 1 : -1));
+ if (OpeningBracketCommands.Contains(cw) || ClosingBracketCommands.Contains(cw))
+ brackets = Math.Max(0, brackets + (OpeningBracketCommands.Contains(cw) ? 1 : -1));
+ if (cw == "mid" && brackets == 0)
+ {
+ result.Append("\\∣");
+ i = j;
+ continue;
+ }
if (cw == "middle")
{
// \middle| between \left and \right, the bar of UnicodeMath; UnicodeMath wants no spaces around the
@@ -648,6 +672,14 @@ private static string RewriteTeXOnly(string latex)
i = afterUnderBase;
continue;
}
+ if (cw is "overbrace" or "underbrace" && Argument(tex, j) is var (braced, afterBraced) && afterBraced >= 0 &&
+ Scripts(tex, afterBraced) is var (braceUnder, braceOver, afterBraceScripts) && braceUnder != null && braceOver != null)
+ {
+ // \overbrace3^a_x: for TeX the brace is an operator with limits, both scripts under and over the whole construct
+ result.Append(RewriteTeXOnly("\\underset{" + braceUnder + "}{\\overset{" + braceOver + "}{\\" + cw + "{" + braced + "}}}"));
+ i = afterBraceScripts;
+ continue;
+ }
if (cw == "underset" && Arguments(tex, j, 2) is var (underArguments, afterUnderset) &&
new Text(underArguments[1].Trim()) is var overText && overText.Find("\\overset", 0) == 0 &&
Arguments(overText, 8, 2) is var (overArguments, afterOverset) && afterOverset == overText.Length)
@@ -686,6 +718,19 @@ private static string RewriteTeXOnly(string latex)
i = afterSideset;
continue;
}
+ if (cw == "prescript" && Arguments(tex, j, 3) is var (prescript, afterPrescript))
+ {
+ // \prescript{sup}{sub}{base} of mathtools: the scripts at the start of the group of their base, where
+ // UnicodeMath reads them as prescripts
+ result.Append('{');
+ if (prescript[0].Trim().Length > 0)
+ result.Append("^{").Append(RewriteTeXOnly(prescript[0])).Append('}');
+ if (prescript[1].Trim().Length > 0)
+ result.Append("_{").Append(RewriteTeXOnly(prescript[1])).Append('}');
+ result.Append(RewriteTeXOnly(prescript[2])).Append('}');
+ i = afterPrescript;
+ continue;
+ }
if (cw == "pod" && Argument(tex, j) is var (pod, afterPod) && afterPod >= 0)
{
result.Append("\\quad(").Append(RewriteTeXOnly(pod)).Append(')');
@@ -718,8 +763,14 @@ private static string RewriteTeXOnly(string latex)
}
if (cw == "mathop" && BracedArgument(tex, j) is var (operatorText, afterOperator) && afterOperator >= 0)
{
- // \mathop{\mathrm{argmin}}: an operator name
+ // \mathop{\mathrm{argmin}}: an operator name; \mathop{x}, of one symbol, a group, which takes the limits
var name = operatorText.Trim();
+ if (Js.Len(name) == 1)
+ {
+ result.Append('{').Append(RewriteTeXOnly(name)).Append('}');
+ i = afterOperator;
+ continue;
+ }
if (name.StartsWith("\\mathrm{", StringComparison.Ordinal) && name[name.Length - 1] == '}')
name = name.Substring(8, name.Length - 9);
result.Append(name.All(char.IsLetter) ? "\\operatorname{" + name + "}" : RewriteTeXOnly(operatorText));
@@ -1147,9 +1198,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();
@@ -1227,6 +1414,10 @@ private string Convert(string latex)
else
{
symbol = UnicodeMathInput.ResolveControlWords("\\" + cw);
+ // the symbols of LaTeX and amssymb that UnicodeMath has no control word for, or another one
+ // (\succeq is ⪰, ≽ in UnicodeMath), as the writer writes them
+ if (!IsFunctionName(cw) && LaTeXSymbols.TryGetCharacter(cw, out var character) && LaTeXSymbols.Math(symbol).TrimEnd() != "\\" + cw)
+ symbol = character;
if (tex[i] == " " && !IsFunctionName(cw))
i++;
// a function name with limits, as Word writes it (\lim _{x→0}): the space would end the name
diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs
index 8cc2c6f..99f4ecd 100644
--- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs
+++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs
@@ -65,7 +65,25 @@ internal static partial class LaTeXSymbols
["↩"] = "hookleftarrow", ["↪"] = "hookrightarrow", ["⇌"] = "rightleftharpoons", ["↼"] = "leftharpoonup", ["⇀"] = "rightharpoonup",
// delimiters
["⟨"] = "langle", ["⟩"] = "rangle", ["⌈"] = "lceil", ["⌉"] = "rceil", ["⌊"] = "lfloor", ["⌋"] = "rfloor", ["‖"] = "|",
- ["{"] = "{", ["}"] = "}", ["〈"] = "langle", ["〉"] = "rangle",
+ ["{"] = "{", ["}"] = "}", ["〈"] = "langle", ["〉"] = "rangle", ["〈"] = "langle", ["〉"] = "rangle",
+ // the symbols of amssymb, from the table of entities.xsl of the XSLT MathML Library (Vasil Yaroshevich)
+ ["¥"] = "yen", ["®"] = "circledR", ["§"] = "S", ["¶"] = "P", ["℧"] = "mho", ["ℶ"] = "beth", ["ℷ"] = "gimel", ["ℸ"] = "daleth",
+ ["∁"] = "complement", ["∡"] = "measuredangle", ["∢"] = "sphericalangle", ["■"] = "blacksquare", ["√"] = "surd",
+ ["↚"] = "nleftarrow", ["↛"] = "nrightarrow", ["↮"] = "nleftrightarrow", ["⇍"] = "nLeftarrow", ["⇏"] = "nRightarrow",
+ ["⇎"] = "nLeftrightarrow", ["↝"] = "rightsquigarrow", ["↭"] = "leftrightsquigarrow", ["↞"] = "twoheadleftarrow",
+ ["↠"] = "twoheadrightarrow", ["↢"] = "leftarrowtail", ["↣"] = "rightarrowtail", ["↫"] = "looparrowleft", ["↬"] = "looparrowright",
+ ["↰"] = "Lsh", ["↱"] = "Rsh", ["↶"] = "curvearrowleft", ["↷"] = "curvearrowright", ["↺"] = "circlearrowleft",
+ ["↻"] = "circlearrowright", ["↽"] = "leftharpoondown", ["⇁"] = "rightharpoondown", ["↾"] = "upharpoonright",
+ ["↿"] = "upharpoonleft", ["⇂"] = "downharpoonright", ["⇃"] = "downharpoonleft", ["⇄"] = "rightleftarrows",
+ ["⇆"] = "leftrightarrows", ["⇇"] = "leftleftarrows", ["⇈"] = "upuparrows", ["⇉"] = "rightrightarrows",
+ ["⇊"] = "downdownarrows", ["⇋"] = "leftrightharpoons", ["⇕"] = "Updownarrow", ["⇚"] = "Lleftarrow", ["⇛"] = "Rrightarrow",
+ ["∔"] = "dotplus", ["⊟"] = "boxminus", ["⋇"] = "divideontimes", ["⋈"] = "bowtie",
+ ["∌"] = "not\\ni", ["≄"] = "not\\simeq", ["≭"] = "not\\asymp", ["⊅"] = "not\\supset", ["≸"] = "not\\lessgtr", ["≹"] = "not\\gtrless",
+ ["∽"] = "backsim", ["⋍"] = "backsimeq", ["≂"] = "eqsim", ["≊"] = "approxeq", ["≎"] = "Bumpeq", ["≏"] = "bumpeq",
+ ["≑"] = "doteqdot", ["≒"] = "fallingdotseq", ["≓"] = "risingdotseq", ["≖"] = "eqcirc", ["≗"] = "circeq", ["≜"] = "triangleq",
+ ["≦"] = "leqq", ["≧"] = "geqq", ["≨"] = "lneqq", ["≩"] = "gneqq", ["≬"] = "between", ["≶"] = "lessgtr", ["≷"] = "gtrless",
+ ["≼"] = "preccurlyeq", ["≽"] = "succcurlyeq", ["≾"] = "precsim", ["≿"] = "succsim", ["⊀"] = "nprec", ["⊁"] = "nsucc",
+ ["⊪"] = "Vvdash",
};
private static readonly Dictionary FontCommands = new Dictionary
@@ -108,6 +126,20 @@ public static bool TryGetPlain(string ch, out string style, out string plain)
return found;
}
+ // command → a character written as it, for the reader (several characters can share a command: ° and ∘ are \circ)
+ private static readonly Dictionary Characters = Commands
+ .GroupBy(entry => entry.Value, entry => entry.Key)
+ .ToDictionary(group => group.Key, group => group.First());
+
+ /// The characters written as commands, with their commands (α: alpha, ≤: le).
+ public static IReadOnlyDictionary CharacterCommands => Commands;
+
+ /// A character the command is written for (geqslant: ⩾).
+ public static bool TryGetCharacter(string command, out string ch) => Characters.TryGetValue(command, out ch);
+
+ /// Whether a character is written as a command (α as \alpha, □ as \square).
+ public static bool IsCommand(string ch) => Commands.ContainsKey(ch);
+
/// The LaTeX of a run of text in math: each character as a letter, a command or itself.
public static string Math(string text)
{
diff --git a/src/SharpEquations/Internal/LaTeX/Mhchem.cs b/src/SharpEquations/Internal/LaTeX/Mhchem.cs
new file mode 100644
index 0000000..e447a7b
--- /dev/null
+++ b/src/SharpEquations/Internal/LaTeX/Mhchem.cs
@@ -0,0 +1,872 @@
+namespace SharpEquations.Internal.LaTeX
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Text;
+ using static SharpEquations.Internal.UnicodeMath.UnicodeMathText;
+
+ ///
+ /// The \ce and \pu commands of the mhchem package, rewritten as plain LaTeX before the LaTeX is read: upright
+ /// element symbols with their counts and charges as scripts, mass and atomic numbers as prescripts, reaction arrows
+ /// with their labels over and under them, bonds, states and hydrate dots. The rules for \ce are those of mhchem.go
+ /// of treeblood (https://github.com/Wyatt915/treeblood, Copyright (c) 2024 Wyatt Sheffield, MIT License), which
+ /// writes MathML, here writing LaTeX. Text is a list of code points.
+ ///
+ internal static class Mhchem
+ {
+ private enum Kind
+ {
+ Letter,
+ Digits,
+ Space,
+ Command,
+ Group,
+ Math,
+ Open,
+ Close,
+ Sub,
+ Sup,
+ Symbol,
+ }
+
+ private enum State
+ {
+ Start,
+ Coefficient,
+ Species,
+ Subscript,
+ Group,
+ Symbol,
+ }
+
+ private sealed class Token
+ {
+ public Token(Kind kind, string value)
+ {
+ Kind = kind;
+ Value = value;
+ }
+
+ public Kind Kind { get; }
+
+ /// The text of the token; the text inside the braces or the dollars of a group or of math.
+ public string Value { get; }
+
+ /// For an opening bracket, how many tokens after it the closing one is; 0 if it has none.
+ public int Offset { get; set; }
+ }
+
+ /// A species: its name with the scripts of its count and charge, and the prescripts of its mass and atomic number.
+ private sealed class Atom
+ {
+ public string Name;
+ public string Count;
+ public string Charge;
+ public string Mass;
+ public string Z;
+
+ public bool OnlyPrescripts => Name == null && Count == null && Charge == null;
+
+ public string ToLaTeX()
+ {
+ var latex = Name;
+ if (Count != null)
+ latex += "_{" + Count + "}";
+ if (Charge != null)
+ latex += "^{" + Charge + "}";
+ if (Mass != null || Z != null)
+ latex = "\\prescript{" + Mass + "}{" + Z + "}{" + latex + "}";
+ return latex;
+ }
+ }
+
+ /// The LaTeX with its \ce{...} and \pu{...} rewritten as plain LaTeX; the LaTeX itself if it has none.
+ public static string Rewrite(string latex)
+ {
+ if (latex == null || latex.IndexOf("\\ce", StringComparison.Ordinal) < 0 && latex.IndexOf("\\pu", StringComparison.Ordinal) < 0)
+ return latex;
+ var tex = Characters(latex);
+ var result = new StringBuilder();
+ for (int i = 0; i < tex.Count;)
+ {
+ if (tex[i] != "\\")
+ {
+ result.Append(tex[i++]);
+ continue;
+ }
+ int j = i + 1;
+ while (j < tex.Count && IsAsciiLetter(tex[j]))
+ j++;
+ if (j == i + 1)
+ {
+ // a control symbol, \\ or \{: the backslash and the character it escapes
+ result.Append(tex[i++]);
+ if (i < tex.Count)
+ result.Append(tex[i++]);
+ continue;
+ }
+ var name = Concat(tex, i + 1, j);
+ int k = j;
+ while (k < tex.Count && tex[k] == " ")
+ k++;
+ if (name is "ce" or "pu" && k < tex.Count && tex[k] == "{")
+ {
+ int end = ClosingBrace(tex, k);
+ var argument = Concat(tex, k + 1, end);
+ var rewritten = name == "ce" ? Chemistry(argument) : Units(argument);
+ i = end + 1;
+ int after = i;
+ while (after < tex.Count && tex[after] == " ")
+ after++;
+ // a script after \ce is the script of the whole formula
+ if (rewritten.Length > 0 && after < tex.Count && tex[after] is "^" or "_")
+ rewritten = "{" + rewritten + "}";
+ result.Append(rewritten);
+ if (rewritten.Length > 0 && IsAsciiLetter(rewritten[rewritten.Length - 1].ToString()) && i < tex.Count && IsAsciiLetter(tex[i]))
+ result.Append(' ');
+ continue;
+ }
+ result.Append(Concat(tex, i, j));
+ i = j;
+ }
+ return result.ToString();
+ }
+
+ /// The plain LaTeX of the argument of \ce.
+ public static string Chemistry(string formula) => Formula.Write(Tokenize(formula), false);
+
+ private static bool IsAsciiLetter(string ch) => ch != null && ch.Length == 1 && (ch[0] >= 'A' && ch[0] <= 'Z' || ch[0] >= 'a' && ch[0] <= 'z');
+
+ private static bool IsDigit(string ch) => ch.Length == 1 && ch[0] >= '0' && ch[0] <= '9';
+
+ private static string Concat(List chars, int start, int end) => string.Concat(chars.Skip(start).Take(end - start));
+
+ /// The index of the brace that closes the one at the index.
+ private static int ClosingBrace(List chars, int open)
+ {
+ int depth = 0;
+ for (int i = open; i < chars.Count; i++)
+ {
+ switch (chars[i])
+ {
+ case "\\":
+ i++;
+ break;
+ case "{":
+ depth++;
+ break;
+ case "}" when --depth == 0:
+ return i;
+ }
+ }
+ throw new FormatException("A brace of the chemical formula is not closed.");
+ }
+
+ private static List Tokenize(string text)
+ {
+ var chars = Characters(text);
+ var tokens = new List();
+ for (int i = 0; i < chars.Count;)
+ {
+ var ch = chars[i];
+ int j = i + 1;
+ switch (ch)
+ {
+ case "\\":
+ while (j < chars.Count && IsAsciiLetter(chars[j]))
+ j++;
+ if (j == i + 1 && j < chars.Count)
+ {
+ j++;
+ var escaped = chars[i + 1];
+ tokens.Add(new Token(escaped == "{" ? Kind.Open : escaped == "}" ? Kind.Close : Kind.Command, "\\" + escaped));
+ }
+ else
+ {
+ tokens.Add(new Token(j == i + 1 ? Kind.Symbol : Kind.Command, Concat(chars, i, j)));
+ }
+ break;
+ case "{":
+ j = ClosingBrace(chars, i);
+ tokens.Add(new Token(Kind.Group, Concat(chars, i + 1, j)));
+ j++;
+ break;
+ case "}":
+ throw new FormatException("A brace of the chemical formula closes nothing.");
+ case "$":
+ while (j < chars.Count && chars[j] != "$")
+ j += chars[j] == "\\" ? 2 : 1;
+ if (j >= chars.Count)
+ throw new FormatException("The math of the chemical formula has no closing $.");
+ tokens.Add(new Token(Kind.Math, Concat(chars, i + 1, j)));
+ j++;
+ break;
+ case "(" or "[":
+ tokens.Add(new Token(Kind.Open, ch));
+ break;
+ case ")" or "]":
+ tokens.Add(new Token(Kind.Close, ch));
+ break;
+ case "^":
+ tokens.Add(new Token(Kind.Sup, ch));
+ break;
+ case "_":
+ tokens.Add(new Token(Kind.Sub, ch));
+ break;
+ case var _ when string.IsNullOrWhiteSpace(ch):
+ while (j < chars.Count && string.IsNullOrWhiteSpace(chars[j]))
+ j++;
+ tokens.Add(new Token(Kind.Space, " "));
+ break;
+ case var _ when IsDigit(ch):
+ while (j < chars.Count && IsDigit(chars[j]))
+ j++;
+ tokens.Add(new Token(Kind.Digits, Concat(chars, i, j)));
+ break;
+ case var _ when char.IsLetter(ch, 0):
+ tokens.Add(new Token(Kind.Letter, ch));
+ break;
+ default:
+ tokens.Add(new Token(Kind.Symbol, ch));
+ break;
+ }
+ i = j;
+ }
+
+ var open = new Stack();
+ for (int i = 0; i < tokens.Count; i++)
+ {
+ switch (tokens[i].Kind)
+ {
+ case Kind.Open:
+ open.Push(i);
+ break;
+ case Kind.Close when open.Count > 0 && Closing(tokens[open.Peek()].Value) == tokens[i].Value:
+ int at = open.Pop();
+ tokens[at].Offset = i - at;
+ break;
+ }
+ }
+ return tokens;
+ }
+
+ private static string Closing(string open) => open switch
+ {
+ "(" => ")",
+ "[" => "]",
+ _ => "\\}",
+ };
+
+ /// Text with math between dollars, as \text and the math.
+ private static string TextWithMath(string text)
+ {
+ var chars = Characters(text);
+ var result = new StringBuilder();
+ for (int i = 0; i < chars.Count;)
+ {
+ int j = i;
+ while (j < chars.Count && chars[j] != "$")
+ j += chars[j] == "\\" ? 2 : 1;
+ if (j > i)
+ result.Append("\\text{").Append(Concat(chars, i, j)).Append('}');
+ if (j >= chars.Count)
+ break;
+ int k = j + 1;
+ while (k < chars.Count && chars[k] != "$")
+ k += chars[k] == "\\" ? 2 : 1;
+ if (k >= chars.Count)
+ throw new FormatException("The math of the chemical formula has no closing $.");
+ result.Append(Math(Concat(chars, j + 1, k)));
+ i = k + 1;
+ }
+ return result.ToString();
+ }
+
+ /// Math inside a formula: LaTeX, where \ce starts a formula again.
+ private static string Math(string latex) => latex.Trim().Length == 0 ? "" : "{" + Rewrite(latex) + "}";
+
+ private static string Bond(string bond) => bond.Trim() switch
+ {
+ "1" or "-" => "-",
+ "2" or "=" => "=",
+ "3" or "#" => "\\equiv",
+ "~" or "..." => "\\cdots",
+ "...." => "{\\cdot}{\\cdot}{\\cdot}{\\cdot}",
+ "~-" => "\\overset{\\cdots}{-}",
+ "~--" or "~=" => "\\overset{\\cdots}{=}",
+ "-~-" => "\\overset{-}{\\underset{-}{\\cdots}}",
+ "->" => "\\rightarrow",
+ "<-" => "\\leftarrow",
+ _ => throw new FormatException("Unknown chemical bond \\bond{" + bond + "}."),
+ };
+
+ /// The arguments the commands that take some take, which mhchem passes on.
+ private static int Arity(string command) => command switch
+ {
+ "frac" or "dfrac" or "tfrac" or "cfrac" or "binom" or "overset" or "underset" or "stackrel" => 2,
+ "text" or "textrm" or "textup" or "textnormal" or "textit" or "textbf" or "textsf" or "texttt" or "mbox" or "mathrm"
+ or "mathit" or "mathbf" or "mathsf" or "mathtt" or "mathcal" or "mathbb" or "mathfrak" or "mathscr" or "boldsymbol"
+ or "operatorname" or "sqrt" or "overline" or "underline" or "hat" or "bar" or "vec" or "dot" or "ddot" or "tilde"
+ or "widehat" or "widetilde" or "overbrace" or "underbrace" or "boxed" or "phantom" or "hphantom" or "vphantom"
+ or "ce" or "pu" or "bond" => 1,
+ _ => 0,
+ };
+
+ private static bool IsTextCommand(string command) => command.StartsWith("text", StringComparison.Ordinal) ||
+ command.StartsWith("math", StringComparison.Ordinal) || command is "mbox" or "operatorname" or "boldsymbol";
+
+ /// The ends of a LaTeX string written one after another, with a blank after a control word before a letter.
+ private static string Join(IEnumerable parts)
+ {
+ var result = new StringBuilder();
+ foreach (var part in parts)
+ {
+ if (part.Length == 0)
+ continue;
+ if (IsAsciiLetter(part[0].ToString()) && EndsWithControlWord(result))
+ result.Append(' ');
+ result.Append(part);
+ }
+ return result.ToString();
+ }
+
+ private static bool EndsWithControlWord(StringBuilder latex)
+ {
+ int i = latex.Length - 1;
+ while (i >= 0 && IsAsciiLetter(latex[i].ToString()))
+ i--;
+ return i < latex.Length - 1 && i >= 0 && latex[i] == '\\';
+ }
+
+ /// One formula of \ce, or the script of one, written as LaTeX.
+ private sealed class Formula
+ {
+ private readonly List tokens;
+ private readonly bool script;
+ private readonly List result = new List();
+ private Atom atom;
+ private State state = State.Start;
+ private Kind? promoted;
+ private int i;
+
+ private Formula(List tokens, bool script)
+ {
+ this.tokens = tokens;
+ this.script = script;
+ }
+
+ public static string Write(List tokens, bool script) => new Formula(tokens, script).Write();
+
+ private Token Peek(int ahead = 0) => i + ahead < tokens.Count ? tokens[i + ahead] : null;
+
+ private string Write()
+ {
+ while (i < tokens.Count)
+ {
+ var t = tokens[i++];
+ var next = Peek();
+ if (promoted != null)
+ {
+ Script(t, next);
+ continue;
+ }
+ switch (t.Kind)
+ {
+ case Kind.Group:
+ Flush();
+ if (t.Value.Trim().Length > 0)
+ result.Add(TextWithMath(t.Value));
+ state = State.Group;
+ break;
+ case Kind.Math:
+ Flush();
+ result.Add(Math(t.Value));
+ break;
+ case Kind.Space:
+ Flush();
+ state = State.Start;
+ break;
+ case Kind.Sup when state == State.Start && (next == null || next.Kind == Kind.Space):
+ // a caret alone is a gas evolving
+ Flush();
+ result.Add("\\uparrow");
+ break;
+ case Kind.Sup or Kind.Sub:
+ promoted = t.Kind;
+ break;
+ case Kind.Command:
+ Command(t);
+ break;
+ case Kind.Open:
+ Open(t);
+ break;
+ case Kind.Letter:
+ Letters(t);
+ break;
+ case Kind.Digits:
+ Number(t);
+ break;
+ case Kind.Symbol:
+ Symbol(t, next);
+ break;
+ default:
+ Flush();
+ result.Add(t.Value);
+ state = State.Group;
+ break;
+ }
+ }
+ Flush();
+ if (promoted != null)
+ throw new FormatException("A script of the chemical formula is missing.");
+ return Join(result);
+ }
+
+ /// The script that a caret or an underscore has promoted the token to.
+ private void Script(Token t, Token next)
+ {
+ string scr;
+ switch (t.Kind)
+ {
+ case Kind.Symbol when t.Value == "-" && next?.Kind == Kind.Digits:
+ i++;
+ scr = "-" + next.Value;
+ break;
+ case Kind.Math:
+ scr = Rewrite(t.Value);
+ break;
+ case Kind.Group:
+ scr = Write(Tokenize(t.Value), true);
+ break;
+ case Kind.Space:
+ throw new FormatException("A script of the chemical formula is missing.");
+ default:
+ var single = new List { t };
+ for (int n = t.Kind == Kind.Command ? Arity(t.Value.Substring(1)) : 0; n > 0 && Peek()?.Kind == Kind.Group; n--)
+ single.Add(tokens[i++]);
+ scr = Write(single, true);
+ break;
+ }
+ var superscript = promoted == Kind.Sup;
+ promoted = null;
+ if (superscript)
+ Superscript(scr);
+ else
+ Subscript(scr);
+ }
+
+ private void Subscript(string scr)
+ {
+ if (atom == null)
+ atom = new Atom { Z = scr };
+ else if (atom.Z == null && atom.Name == null)
+ atom.Z = scr;
+ else if (atom.Count == null && atom.Name != null)
+ atom.Count = scr;
+ else
+ {
+ Flush();
+ atom = new Atom { Z = scr };
+ }
+ }
+
+ private void Superscript(string scr)
+ {
+ if (atom == null)
+ atom = new Atom { Mass = scr };
+ else if (atom.Mass == null && atom.Name == null)
+ atom.Mass = scr;
+ else if (atom.Charge == null && atom.Name != null)
+ atom.Charge = scr;
+ else
+ {
+ Flush();
+ atom = new Atom { Mass = scr };
+ }
+ }
+
+ /// Writes the current species; scripts without one go to what was written last.
+ private void Flush()
+ {
+ if (atom == null)
+ return;
+ if (atom.Name != null)
+ {
+ result.Add(atom.ToLaTeX());
+ }
+ else
+ {
+ var low = atom.Z ?? atom.Count;
+ var high = atom.Mass ?? atom.Charge;
+ var scripts = (low != null ? "_{" + low + "}" : "") + (high != null ? "^{" + high + "}" : "");
+ if (result.Count == 0)
+ result.Add("{}" + scripts);
+ else
+ result[result.Count - 1] = "{" + result[result.Count - 1] + "}" + scripts;
+ }
+ atom = null;
+ }
+
+ /// A new species, which takes the prescripts written before it.
+ private void Species(string name)
+ {
+ if (atom != null && atom.OnlyPrescripts)
+ {
+ atom.Name = name;
+ return;
+ }
+ Flush();
+ atom = new Atom { Name = name };
+ }
+
+ private string Argument()
+ {
+ int j = i;
+ while (j < tokens.Count && tokens[j].Kind == Kind.Space)
+ j++;
+ if (j >= tokens.Count || tokens[j].Kind != Kind.Group)
+ return null;
+ i = j + 1;
+ return tokens[j].Value;
+ }
+
+ private void Command(Token t)
+ {
+ var name = t.Value.Substring(1);
+ if (!name.All(c => IsAsciiLetter(c.ToString())))
+ {
+ // \\, \, and the other control symbols
+ Flush();
+ result.Add(t.Value);
+ state = State.Start;
+ return;
+ }
+ switch (name)
+ {
+ case "bond":
+ var bond = Argument() ?? throw new FormatException("\\bond has no argument.");
+ Flush();
+ result.Add(Bond(bond));
+ state = State.Start;
+ return;
+ case "ce":
+ Species(Write(Tokenize(Argument() ?? throw new FormatException("\\ce has no argument.")), script));
+ state = State.Species;
+ return;
+ case "pu":
+ Species(Units(Argument() ?? throw new FormatException("\\pu has no argument.")));
+ state = State.Species;
+ return;
+ case "left" or "right" or "middle":
+ Flush();
+ result.Add(t.Value + (i < tokens.Count ? tokens[i++].Value : ""));
+ state = State.Start;
+ return;
+ }
+ var latex = new StringBuilder(t.Value);
+ int arity = Arity(name);
+ for (int n = 0; n < arity; n++)
+ {
+ var argument = Argument();
+ if (argument == null)
+ break;
+ var rewritten = name is "frac" or "dfrac" or "tfrac" or "cfrac" ? Write(Tokenize(argument), script)
+ : IsTextCommand(name) ? argument : Rewrite(argument);
+ latex.Append('{').Append(rewritten).Append('}');
+ }
+ Species(latex.ToString());
+ state = arity == 0 ? State.Symbol : State.Species;
+ }
+
+ private void Open(Token t)
+ {
+ int open = i - 1;
+ if (script || t.Offset <= 0 || open + t.Offset >= tokens.Count)
+ {
+ Flush();
+ result.Add(t.Value);
+ return;
+ }
+ int close = open + t.Offset;
+ var inner = tokens.GetRange(open + 1, close - open - 1);
+ if (t.Value == "(" && inner.Count == 3 && inner[0].Kind == Kind.Digits && inner[1].Value == "/" && inner[2].Kind == Kind.Digits)
+ {
+ // (1/2)H2O: a coefficient in parentheses
+ Flush();
+ result.Add("(" + inner[0].Value + "/" + inner[2].Value + ")");
+ i = close + 1;
+ state = State.Coefficient;
+ return;
+ }
+
+ if (t.Value == "(" && inner.Count == 1 && state == State.Start && (inner[0].Value == "v" || inner[0].Kind == Kind.Sup))
+ {
+ // (v) a precipitate, (^) a gas
+ Flush();
+ result.Add(inner[0].Value == "v" ? "\\downarrow" : "\\uparrow");
+ i = close + 1;
+ return;
+ }
+ // a letter alone in parentheses is a state, (s), (l), (g)
+ var content = inner.Count == 1 && inner[0].Kind == Kind.Letter ? "\\mathrm{" + inner[0].Value + "}" : Write(inner, false);
+ Species(t.Value + content + tokens[close].Value);
+ i = close + 1;
+ state = State.Species;
+ }
+
+ private void Letters(Token t)
+ {
+ var name = t.Value;
+ if (script)
+ {
+ // in a script a letter alone is a variable, Fe^n+, letters together a word, Fe^{III}
+ while (Peek()?.Kind == Kind.Letter)
+ name += tokens[i++].Value;
+ Flush();
+ result.Add(name == t.Value ? name : "\\mathrm{" + name + "}");
+ return;
+ }
+ // an element: a letter and the lowercase letters after it
+ while (Peek()?.Kind == Kind.Letter && char.IsLower(Peek().Value, 0))
+ name += tokens[i++].Value;
+ if (name == t.Value && (Peek() == null || Peek().Kind == Kind.Space) && !(atom != null && atom.OnlyPrescripts))
+ {
+ // a letter alone: v a precipitate, a lowercase letter a variable
+ Flush();
+ result.Add(name == "v" ? "\\downarrow" : char.IsLower(name, 0) ? name : "\\mathrm{" + name + "}");
+ state = State.Start;
+ return;
+ }
+ Species("\\mathrm{" + name + "}");
+ state = State.Species;
+ }
+
+ private void Number(Token t)
+ {
+ var number = t.Value;
+ if (script)
+ {
+ result.Add(number);
+ return;
+ }
+ if (Peek()?.Value == "/" && Peek(1)?.Kind == Kind.Digits)
+ {
+ number = "\\frac{" + number + "}{" + Peek(1).Value + "}";
+ i += 2;
+ }
+ else if (state is State.Start or State.Coefficient && Peek()?.Value == "." && Peek(1)?.Kind == Kind.Digits)
+ {
+ number += "." + Peek(1).Value;
+ i += 2;
+ }
+ if (state is State.Start or State.Coefficient)
+ {
+ Flush();
+ result.Add(number);
+ state = State.Coefficient;
+ return;
+ }
+ // a count, which a point after does not continue: KCr(SO4)2.12H2O
+ if (atom != null)
+ atom.Count += number;
+ else if (result.Count > 0)
+ result[result.Count - 1] = "{" + result[result.Count - 1] + "}_{" + number + "}";
+ else
+ result.Add("{}_{" + number + "}");
+ state = State.Subscript;
+ }
+
+ private void Symbol(Token t, Token next)
+ {
+ switch (t.Value)
+ {
+ case "." or "*" when script:
+ // a radical dot
+ result.Add("\\bullet");
+ break;
+ case "." or "*":
+ // the dot of an adduct or a hydrate
+ Flush();
+ result.Add("\\cdot");
+ state = State.Start;
+ break;
+ case "#":
+ Flush();
+ result.Add("\\equiv");
+ state = State.Start;
+ break;
+ case "+":
+ if (state == State.Start || script)
+ {
+ Flush();
+ result.Add("+");
+ break;
+ }
+ atom ??= new Atom();
+ atom.Charge += "+";
+ break;
+ case "<":
+ Flush();
+ result.Add(Arrow() ?? "<");
+ state = State.Start;
+ break;
+ case "-" when next?.Kind == Kind.Symbol && next.Value == ">":
+ Flush();
+ result.Add(Arrow() ?? "-");
+ state = State.Start;
+ break;
+ case "-" when next != null && next.Kind != Kind.Space && next.Kind != Kind.Group:
+ // a bond, C6H5-CHO, or a hyphen after a symbol, \mu-Cl
+ Flush();
+ result.Add(state == State.Symbol ? "{-}" : "-");
+ state = State.Start;
+ break;
+ case "-" when atom != null && !script:
+ atom.Charge += "-";
+ Flush();
+ state = State.Start;
+ break;
+ case "-" when !script && state == State.Group && result.Count > 0 && (next == null || next.Kind == Kind.Space):
+ // a charge after a group: [AgCl2]-
+ Flush();
+ result[result.Count - 1] = "{" + result[result.Count - 1] + "}^{-}";
+ state = State.Start;
+ break;
+ default:
+ Flush();
+ result.Add(t.Value);
+ state = State.Start;
+ break;
+ }
+ }
+
+ /// The arrow that starts at the token before the index, with the labels in brackets after it; null if none does.
+ private string Arrow()
+ {
+ int start = i - 1;
+ int end = start;
+ while (end < tokens.Count && end - start < 4 && tokens[end].Kind == Kind.Symbol && tokens[end].Value is "-" or "=" or "<" or ">")
+ end++;
+ for (int length = end - start; length >= 2; length--)
+ {
+ var arrow = string.Concat(tokens.Skip(start).Take(length).Select(t => t.Value));
+ if (arrow is not ("->" or "<-" or "<->" or "<=>" or "<-->" or "<=>>" or "<<=>"))
+ continue;
+ i = start + length;
+ var above = Label();
+ var below = Label();
+ var latex = arrow switch
+ {
+ "->" => above != null ? "\\xrightarrow{" + above + "}" : "\\longrightarrow",
+ "<-" => above != null ? "\\xleftarrow{" + above + "}" : "\\longleftarrow",
+ _ => Over(above, arrow switch
+ {
+ "<->" => "\\longleftrightarrow",
+ "<=>" => "\\rightleftharpoons",
+ "<-->" => "\\rightleftarrows",
+ "<=>>" => "⥂",
+ _ => "⥄",
+ }),
+ };
+ return below != null ? "\\underset{" + below + "}{" + latex + "}" : latex;
+ }
+ return null;
+ }
+
+ private static string Over(string above, string arrow) => above != null ? "\\overset{" + above + "}{" + arrow + "}" : arrow;
+
+ /// The label in brackets at the index, a formula; null if there is none or it is empty.
+ private string Label()
+ {
+ var open = Peek();
+ if (open == null || open.Kind != Kind.Open || open.Value != "[" || open.Offset <= 0 || i + open.Offset >= tokens.Count)
+ return null;
+ var label = Write(tokens.GetRange(i + 1, open.Offset - 1), false);
+ i += open.Offset + 1;
+ return label.Length > 0 ? label : null;
+ }
+ }
+
+ ///
+ /// The plain LaTeX of the argument of \pu: a number, with its exponent as a power of ten, and its units upright,
+ /// a thin space between them, the digits after a unit as its power (mol-1).
+ ///
+ public static string Units(string quantity)
+ {
+ var tokens = Tokenize(quantity);
+ var parts = new List();
+ int i = 0;
+ Token Peek(int ahead = 0) => i + ahead < tokens.Count ? tokens[i + ahead] : null;
+ bool IsSign(Token t) => t?.Kind == Kind.Symbol && t.Value is "-" or "+" or "−";
+ string SignedDigits()
+ {
+ var sign = "";
+ if (IsSign(Peek()) && Peek(1)?.Kind == Kind.Digits)
+ sign = tokens[i++].Value == "+" ? "+" : "-";
+ return Peek()?.Kind == Kind.Digits ? sign + tokens[i++].Value : null;
+ }
+
+ if (Peek()?.Kind == Kind.Digits || IsSign(Peek()) && Peek(1)?.Kind == Kind.Digits)
+ {
+ var number = SignedDigits();
+ if (Peek()?.Value is "." or "," && Peek(1)?.Kind == Kind.Digits)
+ {
+ number += (tokens[i].Value == "," ? "{,}" : ".") + tokens[i + 1].Value;
+ i += 2;
+ }
+
+ if (Peek()?.Value is "e" or "E" && (Peek(1)?.Kind == Kind.Digits || IsSign(Peek(1)) && Peek(2)?.Kind == Kind.Digits))
+ {
+ i++;
+ number += "\\times 10^{" + SignedDigits() + "}";
+ }
+ parts.Add(number);
+ }
+
+ bool separate = parts.Count > 0;
+ while (i < tokens.Count)
+ {
+ var t = tokens[i++];
+ switch (t.Kind)
+ {
+ case Kind.Space:
+ separate = parts.Count > 0;
+ continue;
+ case Kind.Letter:
+ var unit = t.Value;
+ while (Peek()?.Kind == Kind.Letter)
+ unit += tokens[i++].Value;
+ var power = SignedDigits();
+ parts.Add((separate ? "\\," : "") + "\\mathrm{" + unit + "}" + (power != null ? "^{" + power + "}" : ""));
+ break;
+ case Kind.Sup or Kind.Sub when parts.Count > 0:
+ var scr = Peek()?.Kind == Kind.Group ? Rewrite(tokens[i++].Value) : SignedDigits() ?? (i < tokens.Count ? tokens[i++].Value : "");
+ parts[parts.Count - 1] += t.Value + "{" + scr + "}";
+ break;
+ case Kind.Symbol when t.Value is "." or "*":
+ parts.Add("\\cdot");
+ break;
+ case Kind.Command when t.Value == "\\cdot":
+ parts.Add("\\cdot");
+ break;
+ case Kind.Group or Kind.Math:
+ parts.Add((separate ? "\\," : "") + Math(t.Value));
+ break;
+ case Kind.Command when t.Value.Skip(1).All(c => IsAsciiLetter(c.ToString())):
+ // a prefix, \mu m: the unit after it follows it without a space
+ parts.Add((separate ? "\\," : "") + t.Value);
+ if (Peek()?.Kind == Kind.Space)
+ i++;
+ separate = false;
+ continue;
+ default:
+ parts.Add(t.Value);
+ break;
+ }
+ separate = false;
+ }
+ return Join(parts);
+ }
+ }
+}
diff --git a/src/SharpEquations/Internal/MathML/MathMLParser.cs b/src/SharpEquations/Internal/MathML/MathMLParser.cs
index 94fd411..9859d59 100644
--- a/src/SharpEquations/Internal/MathML/MathMLParser.cs
+++ b/src/SharpEquations/Internal/MathML/MathMLParser.cs
@@ -8,6 +8,7 @@ namespace SharpEquations.Internal.MathML
using System.Linq;
using System.Xml;
using System.Xml.Linq;
+ using System.Xml.Xsl;
using SharpEquations.Internal.Text;
using SharpEquations.Internal.UnicodeMath;
@@ -72,6 +73,9 @@ public static XElement Normalize(XElement source, EquationOptions options, bool
var math = new XElement(source);
foreach (var element in math.DescendantsAndSelf().Where(e => e.Name.Namespace == XNamespace.None))
element.Name = Mml + element.Name.LocalName;
+ // Content MathML (apply, ci, cn...) is read as the presentation MathML it is written as
+ if (validate && HasContentMarkup(math))
+ math = ContentToPresentation(math);
// the prefixes of the source (m:mi) are not part of the equation: the MathML namespace is the default one
math.DescendantsAndSelf().Attributes().Where(a => a.IsNamespaceDeclaration).Remove();
// a link of UnicodeMath (\href) is an mrow with href, as MathML has no a element
@@ -380,6 +384,61 @@ private static bool IsInvisiblyFenced(XElement row)
private static bool IsEmptyFence(XElement element) => element.Name == Mml + "mo" && !element.HasElements && element.Value.Length == 0;
+ ///
+ /// Whether an element of MathML outside the annotations is not presentation MathML: Content MathML, or an
+ /// unknown element, which ctop.xsl copies as it is.
+ ///
+ private static bool HasContentMarkup(XElement math) =>
+ math.Descendants().Any(e => e.Name.Namespace == Mml && !PresentationElements.Contains(e.Name.LocalName) &&
+ !e.Ancestors().Any(a => a.Name == Mml + "annotation-xml" || a.Name == Mml + "annotation"));
+
+ /// Content MathML rewritten as presentation MathML by ctop.xsl, with the presentation MathML around it kept.
+ private static XElement ContentToPresentation(XElement math)
+ {
+ var source = new XElement(math);
+ // the white space between elements, which ctop.xsl would copy into rows (from annotation-xml, say), and
+ // around the text of the identifiers and numbers (and of the parts of a number, 23), which it would
+ // copy into mi and mn
+ source.DescendantNodes().OfType().Where(t => t.Parent!.HasElements && string.IsNullOrWhiteSpace(t.Value)).Remove();
+ foreach (var text in source.Descendants().Where(e => e.Name == Mml + "ci" || e.Name == Mml + "cn").Nodes().OfType())
+ text.Value = text.Value.Trim();
+ XElement? presentation;
+ try
+ {
+ presentation = EquationStylesheets.Transform("ctop.xsl", source);
+ }
+ catch (Exception e) when (e is ArgumentException or XsltException)
+ {
+ throw new FormatException(e.Message, e);
+ }
+ if (presentation == null)
+ throw new FormatException("The Content MathML equation is empty.");
+ // a limit ctop.xsl has nothing for (an integral without bounds, a sum over a condition) is an empty row:
+ // no script at all
+ foreach (var scripts in presentation.Descendants().Where(e => e.Name == Mml + "msubsup" || e.Name == Mml + "munderover").Reverse().ToList())
+ {
+ var children = scripts.Elements().ToList();
+ if (children.Count != 3)
+ continue;
+ var under = scripts.Name == Mml + "munderover";
+ switch (IsEmptyRow(children[1]), IsEmptyRow(children[2]))
+ {
+ case (true, true):
+ scripts.ReplaceWith(children[0]);
+ break;
+ case (true, false):
+ scripts.ReplaceWith(new XElement(Mml + (under ? "mover" : "msup"), scripts.Attributes(), children[0], children[2]));
+ break;
+ case (false, true):
+ scripts.ReplaceWith(new XElement(Mml + (under ? "munder" : "msub"), scripts.Attributes(), children[0], children[1]));
+ break;
+ }
+ }
+ return presentation;
+ }
+
+ private static bool IsEmptyRow(XElement element) => element.Name == Mml + "mrow" && !element.HasElements && string.IsNullOrWhiteSpace(element.Value);
+
private static void Validate(XElement math)
{
foreach (var element in math.DescendantsAndSelf())
diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs
index 0a47f00..91fd200 100644
--- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs
+++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathPreprocess.cs
@@ -767,12 +767,14 @@ private object PreprocessScript(PreprocessStyle dsty, JsObject value, string int
basE = DropSingletonLists(ret["base"]);
if (Truthy(Get(basE, "script")) && Eq(Get(Get(basE, "script"), "type"), "subsup"))
{
+ // ret.base is preprocessed already, its scripts with it: preprocessed again, an operator would
+ // become the content of an operator
var baseScript = Get(basE, "script");
if (Has(baseScript, "low"))
- ret["low"] = Preprocess(dsty, Get(baseScript, "low"));
+ ret["low"] = Get(baseScript, "low");
if (Has(baseScript, "high"))
- ret["high"] = Preprocess(dsty, Get(baseScript, "high"));
- ret["base"] = Preprocess(dsty, Get(baseScript, "base"));
+ ret["high"] = Get(baseScript, "high");
+ ret["base"] = Get(baseScript, "base");
}
break;
diff --git a/src/SharpEquations/Resources/ctop.xsl b/src/SharpEquations/Resources/ctop.xsl
new file mode 100644
index 0000000..e0e0ebc
--- /dev/null
+++ b/src/SharpEquations/Resources/ctop.xsl
@@ -0,0 +1,2116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ i
+
+
+
+
+
+
+ +
+
+
+ i
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ e
+
+ i
+
+
+
+
+
+
+
+
+
+
+
+
+ e
+
+ i
+
+
+
+
+
+
+
+
+
+
+ E
+
+
+
+
+
+
+ 0x
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (-1)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ λ
+
+
+
+
+
+
+
+
+
+
+ ∈
+
+
+
+ .
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∘
+
+
+
+
+
+
+id
+
+
+
+
+domain
+
+
+
+
+codomain
+
+
+
+
+image
+
+
+
+
+ unexpected domainofapplication
+
+
+
+
+
+
+
+
+ ∈
+
+
+
+
+
+
+
+
+
+
+
+ restriction
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+
+
+
+
+
+
+ if
+
+
+
+ otherwise
+
+
+
+
+
+
+
+
+
+
+
+
+⌊
+
+/
+
+⌋
+
+
+
+
+
+
+
+
+
+
+
+!
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+ max
+
+
+
+
+
+
+ min
+
+
+
+
+
+
+
+ −
+
+
+
+
+
+
+
+
+
+ −
+
+
+
+
+
+
+
+
+
+
+ (
+
+
+
+ −
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+
+
+
+
+
+
+
+
+
+(
+
+
+
+
+
+
+
+
+)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mod
+
+
+
+
+
+
+
+
+
+
+ (
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+ )
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+gcd
+
+
+
+
+
+
+
+
+ ∧
+
+
+
+
+
+
+
+
+
+
+ ∨
+
+
+
+
+
+
+
+
+
+ xor
+
+
+
+
+
+
+
+¬
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⇒
+
+
+
+
+
+
+
+
+
+ ∀
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+ ∃
+
+ .
+
+
+
+
+ ∧
+
+
+
+
+
+
+
+ ,
+
+
+
+ ∈
+
+
+ ∧
+
+
+
+
+
+
+
+
+
+
+
+
+|
+
+|
+
+
+
+
+
+
+
+
+
+¯
+
+
+
+
+
+ arg
+
+
+
+
+
+ ℛ
+
+
+
+
+ ℑ
+
+
+
+
+ lcm
+
+
+
+
+
+
+⌊
+
+⌋
+
+
+
+
+
+
+
+⌈
+
+⌉
+
+
+
+
+
+
+
+
+
+ =
+
+
+
+
+
+
+
+
+
+ ≠
+
+
+
+
+
+
+
+
+
+ >
+
+
+
+
+
+
+
+
+
+ <
+
+
+
+
+
+
+
+
+
+ ≥
+
+
+
+
+
+
+
+
+
+ ≤
+
+
+
+
+
+
+
+
+
+ ≡
+
+
+
+
+
+
+
+
+
+ ≃
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+ ∫
+
+
+
+
+
+ d
+
+
+
+
+
+
+∫
+
+
+
+
+
+
+
+
+
+
+ ′
+
+
+
+
+
+
+
+ d
+
+ d
+
+
+
+ d
+ d
+
+
+
+
+
+
+
+
+
+ D
+
+
+,
+
+
+
+
+
+
+
+
+
+
+
+ ∂
+
+
+ ∂
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∂
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∂
+
+
+ ∂
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∂
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∂
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+
+
+
+
+
+div
+
+
+
+
+
+div
+
+(
+
+
+
+
+
+↦
+
+
+
+
+)
+
+
+
+
+
+grad
+
+
+
+
+grad
+
+
+(
+
+
+
+↦
+
+)
+
+
+
+
+
+
+curl
+
+
+
+
+
+∇2
+
+
+
+
+
+
+
+(
+
+
+
+↦
+
+)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∪
+
+
+
+
+
+ ⋃
+
+
+
+
+
+
+
+
+
+ ∩
+
+
+
+
+
+
+ ⋂
+
+
+
+
+
+
+
+
+
+ ∈
+
+
+
+
+
+
+
+
+
+ ∉
+
+
+
+
+
+
+
+
+
+
+
+ ⊆
+
+
+
+
+
+
+
+
+
+ ⊂
+
+
+
+
+
+
+
+
+
+ ⊈
+
+
+
+
+
+
+
+
+
+ ⊄
+
+
+
+
+
+
+
+
+
+ ∖
+
+
+
+
+
+
+|
+
+|
+
+
+
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ∑
+
+
+
+
+
+
+
+
+
+
+
+
+∑
+
+
+
+
+
+
+
+
+
+
+ =
+
+
+
+
+
+
+
+
+ ∏
+
+
+
+
+
+
+∏
+
+
+
+
+
+
+
+
+
+
+ lim
+
+
+
+
+
+
+
+
+
+ lim
+
+
+
+
+ ↘
+ ↗
+ →
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+→
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ↘
+ ↗
+ →
+ →
+
+
+
+
+
+
+
+
+
+
+ ↘
+ ↗
+ →
+ →
+
+
+
+
+
+
+
+
+
+ →
+
+
+
+
+
+ tendsto
+
+
+
+
+
+
+
+ (
+
+ )
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+e
+
+
+
+
+
+
+
+
+
+
+
+
+log
+
+
+
+log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 〈
+
+
+ ,
+
+〉
+
+
+
+
+
+
+σ
+
+
+
+
+
+
+σ
+
+
+
+
+
+2
+
+
+
+
+
+
+median
+
+
+
+
+
+mode
+
+
+
+
+
+ 〈
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 〉
+
+
+
+
+
+
+ 〈
+
+
+
+
+ 〉
+
+
+
+
+
+
+
+
+
+
+
+ 〈
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 〉
+
+
+
+
+
+
+
+
+
+
+(
+
+
+
+
+
+)
+
+
+
+
+
+
+ [
+
+ |
+
+ ]
+
+
+
+
+
+ [
+
+ |
+
+ ∈
+
+ ]
+
+
+
+
+
+(
+
+
+
+
+
+
+
+)
+
+
+
+
+
+
+(
+
+
+
+)
+
+
+
+
+
+ [
+
+ m
+
+
+
+ ,
+
+
+
+ |
+
+
+ m
+
+
+
+ ,
+
+
+
+ =
+
+
+ ;
+
+ ]
+
+
+
+
+
+(
+
+
+
+)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+det
+
+
+
+
+
+
+
+
+
+|
+
+
+
+|
+
+
+
+
+
+
+
+
+
+T
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ .
+
+
+
+
+
+
+
+
+
+
+ ⊗
+
+
+
+
+
+
+
+
+
+
+
+
+
+Z
+
+
+
+
+R
+
+
+
+
+Q
+
+
+
+
+N
+
+
+
+
+C
+
+
+
+
+P
+
+
+
+
+ e
+
+
+
+
+ i
+
+
+
+
+ NaN
+
+
+
+
+ true
+
+
+
+
+ false
+
+
+
+
+ ∅
+
+
+
+
+
+ π
+
+
+
+
+ γ
+
+
+
+
+ ∞
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+
+
+
+
+
+
+
+ )
+
+
+
+
+
+
+
+
+
+ (
+
+
+
+
+
+
+
+ )
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+ ∈
+
+
+
+
+
+ ,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ share
+
+
+
+
diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs
index 9d7ffc0..bf94513 100644
--- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs
+++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs
@@ -72,7 +72,7 @@ public class LaTeXReaderTests
[Arguments(@"\{x\mid x>0\}", @"\{x\mid x>0\}", @"{𝑥∣𝑥>0}")]
[Arguments(@"a\mid b", @"a\mid b", @"𝑎\∣𝑏")]
[Arguments(@"\langle\psi\mid", @"\langle\psi\mid", @"⟨𝜓\∣")]
- [Arguments(@"\mid\psi\rangle", @"|\psi\rangle", @"|𝜓⟩")]
+ [Arguments(@"\mid\psi\rangle", @"\mid\psi\rangle", @"\∣𝜓\⟩")]
[Arguments(@"\overset{\to}{a}\overset{\to}{b}", @"\overset{\to}{a}\overset{\to}{b}", @"𝑎┴→ 𝑏┴→")]
[Arguments(@"\mathring{a}\overleftarrow{b}", @"\mathring{a}\overleftarrow{b}", @"𝑎┴˚ 𝑏┴←")]
[Arguments(@"\vphantom{\sum}_1^2\sum_3^4", @"\vphantom{\sum}_1^2\sum_3^4", @"⇳∑₁²∑₃⁴")]
@@ -81,6 +81,7 @@ public class LaTeXReaderTests
[Arguments(@"\xrightarrow[y]{x} \xleftarrow x", @"\underset{y}{\overset{x}{\to}}\overset{x}{\leftarrow}", @"→┬𝑦┴𝑥 ←┴𝑥")]
[Arguments(@"\underset ab+\underleftarrow{c}", @"\underset{a}{b}+\underset{\leftarrow}{c}", @"𝑏┬𝑎+𝑐┬←")]
[Arguments(@"\sideset{_1^2}{_3^4}\sum", @"\vphantom{\sum}_1^2\sum_3^4", @"⇳∑₁²∑₃⁴")]
+ [Arguments(@"\prescript{14}{6}{C}+\prescript{}{2}{x}", @"{}_6^{14}C+{}_2x", @"_6^14 𝐶+(_2)𝑥")]
[Arguments(@"\genfrac\{]{0pt}{}{a}{b}+\dbinom a b", @"\{\genfrac{}{}{0pt}{}{a}{b}]+\binom{a}{b}", @"{𝑎¦𝑏]+𝑎⒞𝑏")]
[Arguments(@"a \pod{n}+\not a", @"a\quad(n)+\overset{̸}{a}", @"𝑎 (𝑛)+𝑎̸")]
[Arguments(@"^3+{'x}", @"{}^3+{}'x", @"⬚³+⬚′𝑥")]
@@ -137,6 +138,12 @@ public class LaTeXReaderTests
[Arguments(@"\langle a \vert b \rangle", @"\langle a|b\rangle", @"⟨𝑎│𝑏⟩")]
[Arguments(@"\left. x \right. ", @"x", @"𝑥")]
[Arguments(@"\mathop{\sum}_i", @"\sum_i", @"∑_𝑖")]
+ [Arguments(@"\mathop{x}\limits_0^1", @"\underset{0}{\overset{1}{x}}", @"𝑥┬0┴1")]
+ [Arguments(@"\underset{0}{\overset{1}{x}}", @"\underset{0}{\overset{1}{x}}", @"𝑥┬0┴1")]
+ [Arguments(@"\overbrace3^a_x", @"\underset{x}{\overset{a}{\overbrace{3}}}", @"⏞3┬𝑥┴𝑎")]
+ [Arguments(@"\underset{x}{\overset{a}{\overbrace{3}}}", @"\underset{x}{\overset{a}{\overbrace{3}}}", @"⏞3┬𝑥┴𝑎")]
+ [Arguments(@"\underbrace{a+b}_n^x+c", @"\underset{n}{\overset{x}{\underbrace{a+b}}}+c", @"⏟(𝑎+𝑏)┬𝑛┴𝑥+𝑐")]
+ [Arguments(@"\underset{n}{\overset{x}{\underbrace{a+b}}}+c", @"\underset{n}{\overset{x}{\underbrace{a+b}}}+c", @"⏟(𝑎+𝑏)┬𝑛┴𝑥+𝑐")]
[Arguments(@"\xleftarrow{g}", @"\overset{g}{\leftarrow}", @"←┴𝑔")]
[Arguments(@"\pmod n", @"(\mod\,n)", @"(mod 𝑛)")]
[Arguments(@"\hspace*{2cm}x", @"x", @"𝑥")]
@@ -326,6 +333,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.")]
@@ -350,5 +378,55 @@ public async Task RejectsLaTeXThatIsNotAnEquation(string latex)
{
await Assert.That(() => Equation.Parse(latex, EquationFormat.LaTeX)).ThrowsExactly();
}
+
+ [Test]
+ [Arguments(@"a\square b", "□")]
+ [Arguments(@"a\blacksquare b", "■")]
+ [Arguments(@"a\surd b", "√")]
+ [Arguments(@"a\triangleq b", "≜")]
+ [Arguments(@"a\eqsim b", "≂")]
+ [Arguments(@"a\doteqdot b", "≑")]
+ [Arguments(@"a\rightsquigarrow b", "↝")]
+ [Arguments(@"a\leftrightsquigarrow b", "↭")]
+ [Arguments(@"a\measuredangle b", "∡")]
+ [Arguments(@"a\sphericalangle b", "∢")]
+ [Arguments(@"a\mho b", "℧")]
+ [Arguments(@"a\yen b", "¥")]
+ [Arguments(@"a\circledR b", "®")]
+ [Arguments(@"a\S b", "§")]
+ [Arguments(@"a\P b", "¶")]
+ [Arguments(@"a\geqslant b", "⩾")]
+ [Arguments(@"a\leqslant b", "⩽")]
+ [Arguments(@"a\therefore b", "∴")]
+ [Arguments(@"a\coloneqq b", "≔")]
+ [Arguments(@"a\dblcolon b", "∷")]
+ [Arguments(@"a\ngtr b", "≯")]
+ [Arguments(@"a\lozenge b", "◊")]
+ [Arguments(@"a\oslash b", "⊘")]
+ [Arguments(@"a\succeq b", "⪰")]
+ [Arguments(@"a\mid b", "∣")]
+ public async Task ReadsSymbolsThatUnicodeMathHasNoControlWordFor(string latex, string symbol)
+ {
+ var math = Equation.Parse(latex, EquationFormat.LaTeX, Strict).ToMathML();
+ await Assert.That(math.Elements().Select(e => e.Value).ToList()).IsEquivalentTo(new[] { "a", symbol, "b" });
+ }
+
+ [Test]
+ [Arguments("□", @"a\square b")]
+ [Arguments("√", @"a{\surd}b")]
+ [Arguments("≦", @"a\leqq b")]
+ [Arguments("↠", @"a\twoheadrightarrow b")]
+ [Arguments("⇄", @"a\rightleftarrows b")]
+ [Arguments("≼", @"a\preccurlyeq b")]
+ [Arguments("∌", @"a\not\ni b")]
+ [Arguments("〈", @"a\langle b")]
+ public async Task WritesTheSymbolsOfAmssymbAsCommands(string symbol, string latex)
+ {
+ var equation = Equation.Parse("", EquationFormat.MathML);
+ var written = equation.ToString(EquationFormat.LaTeX);
+ await Assert.That(written).IsEqualTo(latex);
+ var back = Equation.Parse(written, EquationFormat.LaTeX, Strict);
+ await Assert.That(Equation.Parse(back.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX)).IsEqualTo(back);
+ }
}
}
diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXSymbolsTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXSymbolsTests.cs
new file mode 100644
index 0000000..117eb91
--- /dev/null
+++ b/tests/SharpEquations.Tests/LaTeX/LaTeXSymbolsTests.cs
@@ -0,0 +1,34 @@
+#nullable enable
+
+namespace SharpEquations.Tests.LaTeX
+{
+ using System.Collections.Generic;
+ using System.Security;
+ using System.Threading.Tasks;
+ using SharpEquations;
+ using SharpEquations.Internal.LaTeX;
+
+ /// Every command the standard LaTeX writer writes for a character is one the LaTeX reader reads.
+ public class LaTeXSymbolsTests
+ {
+ private static readonly EquationOptions Strict = new EquationOptions { Strict = true };
+
+ public static IEnumerable Characters() => LaTeXSymbols.CharacterCommands.Keys;
+
+ [Test]
+ [MethodDataSource(nameof(Characters))]
+ public async Task CharacterWrittenAsLaTeXReadsBack(string character)
+ {
+ var element = char.IsLetter(character, 0) ? "mi" : "mo";
+ var equation = Equation.Parse("", EquationFormat.MathML);
+ var latex = equation.ToString(EquationFormat.LaTeX);
+
+ // read back as the character (or one written as the same command: \circ for ° and ∘), not as the text "\geqslantb";
+ // what UnicodeMath makes of the character (an identifier or an operator) is lost only the first time
+ var read = Equation.Parse(latex, EquationFormat.LaTeX, Strict);
+ await Assert.That(read.ToString(EquationFormat.LaTeX)).IsEqualTo(latex);
+ await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX, Strict)).IsEqualTo(read);
+ await Assert.That(Equation.Parse(read.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX, Strict)).IsEqualTo(read);
+ }
+ }
+}
diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs
index 91ee8c2..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); \limits after \mathop; both scripts of \overbrace and \underbrace; \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",
- "issue-125-4-limits", "overbrace-c", "underbrace-c", "middle", "spaces",
- "newenvironment-multi-node", "newcommand-escaped-hash",
+ "middle", "spaces",
};
// 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)
diff --git a/tests/SharpEquations.Tests/LaTeX/MhchemTests.cs b/tests/SharpEquations.Tests/LaTeX/MhchemTests.cs
new file mode 100644
index 0000000..e33fafa
--- /dev/null
+++ b/tests/SharpEquations.Tests/LaTeX/MhchemTests.cs
@@ -0,0 +1,78 @@
+#nullable enable
+
+namespace SharpEquations.Tests.LaTeX
+{
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using System.Xml.Linq;
+ using SharpEquations;
+
+ ///
+ /// The \ce and \pu of mhchem: the chemistry test cases of treeblood read strictly, and the MathML of some checked
+ /// by hand.
+ ///
+ public class MhchemTests
+ {
+ private static readonly EquationOptions Strict = new EquationOptions { Strict = true };
+
+ public static IEnumerable Formulas() => JsonSerializer.Deserialize>(
+ File.ReadAllText(TestData.Path("latex", "treeblood-mhchem.json")))!;
+
+ private static string Compact(Equation equation) => string.Concat(equation.ToMathML().Nodes().Select(n => n.ToString(SaveOptions.DisableFormatting)))
+ .Replace(" xmlns=\"http://www.w3.org/1998/Math/MathML\"", "");
+
+ [Test]
+ public async Task TheCorpusHasAllTheFormulas()
+ {
+ await Assert.That(Formulas().Count()).IsEqualTo(76);
+ }
+
+ [Test]
+ [MethodDataSource(nameof(Formulas))]
+ public async Task EveryFormulaParsesStrictly(string latex)
+ {
+ var equation = Equation.Parse(latex, EquationFormat.LaTeX, Strict);
+ var mathml = Compact(equation);
+ await Assert.That(mathml.Length).IsGreaterThan(0);
+ await Assert.That(mathml).DoesNotContain("⬚");
+ await Assert.That(mathml).DoesNotContain("[object Object]");
+ await Assert.That(mathml).DoesNotContain("\\");
+ }
+
+ [Test]
+ [Arguments(@"\ce{H2O}", "H2O")]
+ [Arguments(@"\ce{CrO4^2-}", "CrO42−")]
+ [Arguments(@"\ce{[AgCl2]-}", "[AgCl2]−")]
+ [Arguments(@"\ce{(NH4)2S}", "(NH4)2S")]
+ [Arguments(@"\ce{^{227}_{90}Th+}", "Th+90227")]
+ [Arguments(@"\ce{^0_-1n-}", "n−−10")]
+ [Arguments(@"\ce{H{}^3HO}", "HH3O")]
+ [Arguments(@"\ce{1/2H2O}", "12H2O")]
+ [Arguments(@"\ce{KCr(SO4)2.12H2O}", "KCr(SO4)2⋅12H2O")]
+ [Arguments(@"\ce{A <=> B}", "A⇌B")]
+ [Arguments(@"\ce{A ->[H2O] B}", "A→H2OB")]
+ [Arguments(@"\ce{A ->[{text above}][{text below}] B}", "A→text abovetext belowB")]
+ [Arguments(@"\ce{SO4^2- + Ba^2+ -> BaSO4 v}", "SO42−+Ba2+⟶BaSO4↓")]
+ [Arguments(@"\ce{A-B=C#D}", "A−B=C≡D")]
+ [Arguments(@"\ce{NO_x}", "NOx")]
+ [Arguments(@"\ce{Ca(OH)2(s)}", "Ca(OH)2(s)")]
+ [Arguments(@"\ce{H2O}^2", "H2O2")]
+ [Arguments(@"\pu{1.2e3 kJ mol-1}", "1.2×103 kJ mol−1")]
+ public async Task FormulaIsReadAsMathML(string latex, string expected)
+ {
+ await Assert.That(Compact(Equation.Parse(latex, EquationFormat.LaTeX, Strict))).IsEqualTo(expected);
+ }
+
+ [Test]
+ [Arguments(@"\ce{A\bond{?}B}")]
+ [Arguments(@"\ce{H2O")]
+ [Arguments(@"\ce{A $x B}")]
+ public async Task MalformedFormulaIsRejected(string latex)
+ {
+ await Assert.That(Equation.TryParse(latex, EquationFormat.LaTeX, Strict, out _)).IsFalse();
+ }
+ }
+}
diff --git a/tests/SharpEquations.Tests/MathML/ContentMathMLTests.cs b/tests/SharpEquations.Tests/MathML/ContentMathMLTests.cs
new file mode 100644
index 0000000..33d3221
--- /dev/null
+++ b/tests/SharpEquations.Tests/MathML/ContentMathMLTests.cs
@@ -0,0 +1,168 @@
+#nullable enable
+
+namespace SharpEquations.Tests.MathML
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using System.Xml.Linq;
+ using SharpEquations;
+
+ /// Content MathML read as the presentation MathML it is written as (ctop.xsl).
+ public class ContentMathMLTests
+ {
+ private const string Namespace = "http://www.w3.org/1998/Math/MathML";
+ private static readonly XNamespace Mml = Namespace;
+ private static readonly EquationOptions Strict = new EquationOptions { Strict = true };
+
+ /// The elements of presentation MathML an equation may hold once read.
+ private static readonly HashSet Presentation = new HashSet(StringComparer.Ordinal)
+ {
+ "math", "mi", "mn", "mo", "mtext", "mspace", "ms", "mglyph", "mrow", "mfrac", "msqrt", "mroot", "mstyle",
+ "merror", "mpadded", "mphantom", "menclose", "msub", "msup", "msubsup", "munder", "mover",
+ "munderover", "mmultiscripts", "mprescripts", "none", "mtable", "mtr", "mlabeledtr", "mtd", "maligngroup",
+ "malignmark", "mstack", "mlongdiv", "msgroup", "msrow", "mscarries", "mscarry", "msline", "maction",
+ "semantics", "annotation", "annotation-xml",
+ };
+
+ public static IEnumerable CtopFiles() =>
+ Directory.GetFiles(TestData.Path("contentmathml"), "*.mml").Select(Path.GetFileName).OrderBy(f => f, StringComparer.Ordinal)!;
+
+ private static Equation ReadFile(string file) =>
+ Equation.Parse(File.ReadAllText(TestData.Path("contentmathml", file)), EquationFormat.MathML, Strict);
+
+ private static string Read(string mathML) =>
+ Equation.Parse(mathML, EquationFormat.MathML, Strict).ToString().Replace(" xmlns=\"" + Namespace + "\"", "");
+
+ private static string LaTeX(string mathML) => Equation.Parse(mathML, EquationFormat.MathML, Strict).ToString(EquationFormat.LaTeX);
+
+ [Test]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("", "")]
+ [Arguments("",
+ "")]
+ [Arguments("",
+ "")]
+ [Arguments("",
+ "")]
+ public async Task ReadsContentMathMLAsPresentation(string content, string expected)
+ {
+ await Assert.That(Read(content)).IsEqualTo(expected);
+ }
+
+ [Test]
+ [Arguments("", "a/b")]
+ [Arguments("", @"\sin x")]
+ [Arguments("",
+ @"\int_0^\infty e^{-x}dx")]
+ [Arguments("", @"\sum\limits_{i=1}^ni")]
+ [Arguments("", @"\frac{d \sin x}{dx}")]
+ [Arguments("",
+ @"\frac{\partial^2f(x,y)}{\partial x\partial y}")]
+ [Arguments("", @"\frac{\partial^2f}{\partial x^2}")]
+ [Arguments("",
+ @"\lim_{x\to0}x")]
+ [Arguments("",
+ @"\begin{pmatrix}1&0\\0&1\end{pmatrix}")]
+ [Arguments("", @"\{a,b\}")]
+ [Arguments("", "(0,1]")]
+ [Arguments("", @"p\wedge\neg q")]
+ [Arguments("", "x+y")]
+ public async Task WritesContentMathMLInOtherFormats(string content, string latex)
+ {
+ await Assert.That(LaTeX(content)).IsEqualTo(latex);
+ }
+
+ [Test]
+ public async Task ReadsTheSameEquationAsItsPresentation()
+ {
+ var content = Equation.Parse("", EquationFormat.MathML);
+ var presentation = Equation.Parse("", EquationFormat.MathML);
+ await Assert.That(content).IsEqualTo(presentation);
+ }
+
+ [Test]
+ public async Task KeepsThePresentationMathMLAroundIt()
+ {
+ await Assert.That(Read(""))
+ .IsEqualTo("");
+ await Assert.That(Read(""))
+ .IsEqualTo("");
+ }
+
+ [Test]
+ public async Task TakesThePresentationAnnotationOfSemantics()
+ {
+ await Assert.That(Read(""))
+ .IsEqualTo("");
+ }
+
+ [Test]
+ public async Task LeavesContentAnnotationsAlone()
+ {
+ const string annotated = "";
+ await Assert.That(Read(annotated)).IsEqualTo(annotated);
+ }
+
+ [Test]
+ public async Task IgnoresWhiteSpaceBetweenElementsAndAroundTokens()
+ {
+ const string indented = "\n \n \n x \n \n 1\n \n \n";
+ await Assert.That(Equation.Parse(indented, EquationFormat.MathML)).IsEqualTo(Equation.Parse("", EquationFormat.MathML));
+ }
+
+ [Test]
+ public async Task ReadsContentMathMLGivenAsXml()
+ {
+ var math = new XElement(Mml + "math", new XElement(Mml + "apply", new XElement(Mml + "times"), new XElement(Mml + "cn", "2"), new XElement(Mml + "ci", "x")));
+ await Assert.That(Equation.Parse(math, EquationFormat.MathML, Strict).ToString(EquationFormat.LaTeX)).IsEqualTo("2x");
+ }
+
+ [Test]
+ public async Task RejectsWhatIsNeitherContentNorPresentationWhenStrict()
+ {
+ await Assert.That(() => Equation.Parse("", EquationFormat.MathML, Strict))
+ .Throws().WithMessageContaining("foo");
+ }
+
+ [Test]
+ public async Task RejectsContentMathMLNestedTooDeeply()
+ {
+ var deep = string.Concat(Enumerable.Repeat("", 600)) + "x" + string.Concat(Enumerable.Repeat("", 600));
+ await Assert.That(() => Equation.Parse("", EquationFormat.MathML)).ThrowsExactly();
+ }
+
+ [Test]
+ [MethodDataSource(nameof(CtopFiles))]
+ public async Task ReadsTheEquationsOfCtopAsPresentationMathML(string file)
+ {
+ var math = ReadFile(file).ToMathML();
+ var others = math.DescendantsAndSelf().Where(e => e.Name.Namespace != Mml || !Presentation.Contains(e.Name.LocalName)).Select(e => e.Name.LocalName).ToList();
+ await Assert.That(others).IsEmpty();
+ }
+
+ [Test]
+ [MethodDataSource(nameof(CtopFiles))]
+ public async Task WritesTheEquationsOfCtopAsMathMLThatReadsBack(string file)
+ {
+ var equation = ReadFile(file);
+ await Assert.That(Equation.Parse(equation.ToString(), EquationFormat.MathML, Strict)).IsEqualTo(equation);
+ }
+
+ [Test]
+ [MethodDataSource(nameof(CtopFiles))]
+ public async Task WritesTheEquationsOfCtopAsLaTeXAndOmml(string file)
+ {
+ var equation = ReadFile(file);
+ await Assert.That(() => equation.ToString(EquationFormat.LaTeX)).ThrowsNothing();
+ await Assert.That(() => equation.ToOmml()).ThrowsNothing();
+ }
+ }
+}
diff --git a/tests/SharpEquations.Tests/MathML/MathMLReaderTests.cs b/tests/SharpEquations.Tests/MathML/MathMLReaderTests.cs
index 918c199..a541bb8 100644
--- a/tests/SharpEquations.Tests/MathML/MathMLReaderTests.cs
+++ b/tests/SharpEquations.Tests/MathML/MathMLReaderTests.cs
@@ -113,7 +113,6 @@ public async Task AcceptsPresentationMathMLWhenStrict(string mathML)
[Test]
[Arguments("", "foo")]
- [Arguments("", "apply")]
[Arguments("", "svg")]
public async Task RejectsOtherElementsWhenStrict(string mathML, string element)
{
diff --git a/tests/data/README.md b/tests/data/README.md
index e2b61ef..52df365 100644
--- a/tests/data/README.md
+++ b/tests/data/README.md
@@ -60,6 +60,11 @@ author expects for each (the `results` table of `dist/unicodemathml-integration.
MathML with the UnicodeMath and the TeX its author expects for it. Copyright (c) 2019 Noah Doersing, MIT License;
maintained by Murray Sargent.
+`contentmathml/ctop-*.mml`: the 40 equations of `ctop/tests/ctopxslt.html` of
+[davidcarlisle/web-xslt](https://github.com/davidcarlisle/web-xslt) (commit 64a32a4a4f78529010f548524f3acc7241be54d5),
+one file each, as they are there: Content MathML, some of it mixed with presentation MathML. Copyright David Carlisle,
+MIT License.
+
`latex/latex2mathml-converter.json`: the LaTeX of the `test_converter` and `test_converter_inline` cases of
`tests/test_converter.py` of [roniemartinez/latex2mathml](https://github.com/roniemartinez/latex2mathml) (commit
b9ee564589d79c3b7bfaf4bb920962745abbb694), with the MathML of their snapshots (`tests/__snapshots__/test_converter`,
@@ -78,3 +83,8 @@ of `\mathsf{1}` is sans-serif (`mathsf`).
(commit 9458a7a2f5125609c92eb178fcd0f8b8419e2c40), MathML with the LaTeX mml2tex writes for it, and the MathML of
`example/*.xml`, which has none, as `{source, label, mathml, tex}` (namespace prefixes, processing instructions and
whitespace removed). Copyright (c) 2015, transpect.io, BSD 2-Clause License.
+
+`latex/treeblood-mhchem.json`: the LaTeX of the `chemistry` cases of `test/TESTCASES.yaml` of
+[Wyatt915/treeblood](https://github.com/Wyatt915/treeblood) (commit 83ddf845ee4aafc040ea4639dae0f8536aeda071), the
+`\ce` formulas of mhchem, as a list of strings; their MathML, which treeblood writes in its own form, is not kept.
+Copyright (c) 2024 Wyatt Sheffield, MIT License.
diff --git a/tests/data/contentmathml/ctop-01.mml b/tests/data/contentmathml/ctop-01.mml
new file mode 100644
index 0000000..fc6ae79
--- /dev/null
+++ b/tests/data/contentmathml/ctop-01.mml
@@ -0,0 +1,19 @@
+
diff --git a/tests/data/contentmathml/ctop-02.mml b/tests/data/contentmathml/ctop-02.mml
new file mode 100644
index 0000000..b36acbc
--- /dev/null
+++ b/tests/data/contentmathml/ctop-02.mml
@@ -0,0 +1,16 @@
+
diff --git a/tests/data/contentmathml/ctop-03.mml b/tests/data/contentmathml/ctop-03.mml
new file mode 100644
index 0000000..0d58f84
--- /dev/null
+++ b/tests/data/contentmathml/ctop-03.mml
@@ -0,0 +1,11 @@
+
diff --git a/tests/data/contentmathml/ctop-04.mml b/tests/data/contentmathml/ctop-04.mml
new file mode 100644
index 0000000..f0e9000
--- /dev/null
+++ b/tests/data/contentmathml/ctop-04.mml
@@ -0,0 +1,13 @@
+
diff --git a/tests/data/contentmathml/ctop-05.mml b/tests/data/contentmathml/ctop-05.mml
new file mode 100644
index 0000000..a5b4ea2
--- /dev/null
+++ b/tests/data/contentmathml/ctop-05.mml
@@ -0,0 +1,7 @@
+
diff --git a/tests/data/contentmathml/ctop-06.mml b/tests/data/contentmathml/ctop-06.mml
new file mode 100644
index 0000000..dd3341f
--- /dev/null
+++ b/tests/data/contentmathml/ctop-06.mml
@@ -0,0 +1,8 @@
+
diff --git a/tests/data/contentmathml/ctop-07.mml b/tests/data/contentmathml/ctop-07.mml
new file mode 100644
index 0000000..758cde3
--- /dev/null
+++ b/tests/data/contentmathml/ctop-07.mml
@@ -0,0 +1,3 @@
+
diff --git a/tests/data/contentmathml/ctop-08.mml b/tests/data/contentmathml/ctop-08.mml
new file mode 100644
index 0000000..1821dd7
--- /dev/null
+++ b/tests/data/contentmathml/ctop-08.mml
@@ -0,0 +1,12 @@
+
diff --git a/tests/data/contentmathml/ctop-09.mml b/tests/data/contentmathml/ctop-09.mml
new file mode 100644
index 0000000..4fae0c3
--- /dev/null
+++ b/tests/data/contentmathml/ctop-09.mml
@@ -0,0 +1,7 @@
+
diff --git a/tests/data/contentmathml/ctop-10.mml b/tests/data/contentmathml/ctop-10.mml
new file mode 100644
index 0000000..8128bb7
--- /dev/null
+++ b/tests/data/contentmathml/ctop-10.mml
@@ -0,0 +1,7 @@
+
diff --git a/tests/data/contentmathml/ctop-11.mml b/tests/data/contentmathml/ctop-11.mml
new file mode 100644
index 0000000..abd6c1c
--- /dev/null
+++ b/tests/data/contentmathml/ctop-11.mml
@@ -0,0 +1,12 @@
+
diff --git a/tests/data/contentmathml/ctop-12.mml b/tests/data/contentmathml/ctop-12.mml
new file mode 100644
index 0000000..d8604b5
--- /dev/null
+++ b/tests/data/contentmathml/ctop-12.mml
@@ -0,0 +1,3 @@
+
diff --git a/tests/data/contentmathml/ctop-13.mml b/tests/data/contentmathml/ctop-13.mml
new file mode 100644
index 0000000..5d7f2e1
--- /dev/null
+++ b/tests/data/contentmathml/ctop-13.mml
@@ -0,0 +1,6 @@
+
diff --git a/tests/data/contentmathml/ctop-14.mml b/tests/data/contentmathml/ctop-14.mml
new file mode 100644
index 0000000..2eacfb5
--- /dev/null
+++ b/tests/data/contentmathml/ctop-14.mml
@@ -0,0 +1,41 @@
+
diff --git a/tests/data/contentmathml/ctop-15.mml b/tests/data/contentmathml/ctop-15.mml
new file mode 100644
index 0000000..d8a5a32
--- /dev/null
+++ b/tests/data/contentmathml/ctop-15.mml
@@ -0,0 +1,13 @@
+
diff --git a/tests/data/contentmathml/ctop-16.mml b/tests/data/contentmathml/ctop-16.mml
new file mode 100644
index 0000000..f4191f5
--- /dev/null
+++ b/tests/data/contentmathml/ctop-16.mml
@@ -0,0 +1,20 @@
+
diff --git a/tests/data/contentmathml/ctop-17.mml b/tests/data/contentmathml/ctop-17.mml
new file mode 100644
index 0000000..94d470f
--- /dev/null
+++ b/tests/data/contentmathml/ctop-17.mml
@@ -0,0 +1,5 @@
+
diff --git a/tests/data/contentmathml/ctop-18.mml b/tests/data/contentmathml/ctop-18.mml
new file mode 100644
index 0000000..a7bb77a
--- /dev/null
+++ b/tests/data/contentmathml/ctop-18.mml
@@ -0,0 +1,9 @@
+
diff --git a/tests/data/contentmathml/ctop-19.mml b/tests/data/contentmathml/ctop-19.mml
new file mode 100644
index 0000000..a5d1cf4
--- /dev/null
+++ b/tests/data/contentmathml/ctop-19.mml
@@ -0,0 +1,23 @@
+
diff --git a/tests/data/contentmathml/ctop-20.mml b/tests/data/contentmathml/ctop-20.mml
new file mode 100644
index 0000000..26db053
--- /dev/null
+++ b/tests/data/contentmathml/ctop-20.mml
@@ -0,0 +1,23 @@
+
diff --git a/tests/data/contentmathml/ctop-21.mml b/tests/data/contentmathml/ctop-21.mml
new file mode 100644
index 0000000..85d94a4
--- /dev/null
+++ b/tests/data/contentmathml/ctop-21.mml
@@ -0,0 +1,23 @@
+
diff --git a/tests/data/contentmathml/ctop-22.mml b/tests/data/contentmathml/ctop-22.mml
new file mode 100644
index 0000000..61ebd69
--- /dev/null
+++ b/tests/data/contentmathml/ctop-22.mml
@@ -0,0 +1,11 @@
+
diff --git a/tests/data/contentmathml/ctop-23.mml b/tests/data/contentmathml/ctop-23.mml
new file mode 100644
index 0000000..43ad827
--- /dev/null
+++ b/tests/data/contentmathml/ctop-23.mml
@@ -0,0 +1,6 @@
+
diff --git a/tests/data/contentmathml/ctop-24.mml b/tests/data/contentmathml/ctop-24.mml
new file mode 100644
index 0000000..9a93be2
--- /dev/null
+++ b/tests/data/contentmathml/ctop-24.mml
@@ -0,0 +1,14 @@
+
diff --git a/tests/data/contentmathml/ctop-25.mml b/tests/data/contentmathml/ctop-25.mml
new file mode 100644
index 0000000..84053eb
--- /dev/null
+++ b/tests/data/contentmathml/ctop-25.mml
@@ -0,0 +1,19 @@
+
diff --git a/tests/data/contentmathml/ctop-26.mml b/tests/data/contentmathml/ctop-26.mml
new file mode 100644
index 0000000..4116a8d
--- /dev/null
+++ b/tests/data/contentmathml/ctop-26.mml
@@ -0,0 +1,16 @@
+
diff --git a/tests/data/contentmathml/ctop-27.mml b/tests/data/contentmathml/ctop-27.mml
new file mode 100644
index 0000000..e2be521
--- /dev/null
+++ b/tests/data/contentmathml/ctop-27.mml
@@ -0,0 +1,15 @@
+
diff --git a/tests/data/contentmathml/ctop-28.mml b/tests/data/contentmathml/ctop-28.mml
new file mode 100644
index 0000000..2e720e9
--- /dev/null
+++ b/tests/data/contentmathml/ctop-28.mml
@@ -0,0 +1,15 @@
+
diff --git a/tests/data/contentmathml/ctop-29.mml b/tests/data/contentmathml/ctop-29.mml
new file mode 100644
index 0000000..bd87377
--- /dev/null
+++ b/tests/data/contentmathml/ctop-29.mml
@@ -0,0 +1,15 @@
+
diff --git a/tests/data/contentmathml/ctop-30.mml b/tests/data/contentmathml/ctop-30.mml
new file mode 100644
index 0000000..772b882
--- /dev/null
+++ b/tests/data/contentmathml/ctop-30.mml
@@ -0,0 +1,3 @@
+
diff --git a/tests/data/contentmathml/ctop-31.mml b/tests/data/contentmathml/ctop-31.mml
new file mode 100644
index 0000000..9e324c9
--- /dev/null
+++ b/tests/data/contentmathml/ctop-31.mml
@@ -0,0 +1,3 @@
+
diff --git a/tests/data/contentmathml/ctop-32.mml b/tests/data/contentmathml/ctop-32.mml
new file mode 100644
index 0000000..e3e4e28
--- /dev/null
+++ b/tests/data/contentmathml/ctop-32.mml
@@ -0,0 +1,7 @@
+
diff --git a/tests/data/contentmathml/ctop-33.mml b/tests/data/contentmathml/ctop-33.mml
new file mode 100644
index 0000000..de553f6
--- /dev/null
+++ b/tests/data/contentmathml/ctop-33.mml
@@ -0,0 +1,11 @@
+
diff --git a/tests/data/contentmathml/ctop-34.mml b/tests/data/contentmathml/ctop-34.mml
new file mode 100644
index 0000000..667e86d
--- /dev/null
+++ b/tests/data/contentmathml/ctop-34.mml
@@ -0,0 +1,11 @@
+
diff --git a/tests/data/contentmathml/ctop-35.mml b/tests/data/contentmathml/ctop-35.mml
new file mode 100644
index 0000000..fda3bbc
--- /dev/null
+++ b/tests/data/contentmathml/ctop-35.mml
@@ -0,0 +1,14 @@
+
diff --git a/tests/data/contentmathml/ctop-36.mml b/tests/data/contentmathml/ctop-36.mml
new file mode 100644
index 0000000..0e6c53a
--- /dev/null
+++ b/tests/data/contentmathml/ctop-36.mml
@@ -0,0 +1,5 @@
+
diff --git a/tests/data/contentmathml/ctop-37.mml b/tests/data/contentmathml/ctop-37.mml
new file mode 100644
index 0000000..8ba87f4
--- /dev/null
+++ b/tests/data/contentmathml/ctop-37.mml
@@ -0,0 +1,5 @@
+
diff --git a/tests/data/contentmathml/ctop-38.mml b/tests/data/contentmathml/ctop-38.mml
new file mode 100644
index 0000000..0ba8005
--- /dev/null
+++ b/tests/data/contentmathml/ctop-38.mml
@@ -0,0 +1,4 @@
+
diff --git a/tests/data/contentmathml/ctop-39.mml b/tests/data/contentmathml/ctop-39.mml
new file mode 100644
index 0000000..81ee09e
--- /dev/null
+++ b/tests/data/contentmathml/ctop-39.mml
@@ -0,0 +1,4 @@
+
diff --git a/tests/data/contentmathml/ctop-40.mml b/tests/data/contentmathml/ctop-40.mml
new file mode 100644
index 0000000..ded960e
--- /dev/null
+++ b/tests/data/contentmathml/ctop-40.mml
@@ -0,0 +1,5 @@
+
diff --git a/tests/data/latex/treeblood-mhchem.json b/tests/data/latex/treeblood-mhchem.json
new file mode 100644
index 0000000..1d47e65
--- /dev/null
+++ b/tests/data/latex/treeblood-mhchem.json
@@ -0,0 +1,78 @@
+[
+ "\\ce{CO2 + C -> 2 CO}",
+ "\\ce{Hg^2+ ->[I-] HgI2 ->[I-] [Hg^{II}I4]^2-}",
+ "\\ce{H2O}",
+ "\\ce{Sb2O3}",
+ "\\ce{H+}",
+ "\\ce{CrO4^2-}",
+ "\\ce{[AgCl2]-}",
+ "\\ce{Y^99+}",
+ "\\ce{Y^{99+}}",
+ "\\ce{Fe^{II}Fe^{III}2O4}",
+ "\\ce{2H2O}",
+ "\\ce{2 H2O}",
+ "\\ce{0.5H2O}",
+ "\\ce{1/2H2O}",
+ "\\ce{(1/2)H2O}",
+ "\\ce{$n$H2O}",
+ "\\ce{^{227}_{90}Th+}",
+ "\\ce{^227_90Th+}",
+ "\\ce{^{0}_{-1}n^{-}}",
+ "\\ce{^0_-1n-}",
+ "\\ce{H{}^3HO}",
+ "\\ce{H^3HO}",
+ "\\ce{(NH4)2S}",
+ "\\ce{[\\{(X2)3\\}2]^3+}",
+ "\\ce{CH4 + 2 $\\left( \\ce{O2 + 79/21 N2} \\right)$}",
+ "\\ce{H2(aq)}",
+ "\\ce{CO3^2-{}_{(aq)}}",
+ "\\ce{NaOH(aq,$\\infty$)}",
+ "\\ce{OCO^{.-}}",
+ "\\ce{NO^{(2.)-}}",
+ "\\ce{NO_x}",
+ "\\ce{Fe^n+}",
+ "\\ce{x Na(NH4)HPO4 ->[\\Delta] (NaPO3)_x + x NH3 ^ + x H2O}",
+ "\\ce{\\mu-Cl}",
+ "\\ce{[Pt(\\eta^2-C2H4)Cl3]-}",
+ "\\ce{^234_90Th -> ^0_-1\\beta{} + ^234_91Pa}",
+ "\\ce{Fe(CN)_{$\\frac{6}{2}$}}",
+ "\\ce{NO_$x$}",
+ "\\ce{NO_${x}$}",
+ "\\ce{$cis${-}[PtCl2(NH3)2]}",
+ "\\ce{{(+)}_589{-}[Co(en)3]Cl3}",
+ "\\ce{KCr(SO4)2*12H2O}",
+ "\\ce{KCr(SO4)2.12H2O}",
+ "\\ce{KCr(SO4)2 * 12 H2O}",
+ "\\ce{C6H5-CHO}",
+ "\\ce{A-B=C#D}",
+ "\\ce{A\\bond{-}B\\bond{=}C\\bond{#}D}",
+ "\\ce{A\\bond{1}B\\bond{2}C\\bond{3}D}",
+ "\\ce{A\\bond{~}B\\bond{~-}C}",
+ "\\ce{A\\bond{~--}B\\bond{~=}C\\bond{-~-}D}",
+ "\\ce{A\\bond{...}B\\bond{....}C}",
+ "\\ce{A\\bond{->}B\\bond{<-}C}",
+ "\\ce{A -> B}",
+ "\\ce{A <- B}",
+ "\\ce{A <-> B}",
+ "\\ce{A <--> B}",
+ "\\ce{A <=> B}",
+ "\\ce{A <=>> B}",
+ "\\ce{A <<=> B}",
+ "\\ce{A ->[H2O] B}",
+ "\\ce{A ->[{text above}][{text below}] B}",
+ "\\ce{A ->[$x$][$x_i$] B}",
+ "\\ce{A ->[${x}$] B}",
+ "\\ce{A + B}",
+ "\\ce{A - B}",
+ "\\ce{A = B}",
+ "\\ce{A \\pm B}",
+ "\\ce{SO4^2- + Ba^2+ -> BaSO4 v}",
+ "\\ce{A v B (v) -> B ^ B (^)}",
+ "\\ce{Zn^2+ <=>[+ 2OH-][+ 2H+] \\underset{\\text{amphoteres Hydroxid}}{\\ce{Zn(OH)2 v}} <=>[+ 2OH-][+ 2H+] \\underset{\\text{Hydroxozikat}}{\\ce{[Zn(OH)4]^2-}}}",
+ "\\ce{\\frac{[Hg^2+][Hg]}{[Hg2^2+]}}",
+ "\\ce{Hg^2+ ->[I-] \\underset{\\mathrm{red}}{\\ce{HgI2}} ->[I-] \\underset{\\mathrm{red}}{\\ce{[Hg^{II}I4]^2-}}}",
+ "\\ce{N2}",
+ "\\ce{O2}",
+ "\\ce{CO2}",
+ "\\begin{align}\\ce{RNO2 &<=>[+e] RNO2^{-.} \\\\ RNO2^{-.} &<=>[+e] RNO2^2-}\\end{align}"
+]