Skip to content
Open
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
2 changes: 1 addition & 1 deletion BuildVersion.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<!-- The release version: bump this, and merging to main tags and publishes vX.Y.Z.
CI passes a pre-release VersionSuffix for pull request builds (pr.12.34 -> 1.0.0-pr.12.34). -->
<VersionPrefix>1.0.5</VersionPrefix>
<VersionPrefix>1.0.6</VersionPrefix>
</PropertyGroup>

<PropertyGroup>
Expand Down
51 changes: 51 additions & 0 deletions src/SharpEquations/Internal/LaTeX/LaTeXReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i)
["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\╱", ["diagdown"] = "\\╲", ["thickspace"] = "\\;",
["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["idotsint"] = "\\∫⋯\\∫",
["mathscr"] = "\\mathcal", ["operatornamewithlimits"] = "\\operatorname", ["mathstrut"] = "\\vphantom{\\(}",
// \Re and \Im, the symbols ℜ and ℑ of LaTeX, which UnicodeMath reads as the function names Re and Im
["Re"] = "ℜ", ["Im"] = "ℑ",
};

private static readonly HashSet<string> OpeningBrackets = new HashSet<string>(StringComparer.Ordinal) { "(", "[", "{", "⟨", "⌈", "⌊" };
Expand Down Expand Up @@ -891,6 +893,21 @@ private static string RewriteTeXOnly(string latex)
continue;
}
}
else if (cw is "sout" or "hcancel" && Argument(tex, j) is var (struck, afterStruck) && afterStruck >= 0)
{
// a horizontal strike: \sout of ulem (and of KaTeX), \hcancel
result.Append(Enclosure("horizontalstrike", RewriteTeXOnly(struck)));
i = afterStruck;
continue;
}
else if (cw == "enclose" && BracedArgument(tex, j) is var (notation, afterNotation) && afterNotation >= 0 &&
BracedArgument(tex, OptionalArgument(tex, afterNotation).Next) is var (enclosed, afterEnclosed) && afterEnclosed >= 0)
{
// \enclose{notation}[attributes]{body} of MathJax: the notations of MathML menclose, parted by commas
result.Append(Enclosure(notation, RewriteTeXOnly(enclosed)));
i = afterEnclosed;
continue;
}
else if (FontSwitches.TryGetValue(cw, out var font))
{
// an old font switch, {\rm rxn}, styles the rest of its group: \mathrm{rxn}
Expand All @@ -905,6 +922,40 @@ private static string RewriteTeXOnly(string latex)
return result.ToString();
}

// the notations of menclose that UnicodeMath has an operator for
private static readonly Dictionary<string, string> EnclosureOperators = new Dictionary<string, string>(StringComparer.Ordinal)
{
["box"] = "▭", ["roundedbox"] = "▢", ["circle"] = "◯", ["longdiv"] = "⟌",
};

// the notations of menclose that UnicodeMath writes as the bits of the mask of ▭ (the sides are those it hides)
private static readonly Dictionary<string, int> EnclosureBits = new Dictionary<string, int>(StringComparer.Ordinal)
{
["top"] = 1, ["bottom"] = 2, ["left"] = 4, ["right"] = 8, ["horizontalstrike"] = 16, ["verticalstrike"] = 32,
["downdiagonalstrike"] = 64, ["updiagonalstrike"] = 128, ["box"] = 15, ["madruwb"] = 10,
};

private static readonly char[] NotationSeparators = { ',', ' ' };

/// <summary>
/// The UnicodeMath of a body in an enclosure of MathML notations (\enclose{top,bottom}{x}): its operator (⟌{x}), the
/// mask of ▭ (▭(28&amp;{x})), or the square root of the radical; a box when none is known.
/// </summary>
private static string Enclosure(string notation, string body)
{
var notations = notation.Split(NotationSeparators, StringSplitOptions.RemoveEmptyEntries);
if (notations.Length == 1 && notations[0] == "radical")
return "\\sqrt{" + body + "}";
if (notations.Length == 1 && EnclosureOperators.TryGetValue(notations[0], out var op))
return op + "{" + body + "}";
// the actuarial, a combining mark before its body in UnicodeMath, and the ▭ of a mask, held through the passes
// (which would put the mark after a base, and take the mask for the argument of ▭) in a group of their own
if (notations.Length == 1 && notations[0] == "actuarial")
return "{{" + HoldMark("⃧") + "(" + body + ")}}";
int mask = notations.Sum(n => EnclosureBits.TryGetValue(n, out var bits) ? bits : 0);
return mask == 0 || mask == 15 ? "▭{" + body + "}" : "{" + HoldMark("▭") + "(" + (mask ^ 15) + "&{" + body + "})}";
}

// the font switches of plain TeX, which style the rest of their group, as the LaTeX commands with an argument
private static readonly Dictionary<string, string> FontSwitches = new Dictionary<string, string>(StringComparer.Ordinal)
{
Expand Down
43 changes: 33 additions & 10 deletions src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,26 @@ internal sealed partial class LaTeXSerializer
["⎵"] = "underbracket", ["⏝"] = "underparen", ["⏟"] = "underbrace", ["⏡"] = "undershell", ["▁"] = "underbar",
};

// the enclosures of Word's LaTeX, as TeX.js writes them (the others are boxed)
private static readonly Dictionary<string, string> Enclosures = new Dictionary<string, string>
{
["box"] = "boxed", ["top"] = "", ["bottom"] = "", ["roundedbox"] = "", ["circle"] = "circle", ["longdiv"] = "", ["actuarial"] = "",
["cancel"] = "cancel", ["bcancel"] = "bcancel", ["xcancel"] = "xcancel", ["updiagonalstrike"] = "cancel",
["box"] = "boxed", ["circle"] = "circle", ["cancel"] = "cancel", ["bcancel"] = "bcancel", ["xcancel"] = "xcancel", ["updiagonalstrike"] = "cancel",
["downdiagonalstrike"] = "bcancel", ["updiagonalstrike downdiagonalstrike"] = "xcancel", ["downdiagonalstrike updiagonalstrike"] = "xcancel",
};

// the enclosures of standard LaTeX that have a command: \boxed, those of the cancel package, \sout of ulem (and
// KaTeX) and \sqrt; the others are \enclose of MathJax
private static readonly Dictionary<string, string> StandardEnclosures = new Dictionary<string, string>
{
["box"] = "boxed", ["cancel"] = "cancel", ["bcancel"] = "bcancel", ["xcancel"] = "xcancel", ["updiagonalstrike"] = "cancel",
["downdiagonalstrike"] = "bcancel", ["updiagonalstrike downdiagonalstrike"] = "xcancel", ["downdiagonalstrike updiagonalstrike"] = "xcancel",
["horizontalstrike"] = "sout", ["radical"] = "sqrt",
};

private static readonly char[] NotationSeparators = { ' ' };

private static readonly string[] Sides = { "top", "bottom", "left", "right" };

private static readonly (string Environment, string Intent)[] MatrixIntents =
{
("pmatrix", ":parenthesized-matrix"), ("vmatrix", ":determinant"), ("Vmatrix", ":normed-matrix"), ("bmatrix", ":bracketed-matrix"),
Expand Down Expand Up @@ -333,14 +346,18 @@ private string TeX(XElement value, bool noAddParens = false)
ret = Unary(value, notation == "top" ? "\\bar" : "\\underline");
break;
}
if (standard && notation == "circle")
// no notation is a box, as UnicodeMath writes it (▭), and so are the four sides
var notations = (notation ?? "").Split(NotationSeparators, StringSplitOptions.RemoveEmptyEntries);
if (notations.Length == 0 || notations.Length == 4 && Sides.All(notations.Contains))
notation = "box";
if (!string.IsNullOrEmpty(notation) && Enclosures.TryGetValue(notation, out var enclosure) && enclosure.Length > 0)
if ((standard ? StandardEnclosures : Enclosures).TryGetValue(notation, out var enclosure))
{
ret = Unary(value, "\\" + enclosure);
break;
}
ret = Unary(value, "\\boxed");
// in standard LaTeX the others (longdiv, roundedbox, circle, left, verticalstrike...) are \enclose of
// MathJax, with its notations parted by commas
ret = standard ? "\\enclose{" + string.Join(",", notations) + "}{" + Nary(value, "", cNode) + "}" : Unary(value, "\\boxed");
break;
}

Expand Down Expand Up @@ -558,7 +575,7 @@ private string TeX(XElement value, bool noAddParens = false)
if (standard && val == "&")
return "\\&";
if (standard && IsFunctionName(val))
return "\\" + val + " ";
return FunctionName(val);
if (OperatorMap.TryGetValue(val, out var mapped))
return mapped;
// escaped for the reader, in standard LaTeX its command if it has one (Ⓢ, \circledS)
Expand Down Expand Up @@ -649,7 +666,7 @@ private string TeX(XElement value, bool noAddParens = false)
}
else if (IsFunctionName(Text(value)))
{
ret = "\\" + Text(value) + " ";
ret = FunctionName(Text(value));
break;
}
else if (Len(Text(value)) > 1 && SharpEquations.Internal.Text.AsciiPatterns.Letters().IsMatch(Text(value)))
Expand Down Expand Up @@ -805,8 +822,14 @@ private static bool IsTallFence(XElement value, string intent)
middle.Any(e => e.DescendantsAndSelf().Any(d => TallElements.Contains(Name(d))));
}

private const string OpeningFences = "([{⟨|‖⌈⌊";
private const string ClosingFences = ")]}⟩|‖⌉⌋";
// the angle brackets also as MTEF writes them, 〈 and 〉 (U+2329 and U+232A), and as CJK brackets (U+3008 and U+3009)
private const string OpeningFences = "([{⟨〈〈|‖⌈⌊";
private const string ClosingFences = ")]}⟩〉〉|‖⌉⌋";

/// <summary>
/// A function name as its command (\sin); Re and Im as \operatorname, \Re and \Im being the symbols ℜ and ℑ.
/// </summary>
private static string FunctionName(string name) => name is "Re" or "Im" ? "\\operatorname{" + name + "}" : "\\" + name + " ";

/// <summary>An mrow between fences that do not pair (]𝑎[, or one side only), read back only with \left and \right.</summary>
private static bool IsUnpairedFence(XElement value, string intent)
Expand Down Expand Up @@ -916,7 +939,7 @@ private static string StandardIdentifier(XElement value)
if (text.Length == 0)
return "{}";
if (IsFunctionName(text))
return "\\" + text + " ";
return FunctionName(text);
var plain = string.Concat(Characters(text).Where(c => c.Length == 1 && (c[0] >= 'A' && c[0] <= 'Z' || c[0] >= 'a' && c[0] <= 'z')));
return plain.Length == text.Length ? "\\operatorname{" + text + "}" : LaTeXSymbols.Math(text);
}
Expand Down
4 changes: 3 additions & 1 deletion src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ private static string Character(string ch)
case "_":
return "\\" + ch;
}
if (Alphanumerics.TryGetValue(ch, out var styled))
// a letterlike symbol with a command of its own is the command (ℑ is \Im, not \mathfrak{I})
bool letterlike = ch.Length == 1 && ch[0] is >= '℀' and <= '⅏' && Commands.ContainsKey(ch);
if (!letterlike && Alphanumerics.TryGetValue(ch, out var styled))
{
var letter = Commands.TryGetValue(styled.Plain, out var greek) ? "\\" + greek + " " : styled.Plain;
// a capital Greek letter is upright in TeX (\Gamma): italic, it is \mathit{\Gamma}
Expand Down
9 changes: 2 additions & 7 deletions src/SharpEquations/Resources/mtef2mml.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -2330,11 +2330,6 @@
<mi><xsl:text disable-output-escaping="yes">&amp;#x2118;</xsl:text></mi>
</xsl:template>

<!-- MATHEMATICAL SCRIPT CAPITAL P -->
<xsl:template match="char[mt_code_value='0x2118' and variation='mathmode']">
<mi><xsl:text disable-output-escaping="yes">&amp;#x1D4AB</xsl:text>;</mi>
</xsl:template>

<!-- MATHEMATICAL SCRIPT CAPITAL P -->
<xsl:template match="char[mt_code_value='0xF10F' and variation='mathmode']">
<mi mathvariant='script'>P</mi>
Expand Down Expand Up @@ -2480,9 +2475,9 @@
<mi mathvariant='script'>k</mi>
</xsl:template>

<!-- MATHEMATICAL SCRIPT SMALL L -->
<!-- SCRIPT SMALL L, the letterlike symbol ℓ (\ell) rather than a script letter l -->
<xsl:template match="char[mt_code_value='0x2113' and variation='mathmode']">
<mi mathvariant='script'>l</mi>
<mi><xsl:text disable-output-escaping="yes">&amp;#x2113;</xsl:text></mi>
</xsl:template>

<!-- MATHEMATICAL SCRIPT SMALL L -->
Expand Down
8 changes: 7 additions & 1 deletion tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ public class LaTeXReaderTests
[Arguments(@"x ^ { 2 } _ i", @"x_i^2", @"𝑥_𝑖^2")]
[Arguments(@"\sum_{\substack{i<n\\i\ne j}} a_i", @"\sum_{\begin{matrix}i<n\\i\ne j\end{matrix}}a_i", @"∑_■(𝑖<𝑛@𝑖≠𝑗) 𝑎_𝑖")]
[Arguments(@"a\cdot b\times c\div d", @"a\cdot b\times c\div d", @"𝑎⋅𝑏×𝑐÷𝑑")]
[Arguments(@"\hbar\ell\Re\Im\aleph", @"\hbar\ell \Re\Im\aleph", @"ℏℓ Re⁡Im⁡ℵ")]
[Arguments(@"\hbar\ell\Re\Im\aleph\wp", @"\hbar\ell\Re\Im\aleph\wp", @"ℏℓℜℑℵ℘")]
[Arguments(@"\Re z+\operatorname{Im}z", @"\Re z+\operatorname{Im}z", @"ℜ𝑧+Im⁡𝑧")]
[Arguments(@"\sout{a+b}+\hcancel x", @"\sout{a+b}+\sout{x}", @"▭(31&𝑎+𝑏)+▭(31&𝑥)")]
[Arguments(@"\enclose{longdiv}{a}+\enclose{top,bottom}{b}+\enclose{left}[mathcolor=red]{c}", @"\enclose{longdiv}{a}+\enclose{bottom,top}{b}+\enclose{left}{c}", @"⟌𝑎+▭(12&𝑏)+▭(11&𝑐)")]
[Arguments(@"\enclose{roundedbox}{a}\enclose{circle}{b}\enclose{box}{c}\enclose{radical}{d}", @"\enclose{roundedbox}{a}\enclose{circle}{b}\boxed{c}\sqrt{d}", @"▢𝑎 ◯𝑏 ▭𝑐 √𝑑")]
[Arguments(@"\sout{x}_1+\enclose{left}{y}^2", @"\sout{x}_1+\enclose{left}{y}^2", @"▭(31&𝑥)₁+▭(11&𝑦)²")]
[Arguments(@"\left\langle\frac{a}{b}\right\rangle", @"\left\langle\frac{a}{b}\right\rangle", @"⟨𝑎/𝑏 ⟩")]
[Arguments(@"\langle a, b\rangle", @"\langle a,b\rangle", @"⟨𝑎,𝑏⟩")]
[Arguments(@"|x|", @"|x|", @"|𝑥|")]
[Arguments(@"\big| x \big|", @"|x|", @"|𝑥|")]
Expand Down
Loading
Loading