From 815ded5341ea0fc697a5882df82f38fbc9a6a0de Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sat, 7 Nov 2026 22:12:13 +0100 Subject: [PATCH 01/45] LaTeX: the test cases of latex2mathml and mml2tex, and what they found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/data/latex holds the 327 conversions and 10 macros of latex2mathml (LaTeX with the MathML it writes), tests/data/mathml the XSpec scenarios and examples of mml2tex (MathML with the LaTeX it writes). The MathML writes LaTeX that reads back stably, the LaTeX reads as latex2mathml reads it once the conventions of its MathML are made the same, and the macros expand to their equivalent; what does not yet is listed, with why, and a case that starts to pass must leave the list. Writer: an empty identifier is {}, not \operatorname{}; the marks over a base are \mathring, \overleftarrow, \overleftrightarrow, \overline; Ⓢ is \circledS; a capital Greek letter is \Gamma upright and \mathit{\Gamma} italic, not \text{Γ} and \Gamma. Reader: two \overset in a row, each grouped whole; \mid a bar that EscapeOddBars escapes without its pair and that is the bar of a ket before ⟩; \mathring, \overleftarrow, \overleftrightarrow, \circledS; a phantom with scripts braced; \_ held before a command. Co-Authored-By: Claude Opus 5.5 --- BuildVersion.props | 2 +- NOTICE | 56 + .../Internal/LaTeX/LaTeXReader.cs | 55 +- .../Internal/LaTeX/LaTeXSerializer.cs | 9 +- .../Internal/LaTeX/LaTeXSymbols.cs | 11 +- .../LaTeX/LaTeXReaderTests.cs | 8 + .../LaTeX/Latex2MathMLCorpusTests.cs | 252 +++ .../MathML/Mml2TexCorpusTests.cs | 58 + tests/data/README.md | 11 + tests/data/latex/latex2mathml-converter.json | 1964 +++++++++++++++++ tests/data/latex/latex2mathml-macros.json | 62 + tests/data/mathml/mml2tex.json | 32 + 12 files changed, 2507 insertions(+), 13 deletions(-) create mode 100644 tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs create mode 100644 tests/SharpEquations.Tests/MathML/Mml2TexCorpusTests.cs create mode 100644 tests/data/latex/latex2mathml-converter.json create mode 100644 tests/data/latex/latex2mathml-macros.json create mode 100644 tests/data/mathml/mml2tex.json diff --git a/BuildVersion.props b/BuildVersion.props index 0a82098..c49d0f2 100644 --- a/BuildVersion.props +++ b/BuildVersion.props @@ -2,7 +2,7 @@ - 1.0.3 + 1.0.4 diff --git a/NOTICE b/NOTICE index 6863e21..5e2c4be 100644 --- a/NOTICE +++ b/NOTICE @@ -182,3 +182,59 @@ 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. + +---------------------------------------------------------------------------- +tests/data/latex/latex2mathml-*.json + +Test cases from https://github.com/roniemartinez/latex2mathml: + +MIT License + +Copyright (c) 2016 Ronie Martinez + +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/mathml/mml2tex.json + +Test cases from https://github.com/transpect/mml2tex: + +Copyright (c) 2015, transpect.io +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +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. diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 36aed34..242e95f 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -263,6 +263,12 @@ private static bool IsParenthesized(string arg) ["overset"] = "┴", ["stackrel"] = "┴", }; + // the commands that put a mark over a base, as \overset{mark}{base} + private static readonly Dictionary OverArrows = new Dictionary(StringComparer.Ordinal) + { + ["mathring"] = "˚", ["overleftarrow"] = "\\leftarrow", ["overleftrightarrow"] = "\\leftrightarrow", + }; + private static bool IsZeroLength(string length) => ZeroLength().IsMatch(length.Trim()); [System.Text.RegularExpressions.GeneratedRegex(@"^0*(\.0*)?\s*[a-z]*$")] @@ -329,6 +335,13 @@ private static string RewriteTeXOnly(string latex) i += 2; continue; } + // \_ held, which the passes would read as a subscript of what follows (\_\{\}) + if (tex[i + 1] == "_") + { + result.Append('\\').Append('\uFDD2'); + i += 2; + continue; + } result.Append(tex[i++]); if (i < tex.Length) result.Append(tex[i++]); @@ -383,6 +396,29 @@ private static string RewriteTeXOnly(string latex) i = j; continue; } + if (cw == "mid" || cw == "circledS") + { + // the relation ∣, a bar that EscapeOddBars escapes when it has no pair (the separator of a bra-ket + // between angles), and Ⓢ, an operator of UnicodeMath + result.Append(cw == "circledS" ? "\\Ⓢ" : angles > 0 ? "│" : "∣"); + i = j; + continue; + } + if (OverArrows.TryGetValue(cw, out var over) && Argument(tex, j) is var (overBase, afterOverBase) && afterOverBase >= 0) + { + // \mathring{a}, \overleftarrow{a}: the mark over the base, as \overset puts it + result.Append(RewriteTeXOnly("\\overset{" + over + "}{" + overBase + "}")); + i = afterOverBase; + continue; + } + if (cw is "phantom" or "vphantom" or "hphantom" && BracedArgument(tex, j) is var (phantom, afterPhantom) && afterPhantom >= 0 && + NonBlank(tex, afterPhantom) is "_" or "^") + { + // a phantom with scripts, \vphantom{\sum}_1^2, braced: UnicodeMath would take the scripts into the phantom + result.Append("{\\").Append(cw).Append('{').Append(RewriteTeXOnly(phantom)).Append("}}"); + i = afterPhantom; + continue; + } if ((cw == "newcommand" || cw == "renewcommand") && j < tex.Length && tex[j] == "\\") { // \newcommand\name{...}: the name in braces, as \newcommand{\name}{...} @@ -478,7 +514,8 @@ private static string RewriteTeXOnly(string latex) var (body, afterBody) = afterScript < 0 ? (null, -1) : BracedArgument(tex, afterScript); if (afterBody >= 0) { - result.Append('〖').Append(RewriteTeXOnly(body)).Append('〗').Append(stack).Append('(').Append(RewriteTeXOnly(script)).Append(')'); + // grouped whole: 〖a〗┴(→)〖b〗┴(→) is not UnicodeMath + result.Append("〖〖").Append(RewriteTeXOnly(body)).Append('〗').Append(stack).Append('(').Append(RewriteTeXOnly(script)).Append(")〗"); i = afterBody; continue; } @@ -613,12 +650,13 @@ private static string EscapeOddBars(string latex) var frames = new Stack>(); frames.Push(new List()); var escaped = new List(); + var kets = new HashSet(); void Close(List bars, bool inBrackets) { - foreach (var bar in new[] { "|", "‖" }) + foreach (var bar in new[] { "|", "‖", "∣" }) { var some = bars.Where(b => tex[b] == bar).ToList(); - if (some.Count % 2 == 1 && !(inBrackets && some.Count == 1 && bar == "|")) + if (some.Count % 2 == 1 && !(inBrackets && some.Count == 1 && bar != "‖")) escaped.Add(some[some.Count - 1]); } } @@ -657,24 +695,27 @@ void Close(List bars, bool inBrackets) Close(frames.Pop(), true); break; case "⟩" when frames.Peek().Count > 0: - // a ket (|𝑟⟩): its bar has no pair + // a ket (|𝑟⟩): its bar has no pair, and ∣ (\mid) there is the bar of the ket + var ket = frames.Peek()[frames.Peek().Count - 1]; + if (tex[ket] == "∣") + kets.Add(ket); frames.Peek().RemoveAt(frames.Peek().Count - 1); break; - case "|" or "‖": + case "|" or "‖" or "∣": frames.Peek().Add(i); break; } } while (frames.Count > 0) Close(frames.Pop(), false); - if (escaped.Count == 0) + if (escaped.Count == 0 && kets.Count == 0) return latex; var result = new StringBuilder(); for (int i = 0; i < tex.Length; i++) { if (escaped.Contains(i)) result.Append('\\'); - result.Append(tex[i]); + result.Append(kets.Contains(i) ? "|" : tex[i]); } return result.ToString(); } diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 117ef71..c0332a8 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -537,8 +537,9 @@ private string TeX(XElement value, bool noAddParens = false) return "\\" + val + " "; if (OperatorMap.TryGetValue(val, out var mapped)) return mapped; + // escaped for the reader, in standard LaTeX its command if it has one (Ⓢ, \circledS) if (IsStructureOperator(val)) - return "\\" + val; + return standard && LaTeXSymbols.Math(val) != val ? LaTeXSymbols.Math(val) : "\\" + val; if (val == "$") { ret = "\\$"; @@ -811,7 +812,8 @@ private string LeftRight(XElement value) private static readonly Dictionary SpacingAccents = new Dictionary { ["^"] = "hat", ["ˆ"] = "hat", ["~"] = "tilde", ["˜"] = "tilde", ["→"] = "vec", ["⃗"] = "vec", ["˙"] = "dot", ["¨"] = "ddot", - ["ˇ"] = "check", ["˘"] = "breve", ["´"] = "acute", ["`"] = "grave", ["‾"] = "bar", ["¯"] = "bar", + ["ˇ"] = "check", ["˘"] = "breve", ["´"] = "acute", ["`"] = "grave", ["‾"] = "bar", ["¯"] = "bar", ["―"] = "overline", + ["˚"] = "mathring", ["←"] = "overleftarrow", ["↔"] = "overleftrightarrow", }; /// A closing fence that is not there: empty in Word mode, also a zero width space in standard LaTeX. @@ -862,6 +864,9 @@ private static string StandardIdentifier(XElement value) return LaTeXSymbols.Math(intent); if (Len(text) != 1) { + // an empty identifier, which holds a place (the base of {}^3, the left side of &=) + if (text.Length == 0) + return "{}"; if (IsFunctionName(text)) return "\\" + text + " "; var plain = string.Concat(Characters(text).Where(c => c.Length == 1 && (c[0] >= 'A' && c[0] <= 'Z' || c[0] >= 'a' && c[0] <= 'z'))); diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index 17e9f1a..946ec0b 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -38,6 +38,7 @@ internal static partial class LaTeXSymbols ["ℵ"] = "aleph", ["∅"] = "emptyset", ["∀"] = "forall", ["∃"] = "exists", ["∄"] = "nexists", ["¬"] = "neg", ["…"] = "ldots", ["⋯"] = "cdots", ["⋮"] = "vdots", ["⋱"] = "ddots", ["°"] = "circ", ["†"] = "dagger", ["‡"] = "ddagger", ["∠"] = "angle", ["△"] = "triangle", ["□"] = "square", ["◊"] = "lozenge", ["⊤"] = "top", ["⊥"] = "perp", ["⊺"] = "intercal", + ["Ⓢ"] = "circledS", // large operators ["∑"] = "sum", ["∏"] = "prod", ["∐"] = "coprod", ["∫"] = "int", ["∬"] = "iint", ["∭"] = "iiint", ["∮"] = "oint", ["⋃"] = "bigcup", ["⋂"] = "bigcap", ["⋁"] = "bigvee", ["⋀"] = "bigwedge", ["⨁"] = "bigoplus", ["⨂"] = "bigotimes", ["⨀"] = "bigodot", @@ -178,7 +179,8 @@ private static string Character(string ch) if (Alphanumerics.TryGetValue(ch, out var styled)) { var letter = Commands.TryGetValue(styled.Plain, out var greek) ? "\\" + greek + " " : styled.Plain; - var font = FontCommands[styled.Style]; + // a capital Greek letter is upright in TeX (\Gamma): italic, it is \mathit{\Gamma} + var font = styled.Style == "mit" && IsCapitalGreek(styled.Plain) && greek != null ? "mathit" : FontCommands[styled.Style]; return font.Length == 0 ? letter : "\\" + font + "{" + letter.TrimEnd() + "}"; } if (Commands.TryGetValue(ch, out var command)) @@ -189,12 +191,15 @@ private static string Character(string ch) /// Upright text in math: \mathrm{x} for a letter, \text{...} for words. public static string Upright(string text) { - // an upright Greek letter is text: \alpha is italic, and \mathrm does not change it + // an upright Greek letter is text: \alpha is italic, and \mathrm does not change it; a capital one (\Gamma) is + // upright already if (Js.Len(text) == 1 && Commands.ContainsKey(text)) - return char.IsLetter(text, 0) ? Text(text) : Math(text); + return char.IsLetter(text, 0) && !IsCapitalGreek(text) ? Text(text) : Math(text); return Js.Len(text) == 1 ? "\\mathrm{" + Math(text) + "}" : Text(text); } + private static bool IsCapitalGreek(string ch) => Js.Len(ch) == 1 && ch[0] is (>= 'Α' and <= 'Ω') or 'ϴ'; + /// \text{...}, with the characters that are special in LaTeX escaped. public static string Text(string text) { diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 896937a..250b8b0 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -69,6 +69,14 @@ public class LaTeXReaderTests [Arguments(@"\mathop{\mathrm{argmin}}_x f", @"\operatorname{argmin}_xf", @"argmin⁡_𝑥 𝑓")] [Arguments(@"x\in\left\{1,2\right\}", @"x\in\{1,2\}", @"𝑥∈{1,2}")] [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(@"\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", @"⇳∑₁²∑₃⁴")] + [Arguments(@"\_\{\}", @"\_\{\}", @"\_{}")] + [Arguments(@"\Gamma=\mathit{\Gamma}", @"\Gamma=\mathit{\Gamma}", @"Γ=𝛤")] [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(@"\langle a, b\rangle", @"\langle a,b\rangle", @"⟨𝑎,𝑏⟩")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs new file mode 100644 index 0000000..b9a6a58 --- /dev/null +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -0,0 +1,252 @@ +#nullable enable + +namespace SharpEquations.Tests +{ + using System; + 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 test cases of latex2mathml: LaTeX with the MathML latex2mathml writes for it, and macros with the LaTeX they + /// expand to. + /// + public class Latex2MathMLCorpusTests + { + public sealed class Conversion + { + public string id { get; set; } = ""; + public string latex { get; set; } = ""; + public bool display { get; set; } + public string mathml { get; set; } = ""; + } + + public sealed class Macro + { + public string id { get; set; } = ""; + public string latex { get; set; } = ""; + public string equivalent { get; set; } = ""; + public bool display { get; set; } + } + + internal static List Conversions() => JsonSerializer.Deserialize>( + File.ReadAllText(TestData.Path("latex", "latex2mathml-converter.json")))!; + + internal static List Macros() => JsonSerializer.Deserialize>( + File.ReadAllText(TestData.Path("latex", "latex2mathml-macros.json")))!; + + private static EquationOptions Options(bool display) => new EquationOptions { Display = display }; + + private static string Compact(Equation equation) => string.Concat(equation.ToMathML().Elements().Select(e => e.ToString(SaveOptions.DisableFormatting))) + .Replace(" xmlns=\"http://www.w3.org/1998/Math/MathML\"", ""); + + private static readonly XNamespace M = "http://www.w3.org/1998/Math/MathML"; + + // the attributes of latex2mathml that only repeat what the MathML operator dictionary or the default says + private static readonly HashSet Defaulted = new HashSet + { + "fence", "form", "stretchy", "minsize", "maxsize", "symmetric", "lspace", "rspace", "columnalign", "rowalign", + "columnspacing", "rowspacing", "columnlines", "rowlines", "accent", "accentunder", "movablelimits", "separator", + "displaystyle", "scriptlevel", + }; + + // the spacing accents of latex2mathml, with the combining marks SharpEquations writes for them + private static readonly Dictionary Accents = new Dictionary + { + ["˙"] = "̇", ["¨"] = "̈", ["˜"] = "̃", ["^"] = "̂", ["ˇ"] = "̌", ["´"] = "́", + ["`"] = "̀", ["˘"] = "̆", ["˚"] = "̊", ["→"] = "⃗", ["⃛"] = "⃛", ["⃜"] = "⃜", + }; + + private static bool IsSpace(string text) => text.Length > 0 && text.All(c => c == ' ' || c == ' ' || c is >= ' ' and <= '​' || c == ' '); + + private static readonly HashSet InferredRow = new HashSet { "math", "mrow", "mtd", "msqrt", "mstyle", "mpadded", "mphantom", "menclose", "mtext" }; + + /// + /// The equation as MathML, with what latex2mathml and SharpEquations write differently for the same equation + /// made the same: attributes that say the default or set the style, spaces (formatting, which an equation does not + /// keep), invisible function application and times, the middle dot of \cdot (· for latex2mathml, ⋅ for + /// SharpEquations), names of operators and primes (mo for latex2mathml, mi and mo for SharpEquations), spacing + /// accents (˙ for latex2mathml, the combining ̇ for SharpEquations), bars (mover with ¯ for latex2mathml, + /// menclose for SharpEquations), capital Greek letters (upright in TeX, which latex2mathml writes as mi without + /// mathvariant), rows that group nothing. + /// + private static string Canonical(Equation equation) + { + var math = new XElement(equation.ToMathML()); + foreach (var e in math.DescendantsAndSelf().ToList()) + { + e.Attributes().Where(a => Defaulted.Contains(a.Name.LocalName)).Remove(); + var name = e.Name.LocalName; + var text = e.HasElements ? null : e.Value; + switch (name) + { + case "mspace": + case "mo" or "mtext" when text != null && IsSpace(text): + case "mo" when text is "⁡" or "⁢": + e.Remove(); + break; + case "mo" when text == "·": + e.Value = "⋅"; + break; + case "mo" when text != null && Accents.TryGetValue(text, out var mark) && e.Parent!.Name.LocalName is "mover" && e.ElementsBeforeSelf().Any(): + e.Value = mark; + break; + case "mi" or "mo" when text != null && text.Length > 1 && text.All(char.IsLetter): + case "mi" when text != null && text.Length > 0 && text.All(c => c is '′' or '″' or '‴' or '⁗'): + e.Name = M + (text.All(char.IsLetter) ? "mi" : "mo"); + break; + case "mi" when text is { Length: 1 } && text[0] is >= 'Α' and <= 'Ω' && e.Attribute("mathvariant") == null: + e.SetAttributeValue("mathvariant", "normal"); + break; + } + } + foreach (var e in math.Descendants(M + "mover").Where(e => e.Elements().Skip(1).FirstOrDefault()?.Value is "¯" or "‾" or "―" or "̅").ToList()) + e.ReplaceWith(new XElement(M + "menclose", new XAttribute("notation", "top"), e.Elements().First())); + bool changed; + do + { + changed = false; + foreach (var e in math.Descendants().Where(e => e.Name.LocalName is "mrow" or "mstyle" && !e.HasAttributes).ToList()) + if (e.Elements().Count() == 1 || InferredRow.Contains(e.Parent!.Name.LocalName)) + { + e.ReplaceWith(e.Nodes()); + changed = true; + } + } + while (changed); + return string.Concat(math.Elements().Select(e => e.ToString(SaveOptions.DisableFormatting))).Replace(" xmlns=\"" + M.NamespaceName + "\"", ""); + } + + // what does not read back as it was written, through the UnicodeMath that LaTeX is read as: a name of operator + // alone (\operatorname{lg}, which UnicodeMath reads as the letters l g), bars with nothing between them (|\,|\:|, + // \{\middle|\}), a parenthesis alone in a phantom (\mathstrut, \phantom{(}) + private static readonly HashSet NotWrittenBack = new HashSet + { + "spaces", "lg", "middle", "mathstrut", + }; + + // what the reader of LaTeX, which reads LaTeX as Word does, reads otherwise than latex2mathml: the commands Word + // does not know (\href, \class, \style, \color, \raise, \smash, \mathchoice, \LaTeX, the sizes, \hbox, \verb, + // the old font switches \rm, \cal, \Bbb...), the environments and arguments it does not take (matrix*, \matrix{}, + // the column specification of array, \hline, \substack, \sideset, \genfrac, \above, \atop, unbraced arguments as + // \underset ab), \limits and \mathop, spaces before scripts (x ^ { 2 }), a script without base, and the symbols + // it maps to other characters or does not have (\gt, \lt, \And, \S, \omicron, \dotsb, \shortmid...) + private static readonly HashSet ReadOtherwise = new HashSet + { + "307-a", "307-b", "And", "Bmatrix", "LaTeX", "LaTeX-with-style", "S", "TeX", "TeX-with-style", "above", + "abovewithdelims", "align-nonumber", "align-notag", "align0", "align1", "argmax", + "array-with-horizontal-lines", "atop-and-atopwithdelims", "backslash-e", "bigcirc", + "bigl-smallmatrix-bigr", "binomial-coefficients", "blackboard-bold", "boldsymbol", "boxed-box", "bra", + "brace", "brace-1", "braket", "calligraphic-mode", "centerdot", "cfrac", "circledS", "class", + "colorbox", "comments", "degree", "diagdown-diagup", "displaylines", "dotsb", "dotsc", "dotsi", "dotsm", + "dotso", "emph", "empty-color-works", "empty-subscript", "eqalign", "escaped-characters", + "exponent-without-base-works", "fbox", "fcolorbox", "footnotesize", "fraktur", "genfrac-displaystyle", + "genfrac-scriptscriptstyle", "genfrac-scriptstyle", "genfrac-textstyle", "gggtr", "global-fonts", + "greatest-common-divisor", "gt", "gvertneqq", "hbox", "hbox-with-backslash-in-text", + "hbox-with-math-mode", "hfil", "hphantom", "href", "huge", "idotsint", "injlim", "intop", "issue-106", + "issue-118", "issue-125-1-overset", "issue-125-3-underset", "issue-125-4-limits", + "issue-125-4-mathop", "issue-301-a", "issue-301-b", "issue-301-c", "issue-33", "issue-42", + "issue-498-prod", "issue-498-sum", "issue-60-1", "issue-60-2", "issue-75-1-row", "issue-75-2-rows", + "issue-76", "ket", "lVert-double", "large", "ldotp", "leftroot-uproot", "lg", "liminf", + "limit-at-plus-infinity", "limsup", "llap", "llless", "lower", "lt", "lvertneqq", + "math-commands-that-currently-does-nothing", "mathbf", "mathbin", "mathcal", "mathchoice-block", + "mathchoice-inline", "mathfrak", "mathit", "mathllap", "mathop", "mathord", "mathrel", + "mathrm", "mathscr", "mathsf", "mathsfit", "mathstrut", "mathtt", "matrix-with-alignment", + "matrix-without-begin-and-end", "middle", "mit", "moveleft", "moveright", "newcommand-escaped-hash", + "newenvironment-multi-node", "ngeqq", "normalsize-scriptsize", "not", "nshortmid", "nshortparallel", + "nsubseteqq", "null-delimiter", "numbers-identifiers-and-operators", "oldstyle", "omicron", + "operatorname-star", "operatornamewithlimits", "overbrace-c", + "pod", "prime-no-base", "projlim", "rVert-double", "raise", "rule", "rvert", "scr", "shortmid", + "shoveleft", "sideset", "skew", "skew-with-braces", "small-tiny", "smallfrown", "smallint", + "smallsmile", "smash", "smash-bottom", "smash-top", "spaces", "split", "style", "tag", "tag-star", + "textbf", "textcolor", "textit", "textrm", "textsf", "texttt", "thickapprox", "thicksim", "thickspace", + "tilde-1", "tilde-and-space-in-text", "tt", "underbrace-c", "underleftarrow", "underleftrightarrow", + "underline", "underrightarrow", "unicode", "upper-Vmatrix", "varinjlim", "varsubsetneqq", + "varsupsetneq", "varsupsetneqq", "vcenter", "verb", "vmatrix", "widecheck", "widetilde", + "xLeftarrow-double", "xhookleftarrow", "xleftarrow", "xleftarrow-with-argument", "xleftrightarrow", + "xrightarrow", "xrightarrow-with-argument", + }; + + // 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 + private static readonly HashSet NotExpanded = new HashSet + { + "declare-math-operator", "newcommand-optional-default", "newcommand-empty-body", "newenvironment", + "newenvironment-with-args", + }; + + private static string? Check(Func check) + { + try + { + return check(); + } + catch (Exception e) + { + return e.GetType().Name + ": " + e.Message; + } + } + + /// + /// The cases that fail and are not known to, and the known ones that no longer fail (to take out of the list). + /// + private static List Unexpected(IEnumerable cases, Func id, Func input, HashSet known, Func check) + { + var failures = cases.Select(c => (Id: id(c), Input: input(c), Failure: Check(() => check(c)))).ToList(); + var failed = new HashSet(failures.Where(f => f.Failure != null).Select(f => f.Id)); + return failures.Where(f => f.Failure != null && !known.Contains(f.Id)).Select(f => f.Id + ": " + f.Input + "\n " + f.Failure) + .Concat(known.Where(k => !failed.Contains(k)).Select(k => k + ": passes, take it out of the known failures")) + .ToList(); + } + + [Test] + public async Task CorpusIsComplete() + { + await Assert.That(Conversions()).Count().IsEqualTo(327); + await Assert.That(Macros()).Count().IsEqualTo(10); + } + + // the MathML of latex2mathml writes LaTeX that, once read back, reads back to itself + [Test] + public async Task MathMLWritesLaTeXThatReadsBack() + { + var unexpected = Unexpected(Conversions(), c => c.id, c => c.latex, NotWrittenBack, c => + { + var latex = Equation.Parse(c.mathml, EquationFormat.MathML).ToString(EquationFormat.LaTeX); + var once = Equation.Parse(latex, EquationFormat.LaTeX, Options(c.display)); + var twice = Equation.Parse(once.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX, Options(c.display)); + return once == twice ? null : latex + "\n " + once.ToString(EquationFormat.LaTeX); + }); + await Assert.That(unexpected).IsEmpty(); + } + + // the LaTeX reads as the equation of the MathML latex2mathml writes for it + [Test] + public async Task LaTeXReadsAsLatex2MathMLReadsIt() + { + var unexpected = Unexpected(Conversions(), c => c.id, c => c.latex, ReadOtherwise, c => + { + var expected = Equation.Parse(c.mathml, EquationFormat.MathML); + var actual = Equation.Parse(c.latex, EquationFormat.LaTeX, Options(c.display)).WithDisplay(expected.IsDisplay); + return actual == expected || Canonical(actual) == Canonical(expected) ? null : "expected " + Canonical(expected) + "\n actual " + Canonical(actual); + }); + await Assert.That(unexpected).IsEmpty(); + } + + [Test] + public async Task MacrosExpandToTheirEquivalent() + { + var unexpected = Unexpected(Macros(), m => m.id, m => m.latex, NotExpanded, m => + { + var expected = Equation.Parse(m.equivalent, EquationFormat.LaTeX, Options(m.display)); + var actual = Equation.Parse(m.latex, EquationFormat.LaTeX, Options(m.display)); + return actual == expected ? null : expected.ToString(EquationFormat.LaTeX) + "\n " + actual.ToString(EquationFormat.LaTeX); + }); + await Assert.That(unexpected).IsEmpty(); + } + } +} diff --git a/tests/SharpEquations.Tests/MathML/Mml2TexCorpusTests.cs b/tests/SharpEquations.Tests/MathML/Mml2TexCorpusTests.cs new file mode 100644 index 0000000..e0772ed --- /dev/null +++ b/tests/SharpEquations.Tests/MathML/Mml2TexCorpusTests.cs @@ -0,0 +1,58 @@ +#nullable enable + +namespace SharpEquations.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json; + using System.Threading.Tasks; + using SharpEquations; + + /// + /// The test cases of mml2tex: MathML with the LaTeX mml2tex writes for it (its XSpec scenarios), and the MathML of its + /// examples, which have none. + /// + public class Mml2TexCorpusTests + { + public sealed class Case + { + public string source { get; set; } = ""; + public string? label { get; set; } + public string mathml { get; set; } = ""; + public string? tex { get; set; } + } + + private static List Cases() => JsonSerializer.Deserialize>( + File.ReadAllText(TestData.Path("mathml", "mml2tex.json")))!; + + public static IEnumerable> All() => Cases().Select(c => (System.Func)(() => c)); + + public static IEnumerable> WithTeX() => Cases().Where(c => c.tex != null).Select(c => (System.Func)(() => c)); + + // the LaTeX SharpEquations writes reads as the LaTeX mml2tex writes + [Test] + [MethodDataSource(nameof(WithTeX))] + public async Task WritesLaTeXThatReadsAsMml2TexLaTeX(Case c) + { + var equation = Equation.Parse(c.mathml, EquationFormat.MathML); + var options = new EquationOptions { Display = equation.IsDisplay }; + var ours = Equation.Parse(equation.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX, options); + var theirs = Equation.Parse(c.tex!, EquationFormat.LaTeX, options); + await Assert.That(ours).IsEqualTo(theirs).Because(c.label + ": " + equation.ToString(EquationFormat.LaTeX) + " / " + c.tex); + } + + // written once, the equation loses what LaTeX cannot hold (the alignment of the columns, 11 that is a + // number, the italic of Γ, which TeX writes upright) and reads back to itself from then on + [Test] + [MethodDataSource(nameof(All))] + public async Task WritesLaTeXThatReadsBack(Case c) + { + var equation = Equation.Parse(c.mathml, EquationFormat.MathML); + var options = new EquationOptions { Display = equation.IsDisplay }; + var once = Equation.Parse(equation.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX, options); + var twice = Equation.Parse(once.ToString(EquationFormat.LaTeX), EquationFormat.LaTeX, options); + await Assert.That(twice).IsEqualTo(once).Because(once.ToString(EquationFormat.LaTeX)); + } + } +} diff --git a/tests/data/README.md b/tests/data/README.md index b43b99e..537847c 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -59,3 +59,14 @@ author expects for each (the `results` table of `dist/unicodemathml-integration. [MurrayIII/UnicodeMathML](https://github.com/MurrayIII/UnicodeMathML) (commit 636dc8f), as `{mathml, unicodemath, tex}`: MathML with the UnicodeMath and the TeX its author expects for it. Copyright (c) 2019 Noah Doersing, MIT License; maintained by Murray Sargent. + +`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`, +character references resolved), as `{id, latex, display, mathml}`; `latex/latex2mathml-macros.json`: the `test_macro` +cases, as `{id, latex, equivalent, display}`. Copyright (c) 2016 Ronie Martinez, MIT License. + +`mathml/mml2tex.json`: the scenarios of `xspec/*.xspec` of [transpect/mml2tex](https://github.com/transpect/mml2tex) +(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. diff --git a/tests/data/latex/latex2mathml-converter.json b/tests/data/latex/latex2mathml-converter.json new file mode 100644 index 0000000..824f632 --- /dev/null +++ b/tests/data/latex/latex2mathml-converter.json @@ -0,0 +1,1964 @@ +[ + { + "id": "single-identifier", + "latex": "x", + "display": true, + "mathml": "x" + }, + { + "id": "multiple-identifier", + "latex": "xyz", + "display": true, + "mathml": "xyz" + }, + { + "id": "single-number", + "latex": "3", + "display": true, + "mathml": "3" + }, + { + "id": "multiple-numbers", + "latex": "333", + "display": true, + "mathml": "333" + }, + { + "id": "decimal-numbers", + "latex": "12.34", + "display": true, + "mathml": "12.34" + }, + { + "id": "numbers-and-identifiers", + "latex": "12x", + "display": true, + "mathml": "12x" + }, + { + "id": "single-operator", + "latex": "+", + "display": true, + "mathml": "+" + }, + { + "id": "numbers-and-operators", + "latex": "3-2", + "display": true, + "mathml": "3−2" + }, + { + "id": "numbers-and-operators-times", + "latex": "3 \\times 2", + "display": true, + "mathml": "3×2" + }, + { + "id": "numbers-and-operators-cdot", + "latex": "3 \\cdot 2", + "display": true, + "mathml": "3·2" + }, + { + "id": "numbers-and-operators-div", + "latex": "3 \\div 2", + "display": true, + "mathml": "3÷2" + }, + { + "id": "numbers-identifiers-and-operators", + "latex": "3x*2", + "display": true, + "mathml": "3x*2" + }, + { + "id": "single-group", + "latex": "{a}", + "display": true, + "mathml": "a" + }, + { + "id": "multiple-groups", + "latex": "{a}{b}", + "display": true, + "mathml": "ab" + }, + { + "id": "inner-group", + "latex": "{a+{b}}", + "display": true, + "mathml": "a+b" + }, + { + "id": "over", + "latex": "1 \\over 2", + "display": true, + "mathml": "12" + }, + { + "id": "over-inside-braces", + "latex": "{1 \\over 2}", + "display": true, + "mathml": "12" + }, + { + "id": "complex-matrix", + "latex": "\\begin{matrix}a_{1} & b_{2} \\\\ c_{3} & d_{4} \\end{matrix}", + "display": true, + "mathml": "a1b2c3d4" + }, + { + "id": "null-delimiter", + "latex": "\\left\\{ \\begin{array} { l } { 3x - 5y + 4z = 0} \\\\ { x - y + 8z = 0} \\\\ { 2x - 6y + z = 0} \\end{array} \\right.", + "display": true, + "mathml": "{3x−5y+4z=0x−y+8z=02x−6y+z=0" + }, + { + "id": "subscript", + "latex": "a_b", + "display": true, + "mathml": "ab" + }, + { + "id": "superscript", + "latex": "a^b", + "display": true, + "mathml": "ab" + }, + { + "id": "subscript-and-superscript", + "latex": "a_b^c", + "display": true, + "mathml": "abc" + }, + { + "id": "superscript-and-subscript", + "latex": "a^b_c", + "display": true, + "mathml": "acb" + }, + { + "id": "subscript-within-curly-braces", + "latex": "{a_b}", + "display": true, + "mathml": "ab" + }, + { + "id": "superscript-within-curly-braces", + "latex": "{a^b}", + "display": true, + "mathml": "ab" + }, + { + "id": "superscript-subscript-and-curly-braces", + "latex": "a^{i+1}_3", + "display": true, + "mathml": "a3i+1" + }, + { + "id": "simple-fraction", + "latex": "\\frac{1}{2}", + "display": true, + "mathml": "12" + }, + { + "id": "square-root", + "latex": "\\sqrt{2}", + "display": true, + "mathml": "2" + }, + { + "id": "root", + "latex": "\\sqrt[3]{2}", + "display": true, + "mathml": "23" + }, + { + "id": "binomial", + "latex": "\\binom{2}{3}", + "display": true, + "mathml": "(23)" + }, + { + "id": "left-and-right", + "latex": "\\left(x\\right)", + "display": true, + "mathml": "(x)" + }, + { + "id": "space", + "latex": "\\,", + "display": true, + "mathml": "" + }, + { + "id": "matrix", + "latex": "\\begin{matrix}a & b \\\\ c & d \\end{matrix}", + "display": true, + "mathml": "abcd" + }, + { + "id": "matrix-without-begin-and-end", + "latex": "\\matrix{a & b \\\\ c & d}", + "display": true, + "mathml": "abcd" + }, + { + "id": "matrix-with-alignment", + "latex": "\\begin{matrix*}[r]a & b \\\\ c & d \\end{matrix*}", + "display": true, + "mathml": "abcd" + }, + { + "id": "matrix-with-negative-sign", + "latex": "\\begin{matrix}-a & b \\\\ c & d \\end{matrix}", + "display": true, + "mathml": "−abcd" + }, + { + "id": "pmatrix", + "latex": "\\begin{pmatrix}a & b \\\\ c & d \\end{pmatrix}", + "display": true, + "mathml": "(abcd)" + }, + { + "id": "simple-array", + "latex": "\\begin{array}{cr} 1 & 2 \\\\ 3 & 4 \\end{array}", + "display": true, + "mathml": "1234" + }, + { + "id": "array-with-vertical-bar", + "latex": "\\begin{array}{c|rl} 1 & 2 & 3 \\\\ 4 & 5 & 6 \\end{array}", + "display": true, + "mathml": "123456" + }, + { + "id": "array-with-horizontal-lines", + "latex": "\\begin{array}{cr} 1 & 2 \\\\ 3 & 4 \\\\ \\hline 5 & 6 \\\\ \\hdashline 7 & 8 \\end{array}", + "display": true, + "mathml": "12345678" + }, + { + "id": "issue-33", + "latex": "\\begin{bmatrix}\n a_{1,1} & a_{1,2} & \\cdots & a_{1,n} \\\\\n a_{2,1} & a_{2,2} & \\cdots & a_{2,n} \\\\\n \\vdots & \\vdots & \\ddots & \\vdots \\\\\n a_{m,1} & a_{m,2} & \\cdots & a_{m,n}\n \\end{bmatrix}", + "display": true, + "mathml": "[a1,1a1,2⋯a1,na2,1a2,2⋯a2,n⋮⋮⋱⋮am,1am,2⋯am,n]" + }, + { + "id": "issue-42", + "latex": "\\sqrt { ( - 25 ) ^ { 2 } } = \\pm 25", + "display": true, + "mathml": "(−25)2=±25" + }, + { + "id": "issue-45-lt", + "latex": "2 < 5", + "display": true, + "mathml": "2<5" + }, + { + "id": "issue-45-gt", + "latex": "2 > 5", + "display": true, + "mathml": "2>5" + }, + { + "id": "And", + "latex": "\\And", + "display": true, + "mathml": "&" + }, + { + "id": "issue-44", + "latex": "\\left(- x^{3} + 5\\right)^{5}", + "display": true, + "mathml": "(−x3+5)5" + }, + { + "id": "issue-51", + "latex": "\\mathbb{R}", + "display": true, + "mathml": "ℝ" + }, + { + "id": "issue-52", + "latex": "\\bar{z_1} = z_2", + "display": true, + "mathml": "z1¯=z2" + }, + { + "id": "issue-60-1", + "latex": "\\mathrm{...}", + "display": true, + "mathml": "..." + }, + { + "id": "issue-60-2", + "latex": "\\mathrm{...}+\\mathrm{...}", + "display": true, + "mathml": "...+..." + }, + { + "id": "issue-61", + "latex": "\\frac{x + 4}{x + \\frac{123 \\left(\\sqrt{x} + 5\\right)}{x + 4} - 8}", + "display": true, + "mathml": "x+4x+123(x+5)x+4−8" + }, + { + "id": "issue-63", + "latex": "\\sqrt {\\sqrt {\\left( x^{3}\\right) + v}}", + "display": true, + "mathml": "(x3)+v" + }, + { + "id": "empty-subscript", + "latex": "1_{}", + "display": true, + "mathml": "1" + }, + { + "id": "Bmatrix", + "latex": "\\begin{Bmatrix}\\end{Bmatrix}", + "display": true, + "mathml": "{}" + }, + { + "id": "vmatrix", + "latex": "\\begin{vmatrix}\\end{vmatrix}", + "display": true, + "mathml": "||" + }, + { + "id": "upper-Vmatrix", + "latex": "\\begin{Vmatrix}\\end{Vmatrix}", + "display": true, + "mathml": "‖‖" + }, + { + "id": "command-inside-matrix", + "latex": "\\begin{matrix}1^2\\end{matrix}", + "display": true, + "mathml": "12" + }, + { + "id": "backslash-e", + "latex": "\\e", + "display": true, + "mathml": "\\e" + }, + { + "id": "issue-77", + "latex": "\\left[\\begin{matrix}1 & 0 & 0 & 0\\\\0 & 1 & 0 & 0\\\\0 & 0 & 1 & 0\\\\0 & 0 & 0 & 1\\end{matrix}\\right]", + "display": true, + "mathml": "[1000010000100001]" + }, + { + "id": "logarithm", + "latex": "\\log{x}", + "display": true, + "mathml": "logx" + }, + { + "id": "logarithm-with-base", + "latex": "\\log_2{x}", + "display": true, + "mathml": "log2x" + }, + { + "id": "exponent-without-base-works", + "latex": "^3", + "display": true, + "mathml": "3" + }, + { + "id": "limit-at-plus-infinity", + "latex": "\\lim_{x \\to +\\infty} f(x)", + "display": true, + "mathml": "limx→+∞f(x)" + }, + { + "id": "inf", + "latex": "\\inf_{x > s}f(x)", + "display": true, + "mathml": "infx>sf(x)" + }, + { + "id": "issue-76", + "latex": "\\int\\limits_{0}^{\\pi}", + "display": true, + "mathml": "∫0π" + }, + { + "id": "issue-301-a", + "latex": "\\int\\limits^{\\pi}_{0}", + "display": true, + "mathml": "∫0π" + }, + { + "id": "issue-301-b", + "latex": "\\int\\limits_{\\pi}", + "display": true, + "mathml": "∫π" + }, + { + "id": "issue-301-c", + "latex": "\\int\\limits^{\\pi}", + "display": true, + "mathml": "∫π" + }, + { + "id": "issue-75-1-row", + "latex": "\\substack{ \\xi{2}=g{\\left(x \\right)}}", + "display": true, + "mathml": "ξ2=g(x)" + }, + { + "id": "issue-75-2-rows", + "latex": "\\sum_{\\substack{1\\le i\\le n\\\\ i\\ne j}}", + "display": true, + "mathml": "∑1≤i≤ni≠j" + }, + { + "id": "issue-91", + "latex": "\\tan x+\\sec x+\\cos x+\\sin x+\\cot x+\\csc x+\\arccos x+\\arcsin x+\\arctan x +\\cosh x+\\coth x+\\sinh x+\\tanh x", + "display": true, + "mathml": "tanx+secx+cosx+sinx+cotx+cscx+arccosx+arcsinx+arctanx+coshx+cothx+sinhx+tanhx" + }, + { + "id": "issue-98", + "latex": "p_{\\max}", + "display": true, + "mathml": "pmax" + }, + { + "id": "issue-103", + "latex": "\\vec{AB}", + "display": true, + "mathml": "AB→" + }, + { + "id": "issue-106", + "latex": "\\begin{cases} {x=1} \\\\ {y=-2}\\end{cases}", + "display": true, + "mathml": "{x=1y=−2" + }, + { + "id": "issue-108-1", + "latex": "\\max f", + "display": true, + "mathml": "maxf" + }, + { + "id": "issue-108-2", + "latex": "\\max \\{a, b, c\\}", + "display": true, + "mathml": "max{a,b,c}" + }, + { + "id": "issue-108-3", + "latex": "\\min{(x, y)}", + "display": true, + "mathml": "min(x,y)" + }, + { + "id": "issue-112-1", + "latex": "\\dot A", + "display": true, + "mathml": "A˙" + }, + { + "id": "issue-112-2", + "latex": "\\dot{A}", + "display": true, + "mathml": "A˙" + }, + { + "id": "issue-109-1", + "latex": "\\operatorname{sn}x", + "display": true, + "mathml": "snx" + }, + { + "id": "issue-109-2", + "latex": "\\operatorname{sn}(x+y)", + "display": true, + "mathml": "sn(x+y)" + }, + { + "id": "issue-118", + "latex": "\\text{Let}\\ x=\\text{number of cats}.", + "display": true, + "mathml": "Let\u00A0x=number\u00A0of\u00A0cats." + }, + { + "id": "issue-125-1-overset", + "latex": "F(a,n)=\\overset{a-a-a\\cdots-a}{}ntext{个}a", + "display": true, + "mathml": "F(a,n)=a−a−a⋯−antext个a" + }, + { + "id": "issue-125-2-overset", + "latex": "a\\,\\overset{?}{=}\\,b", + "display": true, + "mathml": "a=?b" + }, + { + "id": "issue-125-3-underset", + "latex": "\\underset ab", + "display": true, + "mathml": "ba" + }, + { + "id": "issue-125-4-mathop", + "latex": "a\\mathop{t}b\\mathop{t}c", + "display": true, + "mathml": "atbtc" + }, + { + "id": "issue-125-4-limits", + "latex": "\\mathop{x}\\limits_0^1", + "display": true, + "mathml": "x01" + }, + { + "id": "big", + "latex": "\\Bigg[\\bigg[\\Big[\\big[[", + "display": true, + "mathml": "[[[[[" + }, + { + "id": "global-fonts", + "latex": "x\\rm {\\text{var} = 1+\\{b\\}}\\sf \\Delta", + "display": true, + "mathml": "xvar=1+{b}Δ" + }, + { + "id": "prime", + "latex": "f'(x) = 2x, f''(x) = 2", + "display": true, + "mathml": "f′(x)=2x,f″(x)=2" + }, + { + "id": "issue-462-triple-prime", + "latex": "f'''(x)", + "display": true, + "mathml": "f‴(x)" + }, + { + "id": "issue-462-quadruple-prime", + "latex": "f''''(x)", + "display": true, + "mathml": "f⁗(x)" + }, + { + "id": "issue-462-quintuple-prime", + "latex": "f'''''(x)", + "display": true, + "mathml": "f′′′′′(x)" + }, + { + "id": "issue-462-octuple-prime", + "latex": "f''''''''(x)", + "display": true, + "mathml": "f′′′′′′′′(x)" + }, + { + "id": "prime-no-base", + "latex": "'x", + "display": true, + "mathml": "′x" + }, + { + "id": "spaces", + "latex": "|\\,|\\:|\\>|\\;|\\\\|\\!|\\quad|\\qquad|\\hspace1em|\\hspace{10ex}|\\enspace|\\hskip1em|\\kern-1.5pt|\\mkern10mu|\n \\mskip18mu|\\mspace18mu|\\negthinspace|\\negmedspace|\\negthickspace|\\nobreakspace|\\space|\\thinspace|", + "display": true, + "mathml": "||||||||||||||||||||\u00A0|\u00A0||" + }, + { + "id": "pipe", + "latex": "|x|", + "display": true, + "mathml": "|x|" + }, + { + "id": "double-pipe", + "latex": "\\|x\\|", + "display": true, + "mathml": "‖x‖" + }, + { + "id": "tilde", + "latex": "Hello~World", + "display": true, + "mathml": "Hello\u00A0World" + }, + { + "id": "tilde-and-space-in-text", + "latex": "\\text{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "comments", + "latex": "% this is hidden\n 100\\%!% this is hidden, too", + "display": true, + "mathml": "100%!" + }, + { + "id": "escaped-characters", + "latex": "\\#\\$\\%\\&\\_\\{\\}", + "display": true, + "mathml": "#$%&_{}" + }, + { + "id": "above", + "latex": "{a \\above 1pt b} + {c \\above {1.5pt} d}", + "display": true, + "mathml": "ab+cd" + }, + { + "id": "acute", + "latex": "\\acute a \\acute{bc}", + "display": true, + "mathml": "a´bc´" + }, + { + "id": "atop-and-atopwithdelims", + "latex": "a \\atop {b \\atopwithdelims | \\} c}", + "display": true, + "mathml": "a|bc}" + }, + { + "id": "abovewithdelims", + "latex": "{a \\abovewithdelims [ ] 1pt b} + {c \\abovewithdelims . . {1.5pt} d}", + "display": true, + "mathml": "[ab]+cd" + }, + { + "id": "blackboard-bold", + "latex": "\\Bbb {\\text{var} = 1+\\{b\\}}", + "display": true, + "mathml": "var=1+{b}" + }, + { + "id": "Bbb-group", + "latex": "\\Bbb{AB}C", + "display": true, + "mathml": "ABC" + }, + { + "id": "bigcirc", + "latex": "\\bigcirc", + "display": true, + "mathml": "◯" + }, + { + "id": "boldsymbol", + "latex": "\\boldsymbol {\\text{var} = 1+\\{b\\}}", + "display": true, + "mathml": "var=1+{b}" + }, + { + "id": "boxed-box", + "latex": "\\boxed \\Box", + "display": true, + "mathml": "◻" + }, + { + "id": "breve", + "latex": "\\breve a \\breve{bc}", + "display": true, + "mathml": "a˘bc˘" + }, + { + "id": "brace", + "latex": "{\\brace} + {a \\brace b}", + "display": true, + "mathml": "{}+{ab}" + }, + { + "id": "brace-1", + "latex": "{\\brack} + {a \\brack b}", + "display": true, + "mathml": "[]+[ab]" + }, + { + "id": "calligraphic-mode", + "latex": "{\\cal {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "centerdot", + "latex": "a\\centerdot b", + "display": true, + "mathml": "a⬝b" + }, + { + "id": "cfrac", + "latex": "\\cfrac{2}{1+ \\cfrac{2}{1}}", + "display": true, + "mathml": "21+21" + }, + { + "id": "check", + "latex": "\\check a \\check{bc}", + "display": true, + "mathml": "aˇbcˇ" + }, + { + "id": "choose", + "latex": "a \\choose b", + "display": true, + "mathml": "(ab)" + }, + { + "id": "circledS", + "latex": "\\circledS", + "display": true, + "mathml": "Ⓢ" + }, + { + "id": "color", + "latex": "{a\\color{red}bc}d", + "display": true, + "mathml": "abcd" + }, + { + "id": "empty-color-works", + "latex": "\\color{}ab", + "display": true, + "mathml": "ab" + }, + { + "id": "binomial-coefficients", + "latex": "\\dbinom a b", + "display": true, + "mathml": "(ab)" + }, + { + "id": "ddot-dddot-ddddot", + "latex": "\\ddot a \\dddot b \\ddddot c", + "display": true, + "mathml": "a¨b⃛c⃜" + }, + { + "id": "degree-polynomial", + "latex": "\\deg(f(x))", + "display": true, + "mathml": "deg(f(x))" + }, + { + "id": "determinant", + "latex": "\\det(A)", + "display": true, + "mathml": "det(A)" + }, + { + "id": "dimension-vector-space", + "latex": "\\dim(A)", + "display": true, + "mathml": "dim(A)" + }, + { + "id": "dfrac", + "latex": "\\dfrac a b", + "display": true, + "mathml": "ab" + }, + { + "id": "diagdown-diagup", + "latex": "\\diagdown \\diagup", + "display": true, + "mathml": "╲╱" + }, + { + "id": "dots", + "latex": "x_1, \\dots, x_n", + "display": true, + "mathml": "x1,…,xn" + }, + { + "id": "dotsb", + "latex": "x_1 + \\dotsb + x_n", + "display": true, + "mathml": "x1+⋯+xn" + }, + { + "id": "dotsc", + "latex": "x_1, \\dotsc, x_n", + "display": true, + "mathml": "x1,…,xn" + }, + { + "id": "dotsi", + "latex": "A_1 \\dotsi A_n", + "display": true, + "mathml": "A1⋯An" + }, + { + "id": "dotsm", + "latex": "x_1 \\dotsm x_n", + "display": true, + "mathml": "x1⋯xn" + }, + { + "id": "dotso", + "latex": "A_1 \\dotso A_n", + "display": true, + "mathml": "A1…An" + }, + { + "id": "displaystyle", + "latex": "\\frac ab + {\\displaystyle \\frac cd + \\frac ef} + \\frac gh", + "display": true, + "mathml": "ab+cd+ef+gh" + }, + { + "id": "styles", + "latex": "\\frac ab+\\displaystyle\\frac cd+\\textstyle\\frac ef+\\scriptstyle\\frac gh+\\scriptscriptstyle\\frac ij", + "display": true, + "mathml": "ab+cd+ef+gh+ij" + }, + { + "id": "displaylines", + "latex": "\n \\displaylines{\n a = a\\cr\n \\text{if } a=b \\text{ then } b=a\\\\\n \\text{if } a=b \\text{ and } b=c \\text{ then } a=c\n }\n ", + "display": true, + "mathml": "a=aif\u00A0a=b\u00A0then\u00A0b=aif\u00A0a=b\u00A0and\u00A0b=c\u00A0then\u00A0a=c" + }, + { + "id": "emptyset", + "latex": "\\emptyset", + "display": true, + "mathml": "∅" + }, + { + "id": "exponential-function", + "latex": "\\exp x", + "display": true, + "mathml": "expx" + }, + { + "id": "fbox", + "latex": "\\fbox{ Hello! }", + "display": true, + "mathml": "\u00A0Hello!\u00A0" + }, + { + "id": "fraktur", + "latex": "{\\frak {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "greatest-common-divisor", + "latex": "\\gcd_{\\rm sub}^{\\rm sup}", + "display": true, + "mathml": "gcdsubsup" + }, + { + "id": "genfrac-displaystyle", + "latex": "\\genfrac\\{]{1pt}{0}{a+b}{c+d}", + "display": true, + "mathml": "{a+bc+d]" + }, + { + "id": "genfrac-textstyle", + "latex": "\\genfrac(|{1pt}{1}{a+b}{c+d}", + "display": true, + "mathml": "(a+bc+d|" + }, + { + "id": "genfrac-scriptstyle", + "latex": "\\genfrac(.{1pt}{2}{a+b}{c+d}", + "display": true, + "mathml": "(a+bc+d" + }, + { + "id": "genfrac-scriptscriptstyle", + "latex": "\\genfrac\\{\\}{1pt}{3}{a+b}{c+d}", + "display": true, + "mathml": "{a+bc+d}" + }, + { + "id": "gggtr", + "latex": "\\gggtr", + "display": true, + "mathml": "⋙" + }, + { + "id": "gvertneqq", + "latex": "\\gvertneqq", + "display": true, + "mathml": "≩" + }, + { + "id": "gt", + "latex": "\\gt", + "display": true, + "mathml": ">" + }, + { + "id": "grave", + "latex": "\\grave a \\grave{bc}", + "display": true, + "mathml": "a`bc`" + }, + { + "id": "hat", + "latex": "\\hat a \\hat{bc}", + "display": true, + "mathml": "a^bc^" + }, + { + "id": "hom", + "latex": "\\hom", + "display": true, + "mathml": "hom" + }, + { + "id": "href", + "latex": "\\href{https://github.com/roniemartinez/latex2mathml}{\\text{latex2mathml}}", + "display": true, + "mathml": "latex2mathml" + }, + { + "id": "huge", + "latex": "[{[\\Huge[\\huge[[}[", + "display": true, + "mathml": "[[[[[[" + }, + { + "id": "sqrt-with-multiple-root-nodes", + "latex": "\\sqrt[abc]{123}", + "display": true, + "mathml": "123abc" + }, + { + "id": "hphantom", + "latex": "\\begin{array}{l} \\text{Side Angle Side}\\\\ \\text{S}\\hphantom{\\text{ide }}\\text{A}\\hphantom{\\text{ngle }}\\text{S} \\end{array}", + "display": true, + "mathml": "Side\u00A0Angle\u00A0SideSide\u00A0Angle\u00A0S" + }, + { + "id": "idotsint", + "latex": "\\idotsint", + "display": true, + "mathml": "∫⋯∫" + }, + { + "id": "intop", + "latex": "\\intop", + "display": true, + "mathml": "∫" + }, + { + "id": "injlim", + "latex": "\\injlim", + "display": true, + "mathml": "inj\u2006lim" + }, + { + "id": "ker", + "latex": "\\ker", + "display": true, + "mathml": "ker" + }, + { + "id": "large", + "latex": "[{[\\LARGE[\\Large[\\large[[}[", + "display": true, + "mathml": "[[[[[[[" + }, + { + "id": "normalsize-scriptsize", + "latex": "[{[\\normalsize[\\scriptsize[[}[", + "display": true, + "mathml": "[[[[[[" + }, + { + "id": "mathbb", + "latex": "\\mathbb{AB}C", + "display": true, + "mathml": "ABC" + }, + { + "id": "mathbf", + "latex": "\\mathbf {\\text{var} = 1+\\{b\\}}", + "display": true, + "mathml": "var=1+{b}" + }, + { + "id": "mathcal", + "latex": "{\\mathcal {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathfrak", + "latex": "{\\mathfrak {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathit", + "latex": "{\\mathit {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathrm", + "latex": "{\\mathrm {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathscr", + "latex": "{\\mathscr {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathsf", + "latex": "{\\mathsf {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "mathtt", + "latex": "{\\mathtt {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "math-commands-that-currently-does-nothing", + "latex": "\\mathop{a}\\mathord{b}\\mathpunct{c}\\mathbin{d}\\mathrel{e}", + "display": true, + "mathml": "abcde" + }, + { + "id": "hbox", + "latex": "\\hbox{This is a sentence.}", + "display": true, + "mathml": "This\u00A0is\u00A0a\u00A0sentence." + }, + { + "id": "hbox-with-math-mode", + "latex": "\\hbox{left $x > 0$ center \\$x > 0\\$ right}", + "display": true, + "mathml": "left\u00A0x>0\u00A0center\u00A0\\$x\u00A0>\u00A00\\$\u00A0right" + }, + { + "id": "hbox-with-backslash-in-text", + "latex": "\\hbox{\\alpha $\\alpha$}", + "display": true, + "mathml": "\\alpha\u00A0α" + }, + { + "id": "hfil", + "latex": "\\begin{matrix} xxxxxx & xxxxxx & xxxxxx \\cr ab & \\hfil ab & ab\\hfil\\cr \\end{matrix}", + "display": true, + "mathml": "xxxxxxxxxxxxxxxxxxababab" + }, + { + "id": "ldotp", + "latex": "\\ldotp", + "display": true, + "mathml": "." + }, + { + "id": "lg", + "latex": "\\lg", + "display": true, + "mathml": "lg" + }, + { + "id": "liminf", + "latex": "\\liminf", + "display": true, + "mathml": "lim\u2006inf" + }, + { + "id": "limsup", + "latex": "\\limsup", + "display": true, + "mathml": "lim\u2006sup" + }, + { + "id": "llless", + "latex": "\\llless", + "display": true, + "mathml": "⋘" + }, + { + "id": "lt", + "latex": "\\lt", + "display": true, + "mathml": "<" + }, + { + "id": "lvert", + "latex": "\\lvert", + "display": true, + "mathml": "|" + }, + { + "id": "lVert-double", + "latex": "\\lVert", + "display": true, + "mathml": "‖" + }, + { + "id": "lvertneqq", + "latex": "\\lvertneqq", + "display": true, + "mathml": "≨" + }, + { + "id": "ngeqq", + "latex": "\\ngeqq", + "display": true, + "mathml": "≱" + }, + { + "id": "nshortmid", + "latex": "\\nshortmid", + "display": true, + "mathml": "∤" + }, + { + "id": "nshortparallel", + "latex": "\\nshortparallel", + "display": true, + "mathml": "∦" + }, + { + "id": "nsubseteqq", + "latex": "\\nsubseteqq", + "display": true, + "mathml": "⊈" + }, + { + "id": "omicron", + "latex": "\\omicron", + "display": true, + "mathml": "ο" + }, + { + "id": "Pr", + "latex": "\\Pr", + "display": true, + "mathml": "Pr" + }, + { + "id": "projlim", + "latex": "\\projlim", + "display": true, + "mathml": "proj\u2006lim" + }, + { + "id": "rvert", + "latex": "\\rvert", + "display": true, + "mathml": "|" + }, + { + "id": "rVert-double", + "latex": "\\rVert", + "display": true, + "mathml": "‖" + }, + { + "id": "S", + "latex": "\\S", + "display": true, + "mathml": "§" + }, + { + "id": "shortmid", + "latex": "\\shortmid", + "display": true, + "mathml": "∣" + }, + { + "id": "smallfrown", + "latex": "\\smallfrown", + "display": true, + "mathml": "⌢" + }, + { + "id": "smallint", + "latex": "\\smallint", + "display": true, + "mathml": "∫" + }, + { + "id": "smallsmile", + "latex": "\\smallsmile", + "display": true, + "mathml": "⌣" + }, + { + "id": "surd", + "latex": "\\surd", + "display": true, + "mathml": "√" + }, + { + "id": "thicksim", + "latex": "\\thicksim", + "display": true, + "mathml": "∼" + }, + { + "id": "thickapprox", + "latex": "\\thickapprox", + "display": true, + "mathml": "≈" + }, + { + "id": "varsubsetneqq", + "latex": "\\varsubsetneqq", + "display": true, + "mathml": "⫋" + }, + { + "id": "varsupsetneq", + "latex": "\\varsupsetneq", + "display": true, + "mathml": "⊋" + }, + { + "id": "varsupsetneqq", + "latex": "\\varsupsetneqq", + "display": true, + "mathml": "⫌" + }, + { + "id": "small-tiny", + "latex": "[{[\\small[\\tiny[\\Tiny[[}[", + "display": true, + "mathml": "[[[[[[[" + }, + { + "id": "mbox", + "latex": "\\mbox{This is a sentence.}", + "display": true, + "mathml": "This\u00A0is\u00A0a\u00A0sentence." + }, + { + "id": "style", + "latex": "\\frac{\\style{color:red}{x+1}}{\\style{color:green}y+2}", + "display": true, + "mathml": "x+1y+2" + }, + { + "id": "mathring", + "latex": "\\mathring a \\mathring{bc}", + "display": true, + "mathml": "a˚bc˚" + }, + { + "id": "overleftarrow", + "latex": "\\overleftarrow a \\overleftarrow{bc}", + "display": true, + "mathml": "a←bc←" + }, + { + "id": "overleftrightarrow", + "latex": "\\overleftrightarrow a \\overleftrightarrow{bc}", + "display": true, + "mathml": "a↔bc↔" + }, + { + "id": "overline", + "latex": "\\overline a \\overline{bc}", + "display": true, + "mathml": "a―bc―" + }, + { + "id": "overparen", + "latex": "\\overparen a \\overparen{bc}", + "display": true, + "mathml": "a⏜bc⏜" + }, + { + "id": "overrightarrow", + "latex": "\\overrightarrow a \\overrightarrow{bc}", + "display": true, + "mathml": "a→bc→" + }, + { + "id": "tilde-1", + "latex": "\\tilde a \\tilde{bc}", + "display": true, + "mathml": "a~bc~" + }, + { + "id": "underleftarrow", + "latex": "\\underleftarrow a \\underleftarrow{bc}", + "display": true, + "mathml": "a←bc←" + }, + { + "id": "underrightarrow", + "latex": "\\underrightarrow a \\underrightarrow{bc}", + "display": true, + "mathml": "a→bc→" + }, + { + "id": "underleftrightarrow", + "latex": "\\underleftrightarrow a \\underleftrightarrow{bc}", + "display": true, + "mathml": "a↔bc↔" + }, + { + "id": "underline", + "latex": "\\underline a \\underline{bc}", + "display": true, + "mathml": "a―bc―" + }, + { + "id": "underparen", + "latex": "\\underparen a \\underparen{bc}", + "display": true, + "mathml": "a⏝bc⏝" + }, + { + "id": "widehat", + "latex": "\\widehat a \\widehat{bc}", + "display": true, + "mathml": "a^bc^" + }, + { + "id": "widetilde", + "latex": "\\widetilde a \\widetilde{bc}", + "display": true, + "mathml": "a~bc~" + }, + { + "id": "phantom", + "latex": "\\phantom a", + "display": true, + "mathml": "a" + }, + { + "id": "vphantom", + "latex": "\\vphantom a", + "display": true, + "mathml": "a" + }, + { + "id": "sideset", + "latex": "\\sideset{_1^2}{_3^4}\\sum", + "display": true, + "mathml": "∑12∑34" + }, + { + "id": "307-a", + "latex": "\\sideset{_1^2}{_3^4}{\\sum}", + "display": true, + "mathml": "∑12∑34" + }, + { + "id": "307-b", + "latex": "\\sideset{^{°}}{}{C}", + "display": true, + "mathml": "C°C" + }, + { + "id": "tbinom", + "latex": "\\tbinom{2}{3}", + "display": true, + "mathml": "(23)" + }, + { + "id": "tfrac", + "latex": "\\tfrac{1}{2}", + "display": true, + "mathml": "12" + }, + { + "id": "mit", + "latex": "{\\mit {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "oldstyle", + "latex": "{\\oldstyle {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "scr", + "latex": "{\\scr {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "tt", + "latex": "{\\tt {\\text{var} = 1+\\{b\\}}} + B", + "display": true, + "mathml": "var=1+{b}+B" + }, + { + "id": "textbf", + "latex": "\\textbf{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "textit", + "latex": "\\textit{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "textrm", + "latex": "\\textrm{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "textsf", + "latex": "\\textsf{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "texttt", + "latex": "\\texttt{ Hello~World }", + "display": true, + "mathml": "\u00A0Hello~World\u00A0" + }, + { + "id": "LaTeX", + "latex": "\\LaTeX", + "display": true, + "mathml": "LATEX" + }, + { + "id": "LaTeX-with-style", + "latex": "\\Bbb \\LaTeX", + "display": true, + "mathml": "LATEX" + }, + { + "id": "TeX", + "latex": "\\TeX", + "display": true, + "mathml": "TEX" + }, + { + "id": "TeX-with-style", + "latex": "\\rm \\TeX", + "display": true, + "mathml": "TEX" + }, + { + "id": "skew", + "latex": "\\skew7\\hat A", + "display": true, + "mathml": "A^" + }, + { + "id": "skew-with-braces", + "latex": "\\skew{8}\\tilde M", + "display": true, + "mathml": "M~" + }, + { + "id": "mod", + "latex": "\\mod 5", + "display": true, + "mathml": "mod5" + }, + { + "id": "pmod", + "latex": "\\pmod 5", + "display": true, + "mathml": "(mod5)" + }, + { + "id": "middle", + "latex": "\\left\\{\\middle|\\right\\}", + "display": true, + "mathml": "{|}" + }, + { + "id": "bmod", + "latex": "9 \\bmod2", + "display": true, + "mathml": "9mod2" + }, + { + "id": "overbrace-a", + "latex": "\\overbrace3", + "display": true, + "mathml": "3⏞" + }, + { + "id": "overbrace-b", + "latex": "\\overbrace3^a", + "display": true, + "mathml": "3⏞a" + }, + { + "id": "overbrace-c", + "latex": "\\overbrace3^a_x", + "display": true, + "mathml": "3⏞xa" + }, + { + "id": "underbrace-a", + "latex": "\\underbrace3", + "display": true, + "mathml": "3⏟" + }, + { + "id": "underbrace-b", + "latex": "\\underbrace3_a", + "display": true, + "mathml": "3⏟a" + }, + { + "id": "underbrace-c", + "latex": "\\underbrace3_a^x", + "display": true, + "mathml": "3⏟ax" + }, + { + "id": "xleftarrow", + "latex": "\\xleftarrow x", + "display": true, + "mathml": "←x" + }, + { + "id": "xleftarrow-with-argument", + "latex": "\\xleftarrow[y] x", + "display": true, + "mathml": "←yx" + }, + { + "id": "xrightarrow", + "latex": "\\xrightarrow x", + "display": true, + "mathml": "→x" + }, + { + "id": "xrightarrow-with-argument", + "latex": "\\xrightarrow[y] x", + "display": true, + "mathml": "→yx" + }, + { + "id": "bigl-smallmatrix-bigr", + "latex": "\\bigl(\\begin{smallmatrix} 1 & 2 & 3 \\\\ 4 & 5 & 6 \\\\ \\end{smallmatrix}\\bigr)", + "display": true, + "mathml": "(123456)" + }, + { + "id": "not", + "latex": "\\not\\in\\not a\\not\\operatorname{R}\\not", + "display": true, + "mathml": "∉⧸a⧸R⧸" + }, + { + "id": "split", + "latex": "\\begin{split} x &= y \\\\ &=z \\end{split}", + "display": true, + "mathml": "x=y=z" + }, + { + "id": "align0", + "latex": "\\begin{align*}x &=y & w &=z & a&=b \\end{align*}", + "display": true, + "mathml": "x=yw=za=b" + }, + { + "id": "issue-498-sum", + "latex": "\\sum_{0}^{\\pi}", + "display": true, + "mathml": "∑0π" + }, + { + "id": "issue-498-prod", + "latex": "\\prod_{0}^{\\pi}", + "display": true, + "mathml": "∏0π" + }, + { + "id": "arctg", + "latex": "\\arctg x", + "display": true, + "mathml": "arctgx" + }, + { + "id": "argmax", + "latex": "\\argmax_{x}", + "display": true, + "mathml": "arg\u2009maxx" + }, + { + "id": "varinjlim", + "latex": "\\varinjlim", + "display": true, + "mathml": "inj\u2006lim" + }, + { + "id": "nolimits", + "latex": "\\sum\\nolimits_{i=0}^{n}", + "display": true, + "mathml": "∑i=0n" + }, + { + "id": "bold", + "latex": "\\bold{x}", + "display": true, + "mathml": "x" + }, + { + "id": "bm", + "latex": "\\bm{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathsfit", + "latex": "\\mathsfit{x}", + "display": true, + "mathml": "\uD835\uDE39" + }, + { + "id": "emph", + "latex": "\\emph{ Hello }", + "display": true, + "mathml": "\u00A0Hello\u00A0" + }, + { + "id": "footnotesize", + "latex": "\\footnotesize x", + "display": true, + "mathml": "x" + }, + { + "id": "thickspace", + "latex": "\\thickspace", + "display": true, + "mathml": "" + }, + { + "id": "stackrel", + "latex": "\\stackrel{f}{\\to}", + "display": true, + "mathml": "→f" + }, + { + "id": "degree", + "latex": "90\\degree", + "display": true, + "mathml": "90°" + }, + { + "id": "cancel", + "latex": "\\cancel{x}", + "display": true, + "mathml": "x" + }, + { + "id": "llap", + "latex": "\\llap{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathllap", + "latex": "\\mathllap{x}", + "display": true, + "mathml": "x" + }, + { + "id": "overbracket", + "latex": "\\overbracket{x}", + "display": true, + "mathml": "x⎴" + }, + { + "id": "widecheck", + "latex": "\\widecheck{x}", + "display": true, + "mathml": "xˇ" + }, + { + "id": "xLeftarrow-double", + "latex": "\\xLeftarrow[y] x", + "display": true, + "mathml": "⇐yx" + }, + { + "id": "xleftrightarrow", + "latex": "\\xleftrightarrow{x}", + "display": true, + "mathml": "↔x" + }, + { + "id": "xhookleftarrow", + "latex": "\\xhookleftarrow{x}", + "display": true, + "mathml": "↩x" + }, + { + "id": "operatornamewithlimits", + "latex": "\\operatornamewithlimits{lim}_{x}", + "display": true, + "mathml": "limx" + }, + { + "id": "pod", + "latex": "\\pod{a}", + "display": true, + "mathml": "(a)" + }, + { + "id": "textcolor", + "latex": "\\textcolor{red}{x+y}", + "display": true, + "mathml": "x+y" + }, + { + "id": "colorbox", + "latex": "\\colorbox{yellow}{x+y}", + "display": true, + "mathml": "x+y" + }, + { + "id": "fcolorbox", + "latex": "\\fcolorbox{red}{yellow}{x+y}", + "display": true, + "mathml": "x+y" + }, + { + "id": "smash", + "latex": "\\smash{x}", + "display": true, + "mathml": "x" + }, + { + "id": "smash-bottom", + "latex": "\\smash[b]{x}", + "display": true, + "mathml": "x" + }, + { + "id": "rule", + "latex": "\\rule{1em}{2em}", + "display": true, + "mathml": "" + }, + { + "id": "bra", + "latex": "\\bra{\\psi}", + "display": true, + "mathml": "⟨ψ∣" + }, + { + "id": "ket", + "latex": "\\ket{\\psi}", + "display": true, + "mathml": "∣ψ⟩" + }, + { + "id": "braket", + "latex": "\\braket{\\phi|\\psi}", + "display": true, + "mathml": "⟨ϕ|ψ⟩" + }, + { + "id": "mathstrut", + "latex": "\\mathstrut", + "display": true, + "mathml": "(" + }, + { + "id": "operatorname-star", + "latex": "\\operatorname*{argmax}_{x}", + "display": true, + "mathml": "argmaxx" + }, + { + "id": "vcenter", + "latex": "\\vcenter{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathbin", + "latex": "\\mathbin{+}", + "display": true, + "mathml": "+" + }, + { + "id": "mathop", + "latex": "\\mathop{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathord", + "latex": "\\mathord{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathrel", + "latex": "\\mathrel{=}", + "display": true, + "mathml": "=" + }, + { + "id": "unicode", + "latex": "\\unicode{x263A}", + "display": true, + "mathml": "☺" + }, + { + "id": "verb", + "latex": "\\verb|hello world|", + "display": true, + "mathml": "hello\u00A0world" + }, + { + "id": "tag", + "latex": "x^2 \\tag{1}", + "display": true, + "mathml": "x2(1)" + }, + { + "id": "tag-star", + "latex": "x^2 \\tag*{A}", + "display": true, + "mathml": "x2A" + }, + { + "id": "class", + "latex": "\\class{my-class}{x+y}", + "display": true, + "mathml": "x+y" + }, + { + "id": "shoveleft", + "latex": "\\shoveleft{x}", + "display": true, + "mathml": "x" + }, + { + "id": "raise", + "latex": "\\raise{3pt}{x}", + "display": true, + "mathml": "x" + }, + { + "id": "lower", + "latex": "\\lower{3pt}{x}", + "display": true, + "mathml": "x" + }, + { + "id": "moveleft", + "latex": "\\moveleft{5pt}{x}", + "display": true, + "mathml": "x" + }, + { + "id": "moveright", + "latex": "\\moveright{5pt}{x}", + "display": true, + "mathml": "x" + }, + { + "id": "smash-top", + "latex": "\\smash[t]{x}", + "display": true, + "mathml": "x" + }, + { + "id": "mathchoice-block", + "latex": "\\mathchoice{D}{T}{S}{SS}", + "display": true, + "mathml": "D" + }, + { + "id": "leftroot-uproot", + "latex": "\\sqrt[\\leftroot{2}\\uproot{4} 3]{x}", + "display": true, + "mathml": "x3" + }, + { + "id": "eqalign", + "latex": "\\eqalign{a &= b \\cr c &= d}", + "display": true, + "mathml": "a=bc=d" + }, + { + "id": "align1", + "latex": "\\begin{align} x &= 1 \\\\ y &= 2 \\end{align}", + "display": true, + "mathml": "x=1(1)y=2(2)" + }, + { + "id": "align-nonumber", + "latex": "\\begin{align} x &= 1 \\nonumber \\\\ y &= 2 \\end{align}", + "display": true, + "mathml": "x=1y=2(1)" + }, + { + "id": "align-notag", + "latex": "\\begin{align} x &= 1 \\notag \\\\ y &= 2 \\end{align}", + "display": true, + "mathml": "x=1y=2(1)" + }, + { + "id": "newenvironment-multi-node", + "latex": "\\newenvironment{wrapper}{a +}{+ c} \\begin{wrapper} b \\end{wrapper}", + "display": true, + "mathml": "a+b+c" + }, + { + "id": "issue-498-sum", + "latex": "\\sum_{0}^{\\pi}", + "display": false, + "mathml": "∑0π" + }, + { + "id": "issue-498-prod", + "latex": "\\prod_{0}^{\\pi}", + "display": false, + "mathml": "∏0π" + }, + { + "id": "issue-75-2-rows", + "latex": "\\sum_{\\substack{1\\le i\\le n\\\\ i\\ne j}}", + "display": false, + "mathml": "∑1≤i≤ni≠j" + }, + { + "id": "mathchoice-inline", + "latex": "\\mathchoice{D}{T}{S}{SS}", + "display": false, + "mathml": "T" + }, + { + "id": "def-with-args", + "latex": "\\def\\bold#1{\\mathbf{#1}} \\bold{x}", + "display": false, + "mathml": "x" + }, + { + "id": "newcommand-escaped-hash", + "latex": "\\newcommand{\\foo}[1]{## #1} \\foo{x}", + "display": false, + "mathml": "##x" + } +] diff --git a/tests/data/latex/latex2mathml-macros.json b/tests/data/latex/latex2mathml-macros.json new file mode 100644 index 0000000..f873170 --- /dev/null +++ b/tests/data/latex/latex2mathml-macros.json @@ -0,0 +1,62 @@ +[ + { + "id": "declare-math-operator", + "latex": "\\DeclareMathOperator{\\Res}{Res} \\Res_{z=0}", + "equivalent": "\\operatorname{Res}_{z=0}", + "display": true + }, + { + "id": "newcommand-no-args", + "latex": "\\newcommand{\\R}{\\mathbb{R}} \\R", + "equivalent": "\\mathbb{R}", + "display": true + }, + { + "id": "newcommand-with-args", + "latex": "\\newcommand{\\norm}[1]{\\left\\|#1\\right\\|} \\norm{x}", + "equivalent": "\\left\\|x\\right\\|", + "display": true + }, + { + "id": "newcommand-optional-default", + "latex": "\\newcommand{\\greeting}[1][Hello]{#1} \\greeting{world}", + "equivalent": "world", + "display": false + }, + { + "id": "newcommand-expands-to-command", + "latex": "\\newcommand{\\myfrac}{\\frac} \\myfrac{a}{b}", + "equivalent": "\\frac{a}{b}", + "display": false + }, + { + "id": "newcommand-empty-body", + "latex": "\\newcommand{\\nothing}{} \\nothing x", + "equivalent": "x", + "display": false + }, + { + "id": "def-command", + "latex": "\\def\\R{\\mathbb{R}} \\R", + "equivalent": "\\mathbb{R}", + "display": true + }, + { + "id": "newenvironment", + "latex": "\\newenvironment{myenv}{\\begin{bmatrix}}{\\end{bmatrix}} \\begin{myenv} 1 & 2 \\\\ 3 & 4 \\end{myenv}", + "equivalent": "\\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}", + "display": true + }, + { + "id": "newenvironment-with-args", + "latex": "\\newenvironment{paren}[1]{\\left( #1 +}{\\right)} \\begin{paren}{x} y \\end{paren}", + "equivalent": "\\left( x + y \\right)", + "display": true + }, + { + "id": "newcommand-bare-name", + "latex": "\\newcommand\\R{\\mathbb{R}} \\R", + "equivalent": "\\mathbb{R}", + "display": true + } +] diff --git a/tests/data/mathml/mml2tex.json b/tests/data/mathml/mml2tex.json new file mode 100644 index 0000000..7555e02 --- /dev/null +++ b/tests/data/mathml/mml2tex.json @@ -0,0 +1,32 @@ +[ + { + "source": "xspec/mtable.xspec", + "label": "a 2x3 matrix", + "mathml": "11=1321=23", + "tex": "\\begin{array}{ccc}\n11 & = & 13\\\\\n21 & = & 23\n\\end{array}" + }, + { + "source": "xspec/subsup.xspec", + "label": "Given nested msubs", + "mathml": "trm2", + "tex": "{t_{rm}}_{2}" + }, + { + "source": "xspec/subsup.xspec", + "label": "Given nested msups", + "mathml": "trm2", + "tex": "{t_{rm}}^{2}" + }, + { + "source": "example/example.xml", + "label": null, + "mathml": "1⋯0⋮⋱⋮0⋯1", + "tex": null + }, + { + "source": "example/katexify-example.xml", + "label": null, + "mathml": "Γ=πr2", + "tex": null + } +] From cd20aa9104dc68efb19f0fcf481e06de3f3b50a2 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sat, 7 Nov 2026 22:23:52 +0100 Subject: [PATCH 02/45] Tests of characters outside the basic plane to and from OMML [no-new-version] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the OMML written for math alphanumerics (plain letters with m:scr/m:sty) and emoji, how OMML with astral characters reads, and the round trips. The tests named ...CountingUtf16Units depend on mml2omml.xsl counting UTF-16 units under XslCompiledTransform: 🍕 counts as two characters and is written upright (m:sty p) in a run of its own. Co-Authored-By: Claude Opus 5.5 --- .../Omml/OmmlAstralTests.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs diff --git a/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs b/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs new file mode 100644 index 0000000..5e5f778 --- /dev/null +++ b/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs @@ -0,0 +1,183 @@ +#nullable enable + +namespace SharpEquations.Tests.Omml +{ + using System.Linq; + using System.Threading.Tasks; + using System.Xml.Linq; + using SharpEquations; + + /// + /// Characters outside the basic plane (mathematical alphanumerics such as 𝒢, 𝟏, 𝑥, 𝜕, and emoji such as 🍕) to and + /// from OMML, through the public API. + /// + /// + /// The mathematical alphanumerics are written as Word writes them, the plain letter with its style (𝒢 is a G with + /// m:scr), before mml2omml.xsl sees them, so how the stylesheet counts characters does not matter for them. An + /// emoji reaches the stylesheet as it is, and the stylesheet, run by XslCompiledTransform, counts UTF-16 units + /// where XPath counts code points: 🍕, one character, counts as two, so its mi is taken for a name of several + /// letters and is written upright (m:sty p), in a run of its own. The tests marked as depending on UTF-16 counting + /// pin that behaviour: an implementation that counts code points writes 🍕 as an italic identifier, without m:rPr, + /// in one run with the characters around it, and reads it back to the same equation. + /// + public class OmmlAstralTests + { + private static readonly XNamespace M = MathMLNamespaces.Omml; + + private const string Namespaces = " xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\""; + + /// The OMML without its WordprocessingML run properties (the Cambria Math font) and namespace declarations. + private static string Compact(XElement omml) + { + var copy = new XElement(omml); + copy.Descendants(MathMLNamespaces.WordprocessingML + "rPr").Remove(); + return copy.ToString(SaveOptions.DisableFormatting) + .Replace(" xmlns:m=\"" + M.NamespaceName + "\"", "") + .Replace(" xmlns:w=\"" + MathMLNamespaces.WordprocessingML.NamespaceName + "\"", ""); + } + + /// The runs of the OMML as "properties:text", "-" for a run without m:rPr. + private static string Runs(XElement omml) => string.Join(" ", omml.Descendants(M + "r").Select(r => + string.Join(",", r.Element(M + "rPr")?.Elements().Select(p => p.Name.LocalName + "=" + (string?)p.Attribute(M + "val")) ?? new[] { "-" }) + + ":" + r.Element(M + "t")?.Value)); + + private static string MathML(string body) => "" + body + ""; + + [Test] + [Arguments("𝒢", "G")] + [Arguments("𝑥+𝒢", "x+G")] + [Arguments("x𝒢", "xG")] + [Arguments("𝟏", "1")] + public async Task WritesAlphanumericsAsPlainLetters(string unicodeMath, string omml) + { + await Assert.That(Compact(Equation.Parse(unicodeMath, EquationFormat.UnicodeMath).ToOmml())).IsEqualTo(omml); + } + + [Test] + public async Task WritesTheGroupOperationOfTheCorpus() + { + var omml = Equation.Parse("+ : 𝒢 × 𝒢 → 𝒢", EquationFormat.UnicodeMath).ToOmml(); + await Assert.That(Runs(omml)).IsEqualTo("-:+: scr=script:G -:× scr=script:G -:→ scr=script:G"); + } + + [Test] + public async Task WritesTheIndicatorFunctionsOfTheCorpus() + { + var omml = Equation.Parse("K_c (r) = 𝟏_[¼,¾] (r) + ½ × 𝟏_[0,¼] (r)", EquationFormat.UnicodeMath).ToOmml(); + await Assert.That(Runs(omml)).IsEqualTo("-:K -:c -:r -:= sty=b:1 -:1 -:4 -:, -:3 -:4 -:r -:+ -:1 -:2 -:× sty=b:1 -:0, -:1 -:4 -:r"); + await Assert.That(omml.Descendants(M + "sSub").Select(s => string.Concat(s.Element(M + "e")!.Descendants(M + "t").Select(t => t.Value)))) + .IsEquivalentTo(new[] { "K", "1", "1" }); + } + + [Test] + public async Task WritesTheDerivativesOfTheCorpus() + { + var omml = Equation.Parse("ⅅ_𝑥 𝑓(𝑥)≠𝜕_𝑥𝑦 𝑓(𝑥,𝑦)", EquationFormat.UnicodeMath).ToOmml(); + await Assert.That(Runs(omml)).IsEqualTo("-:D -:x -:f -:x -:≠ -:∂ -:xy -:f -:x,y"); + await Assert.That(omml.Descendants(M + "t").Select(t => t.Value).Where(t => t.Any(char.IsSurrogate))).IsEmpty(); + } + + /// + /// Depends on UTF-16 counting: 🍕 counts as two characters in mml2omml.xsl and is written upright, in a run of + /// its own. Counting code points, it would be <m:r><m:t>🍕</m:t></m:r>, and + /// -:x+🍕 in one run. + /// + [Test] + [Arguments("🍕", "🍕")] + [Arguments("𝑥+🍕", "x+🍕")] + public async Task WritesEmojiUprightCountingUtf16Units(string unicodeMath, string omml) + { + await Assert.That(Compact(Equation.Parse(unicodeMath, EquationFormat.UnicodeMath).ToOmml())).IsEqualTo(omml); + } + + /// + /// Depends on UTF-16 counting: each 🍕 of the corpus example is written upright (m:sty p). Counting code points, + /// the runs would be nor=:pizza -:🍕 -:🍕. + /// + [Test] + public async Task WritesThePizzaOfTheCorpusCountingUtf16Units() + { + var omml = Equation.Parse("(\"pizza\"^🍕)^🍕", EquationFormat.UnicodeMath).ToOmml(); + await Assert.That(Runs(omml)).IsEqualTo("nor=:pizza sty=p:🍕 sty=p:🍕"); + await Assert.That(Compact(omml)).IsEqualTo("" + + "pizza" + + "🍕" + + "" + + "🍕"); + } + + [Test] + [Arguments("𝑥+𝒢", "x+𝒢")] + [Arguments("🍕", "🍕")] + [Arguments("🍕", "🍕")] + [Arguments("𝒢🍕", "𝒢🍕")] + [Arguments("𝟏+𝜕𝑥", "𝟏+𝜕x")] + [Arguments("𝜕𝑥𝑦𝑓", + "𝜕xyf")] + public async Task ReadsAstralCharacters(string runs, string mathML) + { + var equation = Equation.Parse("" + runs + "", EquationFormat.Omml); + await Assert.That(equation.ToMathML().ToString(SaveOptions.DisableFormatting)).IsEqualTo(MathML(mathML)); + await Assert.That(equation.ToMathML().Value.Any(c => c >= '' && c <= '')).IsFalse().Because("no private use characters"); + } + + [Test] + [Arguments("𝒢")] + [Arguments("𝑥+𝒢")] + [Arguments("x𝒢")] + [Arguments("+ : 𝒢 × 𝒢 → 𝒢")] + public async Task RoundTripsAlphanumericsThroughOmml(string unicodeMath) + { + var equation = Equation.Parse(unicodeMath, EquationFormat.UnicodeMath); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back).IsEqualTo(equation); + await Assert.That(back.ToString(EquationFormat.UnicodeMath)).IsEqualTo(equation.ToString(EquationFormat.UnicodeMath)); + await Assert.That(back.ToString(EquationFormat.UnicodeMath)).Contains("𝒢"); + } + + /// + /// Depends on UTF-16 counting: the upright 🍕 that mml2omml.xsl writes reads back with + /// mathvariant="normal", so the first trip through OMML gives another equation, which then stays the same. + /// The UnicodeMath of both is the same. Counting code points, the first trip would give the same equation. + /// + [Test] + [Arguments("🍕", "🍕")] + [Arguments("𝑥+🍕", "x+🍕")] + [Arguments("(\"pizza\"^🍕)^🍕", "(pizza🍕)🍕")] + public async Task RoundTripsEmojiThroughOmmlCountingUtf16Units(string unicodeMath, string mathML) + { + var equation = Equation.Parse(unicodeMath, EquationFormat.UnicodeMath); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back.ToMathML().ToString(SaveOptions.DisableFormatting)).IsEqualTo(MathML(mathML)); + await Assert.That(back).IsNotEqualTo(equation); + await Assert.That(back.ToString(EquationFormat.UnicodeMath)).IsEqualTo(equation.ToString(EquationFormat.UnicodeMath)); + await Assert.That(Equation.Parse(back.ToOmml(), EquationFormat.Omml)).IsEqualTo(back); + } + + [Test] + [Arguments("𝑥+𝒢")] + [Arguments("🍕")] + [Arguments("G🍕")] + public async Task OmmlRoundTripsWithAstralCharacters(string runs) + { + var equation = Equation.Parse("" + runs + "", EquationFormat.Omml); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back).IsEqualTo(equation); + await Assert.That(back.ToOmml().ToString()).IsEqualTo(equation.ToOmml().ToString()); + } + + /// + /// Depends on UTF-16 counting: an italic 🍕 of OMML (a run without m:sty) is written back upright, so it reads + /// back as another equation. Counting code points, it would be written as it was read. + /// + [Test] + public async Task ItalicEmojiOfOmmlComesBackUprightCountingUtf16Units() + { + var equation = Equation.Parse("🍕", EquationFormat.Omml); + await Assert.That(Runs(equation.ToOmml())).IsEqualTo("sty=p:🍕"); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back).IsNotEqualTo(equation); + await Assert.That(back.ToMathML().ToString(SaveOptions.DisableFormatting)).IsEqualTo(MathML("🍕")); + } + } +} From 1ea26a0568fe40945afe8ec4e5d4b58df7098d87 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 10:27:51 +0100 Subject: [PATCH 03/45] LaTeX: limits where TeX puts them, the symbols and environments latex2mathml tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader: - \limits puts the scripts of an operator under and over it (┬ and ┴), \nolimits at its side, and in a display equation the scripts of a summation (\sum, \prod, \bigcup...) go under and over it, as TeX puts them; an integral keeps them at its side. The reader is told whether the equation is display (ToUnicodeMath, from FromLaTeX). - the blanks around a script are left out: x ^ { 2 }. - \begin{array} { l }: the column specification after a blank; matrix* with its optional alignment; \hline, \hdashline, \hfil left out. - the symbols of amsmath, amssymb and plain TeX that UnicodeMath has no control word for: \lt, \gt, \dotsb and the other \dots, \S, \omicron, \gggtr, \nsubseteqq...; \emph as text. Writer: \limits or \nolimits after a large operator whose scripts are not where TeX puts them (under and over an inline summation or an integral, at the side of a display summation). latex2mathml corpus: the conventions of its MathML made the same as ours (~ and ̃, munder with ― and menclose, empty matrices, mathvariant as math alphanumerics, mi and mo for symbols, intent=":text", largeop, mstyle); nine snapshots corrected where its MathML is not what the LaTeX means (the ~ of text, ( - 25 ) ^ { 2 }, \omicron, \lim in display); and what the reader still reads otherwise in four groups: commands Word's LaTeX does not have, LaTeX not read yet, the same equation held otherwise, what is no LaTeX. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 173 +++++++++++++++++- .../Internal/LaTeX/LaTeXSerializer.cs | 35 +++- .../UnicodeMath/UnicodeMathTranslator.cs | 2 +- .../LaTeX/LaTeXReaderTests.cs | 5 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 139 +++++++++----- .../MathML/MathMLWriterTests.cs | 2 +- tests/data/README.md | 7 +- tests/data/latex/latex2mathml-converter.json | 18 +- 8 files changed, 301 insertions(+), 80 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 242e95f..8490c3a 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -27,16 +27,22 @@ internal sealed partial class LaTeXReader private readonly IDictionary macros; - private LaTeXReader(IDictionary macros) + private readonly bool display; + + private LaTeXReader(IDictionary macros, bool display) { this.macros = macros ?? new Dictionary(); + this.display = display; } /// /// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def - /// or \newcommand go to , where later conversions find them. + /// or \newcommand 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. /// - public static string ToUnicodeMath(string tex, IDictionary macros = null) => RestoreTextEscapes(new LaTeXReader(macros).Convert(tex)); + public static string ToUnicodeMath(string tex, IDictionary macros = null, bool display = false) => + RestoreTextEscapes(new LaTeXReader(macros, display).Convert(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 = @@ -48,9 +54,8 @@ private static readonly (string Escape, char Held, char Character)[] TextEscapes private static readonly HashSet TextCommands = new HashSet(StringComparer.Ordinal) { - "text", "textrm", "textup", "textnormal", "textbf", "textit", "textsf", "texttt", "textmd", "mbox", + "text", "textrm", "textup", "textnormal", "textbf", "textit", "textsf", "texttt", "textmd", "mbox", "emph", }; - /// Text as a quoted UnicodeMath string, which keeps parentheses and backslashes as they are. private static string Quoted(string text) => text.Length == 0 ? "" : "\"" + text.Replace("\"", "\\\"") + "\""; @@ -242,11 +247,122 @@ private static bool IsParenthesized(string arg) [":"] = " ", [">"] = " ", [";"] = " ", ["!"] = "", [" "] = "\u00A0", }; + // the large operators whose scripts TeX puts under and over them in a display equation; an integral keeps them + // at its side unless \limits puts them under and over + private static readonly HashSet Summations = new HashSet(StringComparer.Ordinal) + { + "sum", "prod", "coprod", "bigcup", "bigcap", "bigvee", "bigwedge", "bigoplus", "bigotimes", "bigodot", "biguplus", "bigsqcup", + "∑", "∏", "∐", "⋃", "⋂", "⋁", "⋀", "⨁", "⨂", "⨀", "⨄", "⨆", + }; + + /// + /// The scripts of large operators where TeX puts them: under and over after \limits, and after a summation in a + /// display equation, as UnicodeMath's ┬ and ┴ (∑┬(𝑘=1)┴𝑛); at the side after \nolimits, the operator then + /// grouped (〖∑〗_(𝑘=1)^𝑛), since UnicodeMath puts the scripts of a summation under and over it in a display + /// equation. \limits and \nolimits are left out. + /// + private string Limits(string latex) + { + var tex = new Text(latex); + var result = new StringBuilder(); + // where the last summation begins in the result, and whether it ends the result + int summation = -1; + for (int i = 0; i < tex.Length;) + { + var cw = tex[i] == "\\" ? ControlWord(tex, i + 1) : ""; + var next = tex[i] == "\\" ? i + 1 + Math.Max(1, cw.Length) : i + 1; + if (cw is "limits" or "nolimits") + { + var (under, over, afterScripts) = Scripts(tex, next); + if (afterScripts == next) + { + i = next; + continue; + } + if (cw == "limits") + result.Append(UnderOver(under, over)); + else + { + if (display && summation >= 0) + result.Insert(summation, '{').Append('}'); + result.Append(under == null ? "" : "_{" + under + "}").Append(over == null ? "" : "^{" + over + "}"); + } + summation = -1; + i = afterScripts; + continue; + } + var token = tex.Sub(i, next); + var isSummation = Summations.Contains(cw.Length > 0 ? cw : tex[i]); + result.Append(token); + i = next; + summation = isSummation ? result.Length - token.Length : summation >= 0 && tex[i - 1] == " " ? summation : -1; + if (isSummation && display && NonBlank(tex, i) is "_" or "^") + { + // the scripts of a summation in a display equation (\limits or \nolimits would come before them) + var (under, over, afterScripts) = Scripts(tex, i); + result.Append(UnderOver(under, over)); + summation = -1; + i = afterScripts; + } + } + return result.ToString(); + } + + // the limits end at a space, which parts them from the operand: ∑┬𝑘 (−1)^𝑘, not ∑┬(𝑘(−1)) + private static string UnderOver(string under, string over) => + (under == null ? "" : "┬" + ScriptGroup(under)) + (over == null ? "" : "┴" + ScriptGroup(over)) + " "; + + /// + /// A script braced unless it is one character (┴𝑛, ┬{𝑘=1}), as the scripts of _ and ^ are; a command is braced + /// too (┴{\infty}), since the space that ends its name would not part the script from the operand. + /// + private static string ScriptGroup(string script) => new Text(script).Length == 1 ? script : "{" + script + "}"; + + /// The subscript and the superscript at the index (in either order, after blanks), and the index after them. + private static (string Under, string Over, int Next) Scripts(Text tex, int i) + { + string under = null, over = null; + int next = i; + while (true) + { + int j = next; + while (j < tex.Length && tex[j] == " ") + j++; + if (tex[j] is not ("_" or "^") || (tex[j] == "_" ? under : over) != null) + return (under, over, next); + var (script, afterScript) = ScriptArgument(tex, j + 1); + if (afterScript < 0) + return (under, over, next); + if (tex[j] == "_") + under = script; + else + over = script; + next = afterScript; + } + } + + /// A script: a braced group, a command with its arguments, a command or a character; -1 if none. + private static (string Text, int Next) ScriptArgument(Text tex, int i) + { + while (i < tex.Length && tex[i] == " ") + i++; + if (i >= tex.Length) + return (null, -1); + if (tex[i] == "{") + return BracedArgument(tex, i); + if (tex[i] != "\\") + return (tex[i], i + 1); + if (ScriptCommand(tex, i) is var (command, after) && command != null) + return (command, after); + var end = i + 1 + Math.Max(1, ControlWord(tex, i + 1).Length); + return (tex.Sub(i, end), end); + } + // the commands that only size or style what follows, left out (\displaystyle makes a display equation, ⓓ) private static readonly HashSet Dropped = new HashSet(StringComparer.Ordinal) { "big", "Big", "bigg", "Bigg", "bigl", "bigr", "Bigl", "Bigr", "biggl", "biggr", "Biggl", "Biggr", "bigm", "Bigm", "biggm", "Biggm", - "limits", "nolimits", "textstyle", "scriptstyle", "scriptscriptstyle", "nonumber", "notag", + "textstyle", "scriptstyle", "scriptscriptstyle", "nonumber", "notag", "hline", "hdashline", "hfil", "hfill", }; // the commands that are others for MathML @@ -255,6 +371,14 @@ private static bool IsParenthesized(string arg) ["dfrac"] = "\\frac", ["tfrac"] = "\\frac", ["cfrac"] = "\\frac", ["dbinom"] = "\\binom", ["tbinom"] = "\\binom", ["overrightarrow"] = "\\vec", ["Vert"] = "‖", ["lVert"] = "‖", ["rVert"] = "‖", ["bmod"] = "\\operatorname{mod}", ["bm"] = "\\boldsymbol", + // the symbols of amsmath, amssymb and plain TeX that UnicodeMath has no control word for + ["lt"] = "<", ["gt"] = ">", ["dotsb"] = "⋯", ["dotsi"] = "⋯", ["dotsm"] = "⋯", ["dotsc"] = "…", ["dotso"] = "…", + ["ldotp"] = ".", ["gggtr"] = "⋙", ["llless"] = "⋘", ["gvertneqq"] = "≩", ["lvertneqq"] = "≨", ["ngeqq"] = "≱", + ["nshortmid"] = "∤", ["nshortparallel"] = "∦", ["nsubseteqq"] = "⊈", ["omicron"] = "ο", ["S"] = "§", ["shortmid"] = "∣", + ["smallfrown"] = "⌢", ["smallsmile"] = "⌣", ["thicksim"] = "∼", ["thickapprox"] = "≈", ["varsubsetneqq"] = "⫋", + ["varsupsetneq"] = "⊋", ["varsupsetneqq"] = "⫌", ["bigcirc"] = "\\◯", ["centerdot"] = "⬝", ["degree"] = "°", + ["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\╱", ["diagdown"] = "\\╲", ["thickspace"] = "\\;", + ["And"] = "\\&", }; // the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ) @@ -312,11 +436,21 @@ private static string RewriteTeXOnly(string latex) } if (tex[i] != "\\") { + // the blanks around a script, x ^ { 2 }, which LaTeX ignores and UnicodeMath would part the script at; // a script that is a command with arguments, x_\text{ab}: the script is the command and its arguments - if ((tex[i] == "_" || tex[i] == "^") && ScriptCommand(tex, i + 1) is var (command, after) && command != null) + if (tex[i] == "_" || tex[i] == "^") { - result.Append(tex[i]).Append('{').Append(RewriteTeXOnly(command)).Append('}'); - i = after; + while (result.Length > 0 && result[result.Length - 1] == ' ') + result.Length--; + result.Append(tex[i]); + for (i++; i < tex.Length && tex[i] == " "; i++) + { + } + if (ScriptCommand(tex, i) is var (command, after) && command != null) + { + result.Append('{').Append(RewriteTeXOnly(command)).Append('}'); + i = after; + } continue; } // a combining mark after its base (𝑎⃑), held: the passes read the marks of \vec and the like before theirs @@ -500,6 +634,25 @@ private static string RewriteTeXOnly(string latex) i = afterOperator; continue; } + if (cw is "begin" or "end" && BracedArgument(tex, j) is var (environmentName, afterName) && afterName >= 0 && + (environmentName.EndsWith("matrix*", StringComparison.Ordinal) && Environments.ContainsKey(environmentName.TrimEnd('*')) || + cw == "begin" && (environmentName == "array" || environmentName.StartsWith("alignat", StringComparison.Ordinal)))) + { + // \begin{pmatrix*}[r] is the matrix, whose alignment an equation does not keep; the column + // specification of an array follows its name, where the passes take it: \begin{array}{l} + var plain = environmentName.EndsWith("matrix*", StringComparison.Ordinal) ? environmentName.TrimEnd('*') : environmentName; + result.Append('\\').Append(cw).Append('{').Append(plain).Append('}'); + i = afterName; + while (i < tex.Length && tex[i] == " ") + i++; + if (plain != environmentName && cw == "begin" && tex[i] == "[") + { + while (i < tex.Length && tex[i] != "]") + i++; + i++; + } + continue; + } // a combining mark over or under a base, \overset{̸}{b}, is its accent, as UnicodeMath writes it: (𝑏)̸ if ((Stacks.ContainsKey(cw) || cw == "underset") && BracedArgument(tex, j) is var (mark, afterMark) && afterMark >= 0 && Js.Len(mark) == 1 && IsAccent(mark) && BracedArgument(tex, afterMark) is var (accentBase, afterBase) && afterBase >= 0) @@ -769,7 +922,7 @@ private static (string Text, int Next) BracedArgument(Text tex, int i) private string Convert(string latex) { - var tex = new Text(EscapeOddBars(RewriteTeXOnly(latex))); + var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(latex)))); var cwPrev = ""; bool needMacroPass = false; var uniTeX = new StringBuilder(); diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index c0332a8..d01c194 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -48,9 +48,12 @@ private static readonly (string Environment, string Intent)[] MatrixIntents = private readonly bool standard; - private LaTeXSerializer(bool standard) + private readonly bool display; + + private LaTeXSerializer(bool standard, bool display) { this.standard = standard; + this.display = display; } /// @@ -59,7 +62,7 @@ private LaTeXSerializer(bool standard) /// public static string Serialize(XElement math, bool standard = false) { - var latex = new LaTeXSerializer(standard).TeX(MathML.DefaultIntents.Restore(math)); + var latex = new LaTeXSerializer(standard, (string)math.Attribute("display") == "block").TeX(MathML.DefaultIntents.Restore(math)); return standard ? LaTeXSymbols.Tidy(latex) : latex; } @@ -168,11 +171,31 @@ private string Unary(XElement node, string op) return op + "{" + ret + "}"; } - private string Binary(XElement node, string op) => CheckBracing(TeX(First(node)), true) + op + CheckBracing(TeX(Last(node))); + private string Binary(XElement node, string op) => CheckBracing(TeX(First(node)), true) + Limits(node) + op + CheckBracing(TeX(Last(node))); private string Ternary(XElement node, string op1, string op2) { - return CheckBracing(TeX(First(node)), true) + op1 + CheckBracing(TeX(Child(node, 1))) + op2 + CheckBracing(TeX(Last(node))); + return CheckBracing(TeX(First(node)), true) + Limits(node) + op1 + CheckBracing(TeX(Child(node, 1))) + op2 + CheckBracing(TeX(Last(node))); + } + + /// + /// In standard LaTeX, \limits or \nolimits after a large operator whose scripts are not where TeX puts them: + /// TeX puts them under and over a summation (∑, ∏, ⋃...) in a display equation, and at the side of an integral + /// and of a summation in an inline one. + /// + private string Limits(XElement scripted) + { + var op = First(scripted); + while (Name(op) == "mrow" && ChildCount(op) == 1) + op = First(op); + if (!standard || Name(op) != "mo" || !IsLargeOperator(op) || Len(Text(op)) != 1) + return ""; + var c = CodePointAt(Text(op), 0); + if (c >= 0x23DC && c <= 0x23E1) + return ""; + bool underOver = Name(scripted) is "munder" or "mover" or "munderover"; + bool summation = c >= 0x220F && c <= 0x2211 || c >= 0x22C0 && c <= 0x22C3 || c >= 0x2A00 && c <= 0x2A06; + return underOver == (summation && display) ? "" : underOver ? "\\limits" : "\\nolimits"; } private string Nary(XElement node, string op, int cNode) @@ -418,7 +441,7 @@ private string TeX(XElement value, bool noAddParens = false) if (standard) { ret = TakesLimits(First(value)) && !HasAttr(value, "accent") - ? CheckBracing(TeX(First(value)), true) + "^" + CheckBracing(TeX(Last(value))) + ? CheckBracing(TeX(First(value)), true) + Limits(value) + "^" + CheckBracing(TeX(Last(value))) : "\\overset{" + TeX(Last(value)) + "}{" + TeX(First(value)) + "}"; break; } @@ -451,7 +474,7 @@ private string TeX(XElement value, bool noAddParens = false) } if (standard) { - ret = CheckBracing(TeX(First(value)), true) + "_" + CheckBracing(TeX(Last(value))); + ret = CheckBracing(TeX(First(value)), true) + Limits(value) + "_" + CheckBracing(TeX(Last(value))); break; } if ((HasAttr(value, "accentunder") || HasAttr(value, "accent")) && IsCombiningMark(Text(Last(value)))) diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs index 2cfd9a3..e60a170 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs @@ -64,7 +64,7 @@ public static string FromLaTeX(string tex, bool display, bool alignmentMarks = f if (end > start + 1 && chars[end - 2] == "\\" && (chars[end - 1] == "]" || chars[end - 1] == ")")) end -= 2; } - var unicodemath = LaTeXReader.ToUnicodeMath(string.Concat(chars.Skip(start).Take(end - start)), macros); + var unicodemath = LaTeXReader.ToUnicodeMath(string.Concat(chars.Skip(start).Take(end - start)), macros, display); var umChars = UnicodeMathText.Characters(unicodemath); if (umChars.Count > 0 && umChars[0] == "ⓓ" && umChars.Skip(1).FirstOrDefault(c => c != " ") != "{") { diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 250b8b0..90dcecd 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -30,8 +30,9 @@ public class LaTeXReaderTests [Arguments(@"\overbrace{a+b}^{n}", @"\overset{n}{\overbrace{a+b}}", @"⏞(𝑎+𝑏)┴𝑛")] [Arguments(@"\underbrace{a+b}_{n}", @"\underset{n}{\underbrace{a+b}}", @"⏟(𝑎+𝑏)┬𝑛")] [Arguments(@"\operatorname{rank} A", @"\operatorname{rank}A", @"rank⁡𝐴")] - [Arguments(@"\sum\limits_{i=1}^n i", @"\sum_{i=1}^ni", @"∑_(𝑖=1)^𝑛 𝑖")] - [Arguments(@"\int\limits_0^1 f", @"\int_0^1f", @"∫₀¹ 𝑓")] + [Arguments(@"\sum\limits_{i=1}^n i", @"\sum\limits_{i=1}^ni", @"∑1_(𝑖=1)^𝑛 𝑖")] + [Arguments(@"\int\limits_0^1 f", @"\int\limits_0^1f", @"∫1₀¹ 𝑓")] + [Arguments(@"\sum\nolimits_{i=1}^n i", @"\sum_{i=1}^ni", @"∑_(𝑖=1)^𝑛 𝑖")] [Arguments(@"\iint_D f\,dA + \oint_C F", @"\iint_Df\,dA+\oint_CF", @"∬_𝐷 〖𝑓 〗𝑑𝐴+∮_𝐶 𝐹")] [Arguments(@"\prod_{i} a_i \coprod \bigcup_i A_i \bigcap_j B_j", @"\prod_ia_i\coprod\bigcup_iA_i\bigcap_jB_j", @"∏_𝑖 𝑎_𝑖 ∐⋃_𝑖 𝐴_𝑖 ⋂_𝑗 𝐵_𝑗")] [Arguments(@"a \pmod{n}", @"a\;(\mod\,n)", @"𝑎 (mod⁡ 𝑛)")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index b9a6a58..e1f9d4b 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -51,17 +51,21 @@ private static string Compact(Equation equation) => string.Concat(equation.ToMat { "fence", "form", "stretchy", "minsize", "maxsize", "symmetric", "lspace", "rspace", "columnalign", "rowalign", "columnspacing", "rowspacing", "columnlines", "rowlines", "accent", "accentunder", "movablelimits", "separator", - "displaystyle", "scriptlevel", + "displaystyle", "scriptlevel", "largeop", }; // the spacing accents of latex2mathml, with the combining marks SharpEquations writes for them private static readonly Dictionary Accents = new Dictionary { - ["˙"] = "̇", ["¨"] = "̈", ["˜"] = "̃", ["^"] = "̂", ["ˇ"] = "̌", ["´"] = "́", - ["`"] = "̀", ["˘"] = "̆", ["˚"] = "̊", ["→"] = "⃗", ["⃛"] = "⃛", ["⃜"] = "⃜", + ["˙"] = "\u0307", ["¨"] = "\u0308", ["˜"] = "\u0303", ["~"] = "\u0303", ["^"] = "\u0302", ["ˇ"] = "\u030C", + ["´"] = "\u0301", ["`"] = "\u0300", ["˘"] = "\u0306", ["˚"] = "\u030A", ["→"] = "\u20D7", }; - private static bool IsSpace(string text) => text.Length > 0 && text.All(c => c == ' ' || c == ' ' || c is >= ' ' and <= '​' || c == ' '); + // the bars over and under a base, a mark of mover or munder for latex2mathml, menclose for SharpEquations + private static readonly HashSet Bars = new HashSet { "¯", "‾", "―", "_", "\u0305", "\u0332" }; + + private static bool IsSpace(string text) => text.Length > 0 && + text.All(c => c == ' ' || c == '\u00A0' || c is >= '\u2000' and <= '\u200B' || c == '\u205F'); private static readonly HashSet InferredRow = new HashSet { "math", "mrow", "mtd", "msqrt", "mstyle", "mpadded", "mphantom", "menclose", "mtext" }; @@ -70,23 +74,35 @@ private static string Compact(Equation equation) => string.Concat(equation.ToMat /// made the same: attributes that say the default or set the style, spaces (formatting, which an equation does not /// keep), invisible function application and times, the middle dot of \cdot (· for latex2mathml, ⋅ for /// SharpEquations), names of operators and primes (mo for latex2mathml, mi and mo for SharpEquations), spacing - /// accents (˙ for latex2mathml, the combining ̇ for SharpEquations), bars (mover with ¯ for latex2mathml, + /// accents (˙ for latex2mathml, the combining dot above for SharpEquations), bars (mover with ¯ for latex2mathml, /// menclose for SharpEquations), capital Greek letters (upright in TeX, which latex2mathml writes as mi without - /// mathvariant), rows that group nothing. + /// mathvariant), symbols that are neither letters nor digits (mi for latex2mathml, %, °, mo for SharpEquations, + /// which marks a fence or a structure character taken literally with intent=":text"), rows that group nothing. /// private static string Canonical(Equation equation) { var math = new XElement(equation.ToMathML()); foreach (var e in math.DescendantsAndSelf().ToList()) { - e.Attributes().Where(a => Defaulted.Contains(a.Name.LocalName)).Remove(); + e.Attributes().Where(a => Defaulted.Contains(a.Name.LocalName) || a.Name.LocalName == "intent" && a.Value == ":text").Remove(); var name = e.Name.LocalName; + // a style as mathvariant, which MathML Core keeps for normal identifiers only: the math alphanumeric characters + if (!e.HasElements && (string?)e.Attribute("mathvariant") is { } variant && !(variant == "normal" && name == "mi") && + SharpEquations.Internal.UnicodeMath.UnicodeMathSerializer.MathVariants.TryGetValue(variant, out var style)) + { + e.Value = string.Concat(SharpEquations.Internal.Text.CodePoints.Of(e.Value) + .Select(c => SharpEquations.Internal.UnicodeMath.UnicodeMathText.GetMathAlphanumeric(c.ToString(), style))); + e.Attribute("mathvariant")!.Remove(); + } var text = e.HasElements ? null : e.Value; switch (name) { + case "mi" when text is { Length: 1 } && !char.IsLetterOrDigit(text[0]): + e.Name = M + "mo"; + break; case "mspace": case "mo" or "mtext" when text != null && IsSpace(text): - case "mo" when text is "⁡" or "⁢": + case "mo" when text is "\u2061" or "\u2062": e.Remove(); break; case "mo" when text == "·": @@ -104,8 +120,14 @@ private static string Canonical(Equation equation) break; } } - foreach (var e in math.Descendants(M + "mover").Where(e => e.Elements().Skip(1).FirstOrDefault()?.Value is "¯" or "‾" or "―" or "̅").ToList()) - e.ReplaceWith(new XElement(M + "menclose", new XAttribute("notation", "top"), e.Elements().First())); + foreach (var e in math.Descendants().Where(e => e.Name.LocalName is "mover" or "munder" && Bars.Contains(e.Elements().Skip(1).FirstOrDefault()?.Value ?? "")).ToList()) + e.ReplaceWith(new XElement(M + "menclose", new XAttribute("notation", e.Name.LocalName == "mover" ? "top" : "bottom"), e.Elements().First())); + // the empty operators that hold the place of a fence or a cell, and the rows of empty cells (an empty matrix + // is a table without rows for latex2mathml, one empty cell for SharpEquations) + math.Descendants(M + "mo").Where(e => !e.HasElements && e.Value.Length == 0).Remove(); + math.Descendants(M + "mtr").Where(r => r.Elements().All(d => !d.HasElements && d.Value.Length == 0)).Remove(); + foreach (var e in math.Descendants(M + "mstyle").Where(e => !e.HasAttributes)) + e.Name = M + "mrow"; bool changed; do { @@ -129,48 +151,63 @@ private static string Canonical(Equation equation) "spaces", "lg", "middle", "mathstrut", }; - // what the reader of LaTeX, which reads LaTeX as Word does, reads otherwise than latex2mathml: the commands Word - // does not know (\href, \class, \style, \color, \raise, \smash, \mathchoice, \LaTeX, the sizes, \hbox, \verb, - // the old font switches \rm, \cal, \Bbb...), the environments and arguments it does not take (matrix*, \matrix{}, - // the column specification of array, \hline, \substack, \sideset, \genfrac, \above, \atop, unbraced arguments as - // \underset ab), \limits and \mathop, spaces before scripts (x ^ { 2 }), a script without base, and the symbols - // it maps to other characters or does not have (\gt, \lt, \And, \S, \omicron, \dotsb, \shortmid...) - private static readonly HashSet ReadOtherwise = new HashSet - { - "307-a", "307-b", "And", "Bmatrix", "LaTeX", "LaTeX-with-style", "S", "TeX", "TeX-with-style", "above", - "abovewithdelims", "align-nonumber", "align-notag", "align0", "align1", "argmax", - "array-with-horizontal-lines", "atop-and-atopwithdelims", "backslash-e", "bigcirc", - "bigl-smallmatrix-bigr", "binomial-coefficients", "blackboard-bold", "boldsymbol", "boxed-box", "bra", - "brace", "brace-1", "braket", "calligraphic-mode", "centerdot", "cfrac", "circledS", "class", - "colorbox", "comments", "degree", "diagdown-diagup", "displaylines", "dotsb", "dotsc", "dotsi", "dotsm", - "dotso", "emph", "empty-color-works", "empty-subscript", "eqalign", "escaped-characters", - "exponent-without-base-works", "fbox", "fcolorbox", "footnotesize", "fraktur", "genfrac-displaystyle", - "genfrac-scriptscriptstyle", "genfrac-scriptstyle", "genfrac-textstyle", "gggtr", "global-fonts", - "greatest-common-divisor", "gt", "gvertneqq", "hbox", "hbox-with-backslash-in-text", - "hbox-with-math-mode", "hfil", "hphantom", "href", "huge", "idotsint", "injlim", "intop", "issue-106", - "issue-118", "issue-125-1-overset", "issue-125-3-underset", "issue-125-4-limits", - "issue-125-4-mathop", "issue-301-a", "issue-301-b", "issue-301-c", "issue-33", "issue-42", - "issue-498-prod", "issue-498-sum", "issue-60-1", "issue-60-2", "issue-75-1-row", "issue-75-2-rows", - "issue-76", "ket", "lVert-double", "large", "ldotp", "leftroot-uproot", "lg", "liminf", - "limit-at-plus-infinity", "limsup", "llap", "llless", "lower", "lt", "lvertneqq", - "math-commands-that-currently-does-nothing", "mathbf", "mathbin", "mathcal", "mathchoice-block", - "mathchoice-inline", "mathfrak", "mathit", "mathllap", "mathop", "mathord", "mathrel", - "mathrm", "mathscr", "mathsf", "mathsfit", "mathstrut", "mathtt", "matrix-with-alignment", - "matrix-without-begin-and-end", "middle", "mit", "moveleft", "moveright", "newcommand-escaped-hash", - "newenvironment-multi-node", "ngeqq", "normalsize-scriptsize", "not", "nshortmid", "nshortparallel", - "nsubseteqq", "null-delimiter", "numbers-identifiers-and-operators", "oldstyle", "omicron", - "operatorname-star", "operatornamewithlimits", "overbrace-c", - "pod", "prime-no-base", "projlim", "rVert-double", "raise", "rule", "rvert", "scr", "shortmid", - "shoveleft", "sideset", "skew", "skew-with-braces", "small-tiny", "smallfrown", "smallint", - "smallsmile", "smash", "smash-bottom", "smash-top", "spaces", "split", "style", "tag", "tag-star", - "textbf", "textcolor", "textit", "textrm", "textsf", "texttt", "thickapprox", "thicksim", "thickspace", - "tilde-1", "tilde-and-space-in-text", "tt", "underbrace-c", "underleftarrow", "underleftrightarrow", - "underline", "underrightarrow", "unicode", "upper-Vmatrix", "varinjlim", "varsubsetneqq", - "varsupsetneq", "varsupsetneqq", "vcenter", "verb", "vmatrix", "widecheck", "widetilde", - "xLeftarrow-double", "xhookleftarrow", "xleftarrow", "xleftarrow-with-argument", "xleftrightarrow", - "xrightarrow", "xrightarrow-with-argument", + // what the reader does not read as latex2mathml does, in four groups; a case that starts to pass must leave its + // group, and the union of the groups is what LaTeXReadsAsLatex2MathMLReadsIt allows + + // commands of MathJax, KaTeX, plain TeX or packages that Word's LaTeX, which the reader follows, does not have: + // links, classes, colors, boxes and moves, sizes, \hbox and \verb, \LaTeX, \skew, the primitives of plain TeX + // (\matrix{}, \cr, \eqalign, \displaylines, \above, \atop, \brace) and its font switches (\rm, \cal, \Bbb), + // the classes of \mathord and the like, the bra-kets of the physics package, \mathsfit, \argmax, \widecheck, + // the arrows of mathtools + private static readonly HashSet NotInWordLaTeX = new HashSet + { + "href", "class", "style", "textcolor", "colorbox", "fcolorbox", "raise", "lower", "moveleft", "moveright", + "shoveleft", "vcenter", "smash", "smash-bottom", "smash-top", "llap", "mathllap", "rule", "mathchoice-block", + "mathchoice-inline", "unicode", "verb", "huge", "large", "normalsize-scriptsize", "small-tiny", "footnotesize", + "LaTeX", "LaTeX-with-style", "TeX", "TeX-with-style", "hbox", "hbox-with-math-mode", "hbox-with-backslash-in-text", + "fbox", "leftroot-uproot", "skew", "skew-with-braces", "displaylines", "eqalign", "matrix-without-begin-and-end", + "hfil", "above", "abovewithdelims", "atop-and-atopwithdelims", "brace", "brace-1", "oldstyle", "scr", "tt", + "calligraphic-mode", "fraktur", "blackboard-bold", "global-fonts", "greatest-common-divisor", + "math-commands-that-currently-does-nothing", "mathbin", "mathord", "mathrel", "mathop", "issue-125-4-mathop", + "bra", "ket", "braket", "mathsfit", "argmax", "widecheck", "xLeftarrow-double", "xleftrightarrow", "xhookleftarrow", + }; + + // LaTeX and amsmath that the reader does not read yet: \lg and the operator names of amsmath (\liminf, \injlim, + // \operatorname*), \idotsint, \xrightarrow without braces or with its optional argument, the arrows under a + // base, \sideset, \substack, \genfrac with unbraced delimiters, \dbinom a b, unbraced arguments (\underset ab, + // \boxed\Box), \limits after \mathop, the scripts of \overbrace and \underbrace, \pod, \mathstrut, \middle, \not, + // a bar or a script alone (\rvert, |\,|, ^3, 'x), \color{}, \mathscr, a font over \text and braces (\mathrm{\{b\}}, + // \mathsf{\text{}}, \boldsymbol{1}), a digit of a math alphabet read as an identifier (\mathbf{1}), a number + // with a comma in a script (a_{1,1}), \newenvironment and ## in a macro + private static readonly HashSet NotReadYet = new HashSet + { + "lg", "liminf", "limsup", "injlim", "projlim", "varinjlim", "operatornamewithlimits", "operatorname-star", + "idotsint", "xleftarrow", "xleftarrow-with-argument", "xrightarrow", "xrightarrow-with-argument", + "underleftarrow", "underrightarrow", "underleftrightarrow", "sideset", "307-a", "307-b", "issue-75-1-row", + "issue-75-2-rows", "genfrac-displaystyle", "genfrac-textstyle", "genfrac-scriptstyle", "genfrac-scriptscriptstyle", + "binomial-coefficients", "issue-125-3-underset", "boxed-box", "issue-125-4-limits", "overbrace-c", "underbrace-c", + "pod", "mathstrut", "middle", "not", "rvert", "spaces", "exponent-without-base-works", "prime-no-base", + "empty-color-works", "mathscr", "mathrm", "mathsf", "boldsymbol", "mathbf", "mathtt", "issue-33", + "newenvironment-multi-node", "newcommand-escaped-hash", }; + // the same equation, which SharpEquations holds otherwise: an equation number as the label of its row (\tag), + // and none for aligned equations, whose numbers latex2mathml writes as cells; the alignment of align, split and + // cases as marks in one cell; ... as an ellipsis and * as the operator ∗, as Word reads them; an empty script as + // the placeholder ⬚; a letter of \text as an upright identifier; the space of \ and the font of the text + // (\textbf), formatting an equation does not keep + private static readonly HashSet HeldOtherwise = new HashSet + { + "tag", "tag-star", "align1", "align-nonumber", "align-notag", "split", "align0", "issue-106", "issue-60-1", + "issue-60-2", "numbers-identifiers-and-operators", "empty-subscript", "hphantom", "issue-118", "textbf", "textit", + "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" }; + + 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 private static readonly HashSet NotExpanded = new HashSet @@ -208,6 +245,8 @@ public async Task CorpusIsComplete() { await Assert.That(Conversions()).Count().IsEqualTo(327); await Assert.That(Macros()).Count().IsEqualTo(10); + // a case is in one group of what the reader reads otherwise + await Assert.That(ReadOtherwise.Count).IsEqualTo(NotInWordLaTeX.Count + NotReadYet.Count + HeldOtherwise.Count + NotLaTeX.Count); } // the MathML of latex2mathml writes LaTeX that, once read back, reads back to itself diff --git a/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs b/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs index dfa859f..346410d 100644 --- a/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs +++ b/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs @@ -44,7 +44,7 @@ public class MathMLWriterTests [Arguments(@"{1}", @"\begin{Bmatrix}1\end{Bmatrix}", @"\begin{Bmatrix}1\end{Bmatrix}", @"Ⓢ(1)")] [Arguments(@"{1x>0", @"\begin{cases}1&x>0\end{cases}", @"\begin{cases}1&𝑥>0\end{cases}", @"{■(1&𝑥>0)┤")] [Arguments(@"1001", @"\begin{matrix}1&0\\0&1\end{matrix}", @"\begin{matrix}1&0\\0&1\end{matrix}", @"■(1&0@0&1)")] - [Arguments(@"∑i=1nai", @"\sum_{i=1}^na_i", @"∑┬{𝑖=1}┴𝑛𝑎_𝑖", @"∑┬(𝑖=1)┴𝑛 𝑎_𝑖")] + [Arguments(@"∑i=1nai", @"\sum\limits_{i=1}^na_i", @"∑┬{𝑖=1}┴𝑛𝑎_𝑖", @"∑┬(𝑖=1)┴𝑛 𝑎_𝑖")] [Arguments(@"∫01f", @"\int_0^1f", @"∫_0^1𝑓", @"∫₀¹𝑓")] [Arguments(@"limx→0f", @"\lim_{x\to0}f", null, @"lim┬(𝑥→0) 𝑓")] [Arguments(@"sin⁡x+log⁡y+sgn⁡z", @"\sin x+\log y+\operatorname{sgn}z", null, @"sin⁡𝑥+log⁡𝑦+sgn⁡𝑧")] diff --git a/tests/data/README.md b/tests/data/README.md index 537847c..525b927 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -64,7 +64,12 @@ maintained by Murray Sargent. `tests/test_converter.py` of [roniemartinez/latex2mathml](https://github.com/roniemartinez/latex2mathml) (commit b9ee564589d79c3b7bfaf4bb920962745abbb694), with the MathML of their snapshots (`tests/__snapshots__/test_converter`, character references resolved), as `{id, latex, display, mathml}`; `latex/latex2mathml-macros.json`: the `test_macro` -cases, as `{id, latex, equivalent, display}`. Copyright (c) 2016 Ronie Martinez, MIT License. +cases, as `{id, latex, equivalent, display}`. Copyright (c) 2016 Ronie Martinez, MIT License. Nine snapshots were +corrected where latex2mathml writes MathML that is not what the LaTeX means: the `~` of `\text{ Hello~World }` and of +`\textbf`, `\textit`, `\textrm`, `\textsf`, `\texttt` is a no-break space, not a tilde (`tilde-and-space-in-text`, +`textbf`...); the superscript of `( - 25 ) ^ { 2 }` is that of the parenthesized group, not of `)` (`issue-42`); +`\omicron` is a letter, not an operator (`omicron`); the limit of `\lim` goes under it in a display equation +(`limit-at-plus-infinity`). `mathml/mml2tex.json`: the scenarios of `xspec/*.xspec` of [transpect/mml2tex](https://github.com/transpect/mml2tex) (commit 9458a7a2f5125609c92eb178fcd0f8b8419e2c40), MathML with the LaTeX mml2tex writes for it, and the MathML of diff --git a/tests/data/latex/latex2mathml-converter.json b/tests/data/latex/latex2mathml-converter.json index 824f632..9da7560 100644 --- a/tests/data/latex/latex2mathml-converter.json +++ b/tests/data/latex/latex2mathml-converter.json @@ -249,7 +249,7 @@ "id": "issue-42", "latex": "\\sqrt { ( - 25 ) ^ { 2 } } = \\pm 25", "display": true, - "mathml": "(−25)2=±25" + "mathml": "(−25)2=±25" }, { "id": "issue-45-lt", @@ -375,7 +375,7 @@ "id": "limit-at-plus-infinity", "latex": "\\lim_{x \\to +\\infty} f(x)", "display": true, - "mathml": "limx→+∞f(x)" + "mathml": "limx→+∞f(x)" }, { "id": "inf", @@ -597,7 +597,7 @@ "id": "tilde-and-space-in-text", "latex": "\\text{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "comments", @@ -1143,7 +1143,7 @@ "id": "omicron", "latex": "\\omicron", "display": true, - "mathml": "ο" + "mathml": "ο" }, { "id": "Pr", @@ -1407,31 +1407,31 @@ "id": "textbf", "latex": "\\textbf{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "textit", "latex": "\\textit{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "textrm", "latex": "\\textrm{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "textsf", "latex": "\\textsf{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "texttt", "latex": "\\texttt{ Hello~World }", "display": true, - "mathml": "\u00A0Hello~World\u00A0" + "mathml": "\u00A0Hello\u00A0World\u00A0" }, { "id": "LaTeX", From 1f0785511f35a72c0388ec8ce4aac568b1a60dc4 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 10:30:34 +0100 Subject: [PATCH 04/45] =?UTF-8?q?OMML=20reader:=20styled=20digits=20keep?= =?UTF-8?q?=20their=20style=20(=F0=9D=9F=8F,=20=F0=9D=9F=99,=20?= =?UTF-8?q?=F0=9D=9F=A3,=20=F0=9D=9F=AD,=20=F0=9D=9F=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omml2mml.xsl writes no mathvariant on numbers, so a bold one of OMML (m:sty b, "1") was read as a plain 1: 𝟏 of UnicodeMath and \mathbf{1} of LaTeX lost their bold through OMML. The digits of a bold, double-struck, sans-serif or monospace run now go through the stylesheet as their mathematical alphanumerics, each an identifier, as UnicodeMath and LaTeX read them, so the round trip gives the same equation. Monospace is read but still not written: mml2omml.xsl writes no m:scr for it. Co-Authored-By: Claude Opus 5.5 --- BuildVersion.props | 2 +- .../Internal/Omml/OmmlParser.cs | 30 ++++++++++ .../Omml/OmmlAstralTests.cs | 55 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/BuildVersion.props b/BuildVersion.props index 0a82098..c49d0f2 100644 --- a/BuildVersion.props +++ b/BuildVersion.props @@ -2,7 +2,7 @@ - 1.0.3 + 1.0.4 diff --git a/src/SharpEquations/Internal/Omml/OmmlParser.cs b/src/SharpEquations/Internal/Omml/OmmlParser.cs index e2fb54d..8403b84 100644 --- a/src/SharpEquations/Internal/Omml/OmmlParser.cs +++ b/src/SharpEquations/Internal/Omml/OmmlParser.cs @@ -7,6 +7,7 @@ namespace SharpEquations.Internal.Omml using System.Text.RegularExpressions; using System.Xml.Linq; using SharpEquations.Internal.Text; + using SharpEquations.Internal.UnicodeMath; using static SharpEquations.Internal.Omml.Omml; /// @@ -292,9 +293,38 @@ private static XElement PrepareOmml(XElement oMath) var text = run.Element(M + "t")!; text.Value = NameMark + text.Value; } + // omml2mml writes no mathvariant on numbers, which would lose the style of a bold or double-struck digit: + // the digits of a styled run go through the stylesheet as their mathematical alphanumerics (𝟏, 𝟙), each an + // identifier, as UnicodeMath and LaTeX read them + foreach (var run in copy.Descendants(M + "r")) + { + var properties = run.Elements(M + "rPr").LastOrDefault(); + if (properties == null || (string)properties.Element(M + "nor")?.Attribute(M + "val") == "on") + continue; + var style = DigitStyle((string)properties.Element(M + "scr")?.Attribute(M + "val"), (string)properties.Element(M + "sty")?.Attribute(M + "val")); + if (style == null) + continue; + foreach (var text in run.Elements(M + "t")) + text.Value = string.Concat(CodePoints.Of(text.Value).Select(c => c.Value is >= '0' and <= '9' ? UnicodeMathText.GetMathAlphanumeric(c.ToString(), style) : c.ToString())); + } return copy; } + /// The style of the mathematical digits for the m:scr and m:sty of a run, null for plain digits. + private static string DigitStyle(string script, string style) + { + return (script, style) switch + { + ("monospace", _) => "mtt", + ("sans-serif", "b") => "mbfsans", + ("sans-serif", "i" or "bi") => null, + ("sans-serif", _) => "msans", + ("double-struck", _) => "Bbb", + ("roman" or null or "", "b") => "mbf", + _ => null, + }; + } + private const char NameMark = '\uF8FF'; private static bool IsUprightName(XElement run) diff --git a/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs b/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs index 5e5f778..41ddbb6 100644 --- a/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs +++ b/tests/SharpEquations.Tests/Omml/OmmlAstralTests.cs @@ -166,6 +166,61 @@ public async Task OmmlRoundTripsWithAstralCharacters(string runs) await Assert.That(back.ToOmml().ToString()).IsEqualTo(equation.ToOmml().ToString()); } + /// + /// The style of a digit, which omml2mml.xsl does not write on numbers, is kept: a styled digit is its + /// mathematical alphanumeric, an identifier of its own, as UnicodeMath and LaTeX read it. + /// + [Test] + [Arguments("1", "𝟏")] + [Arguments("12", "𝟏𝟐")] + [Arguments("1+x", "𝟏+𝐱")] + [Arguments("1", "𝟙")] + [Arguments("1", "𝟣")] + [Arguments("1", "𝟭")] + [Arguments("12", "12")] + [Arguments("1", "1")] + [Arguments("12", "12")] + public async Task ReadsStyledDigits(string runs, string mathML) + { + var equation = Equation.Parse("" + runs + "", EquationFormat.Omml); + await Assert.That(equation.ToMathML().ToString(SaveOptions.DisableFormatting)).IsEqualTo(MathML(mathML)); + await Assert.That(Equation.Parse(equation.ToOmml(), EquationFormat.Omml)).IsEqualTo(equation); + } + + /// + /// A monospace digit is read, but not written back: mml2omml.xsl writes no m:scr for monospace. + /// + [Test] + public async Task ReadsMonospaceDigits() + { + var equation = Equation.Parse("1", EquationFormat.Omml); + await Assert.That(equation.ToMathML().ToString(SaveOptions.DisableFormatting)).IsEqualTo(MathML("𝟷")); + } + + [Test] + [Arguments("𝟏", EquationFormat.UnicodeMath)] + [Arguments("𝟏𝟐+2", EquationFormat.UnicodeMath)] + [Arguments("𝟙", EquationFormat.UnicodeMath)] + [Arguments("𝟣", EquationFormat.UnicodeMath)] + [Arguments("𝟭", EquationFormat.UnicodeMath)] + [Arguments(@"\mathbf{12}+\mathbb{1}", EquationFormat.LaTeX)] + public async Task RoundTripsStyledDigitsThroughOmml(string source, EquationFormat format) + { + var equation = Equation.Parse(source, format); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back).IsEqualTo(equation); + await Assert.That(back.ToString(format)).IsEqualTo(equation.ToString(format)); + } + + [Test] + public async Task IndicatorFunctionsOfTheCorpusKeepTheirBoldOne() + { + var equation = Equation.Parse("K_c (r) = 𝟏_[¼,¾] (r) + ½ × 𝟏_[0,¼] (r)", EquationFormat.UnicodeMath); + var back = Equation.Parse(equation.ToOmml(), EquationFormat.Omml); + await Assert.That(back.ToMathML().Descendants(MathMLNamespaces.MathML + "msub").Select(s => s.Elements().First().ToString(SaveOptions.DisableFormatting))) + .IsEquivalentTo(new[] { "K", "𝟏", "𝟏" }); + } + /// /// Depends on UTF-16 counting: an italic 🍕 of OMML (a run without m:sty) is written back upright, so it reads /// back as another equation. Counting code points, it would be written as it was read. From b6eeafce79db3f612a51e86b389c6ab2f686786e Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 10:49:35 +0100 Subject: [PATCH 05/45] LaTeX reader: the LaTeX and amsmath of the latex2mathml cases it did not read - extensible arrows with an optional argument and an unbraced label (\xrightarrow[y] x, \xLeftarrow, \xleftrightarrow, \xhookleftarrow), the arrows under a base (\underleftarrow), unbraced arguments of stacks, \boxed, \dbinom, \tbinom and \cfrac (\underset ab, \dbinom a b) - \underset{a}{\overset{b}{X}} as one munderover, as the writer writes it - \substack as a matrix, \sideset as the scripts on the phantom of the operator, \genfrac with escaped delimiters (\genfrac\{]...) - \pod, \mathstrut, \not over a symbol or a group, \lvert and \rvert as bars, \color{}, \mathscr, \idotsint, \Box, \operatorname* and \operatornamewithlimits - a script or a prime without a base (^3, 'x) on an empty one; a comma before a digit parts two numbers (a_{1,1}, a_{m,1}) - an argument can be a command with its arguments or an escaped character (\not\operatorname{R}, \genfrac\{) latex2mathml corpus: empty placeholders, zero lengths, the thickness of a bar, the moves of mpadded and the digits of a math alphabet (one identifier, as OMML reads them since #6) made the same; 103 cases left, 18 of them LaTeX not read yet. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 150 +++++++++++++++++- .../LaTeX/LaTeXReaderTests.cs | 12 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 66 +++++--- 3 files changed, 198 insertions(+), 30 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 8490c3a..50174f4 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -378,7 +378,8 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i) ["smallfrown"] = "⌢", ["smallsmile"] = "⌣", ["thicksim"] = "∼", ["thickapprox"] = "≈", ["varsubsetneqq"] = "⫋", ["varsupsetneq"] = "⊋", ["varsupsetneqq"] = "⫌", ["bigcirc"] = "\\◯", ["centerdot"] = "⬝", ["degree"] = "°", ["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\╱", ["diagdown"] = "\\╲", ["thickspace"] = "\\;", - ["And"] = "\\&", + ["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["Box"] = "\\□", ["idotsint"] = "\\∫⋯\\∫", + ["mathscr"] = "\\mathcal", ["operatornamewithlimits"] = "\\operatorname", ["mathstrut"] = "\\vphantom{\\(}", }; // the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ) @@ -442,6 +443,9 @@ private static string RewriteTeXOnly(string latex) { while (result.Length > 0 && result[result.Length - 1] == ' ') result.Length--; + // a script without a base (^3 at the start of a group) is on an empty one, as LaTeX puts it + if (HasNoBase(result)) + result.Append("{}"); result.Append(tex[i]); for (i++; i < tex.Length && tex[i] == " "; i++) { @@ -453,6 +457,17 @@ private static string RewriteTeXOnly(string latex) } continue; } + // a prime without a base ('x) is on an empty one, as LaTeX puts it + if (tex[i] == "'" && HasNoBase(result)) + result.Append("{}"); + // a comma before a digit parts two numbers in LaTeX (a_{1,1}, a_{m,1}), where UnicodeMath reads one (1,1 or + // ,1): {,} is LaTeX's decimal comma + if (tex[i] == "," && tex[i + 1] is { Length: 1 } next && next[0] is >= '0' and <= '9') + { + result.Append(", "); + i++; + continue; + } // a combining mark after its base (𝑎⃑), held: the passes read the marks of \vec and the like before theirs result.Append(IsAccent(tex[i]) ? HoldMark(tex[i]) : tex[i]); i++; @@ -618,12 +633,89 @@ private static string RewriteTeXOnly(string latex) i = afterFraction; continue; } - if ((cw == "xrightarrow" || cw == "xleftarrow") && BracedArgument(tex, j) is var (arrowLabel, afterArrowLabel) && afterArrowLabel >= 0) + if (ExtensibleArrows.TryGetValue(cw, out var arrow) && OptionalArgument(tex, j) is var (arrowUnder, afterUnder) && + Argument(tex, afterUnder) is var (arrowLabel, afterArrowLabel) && afterArrowLabel >= 0) { - result.Append(RewriteTeXOnly("\\overset{" + arrowLabel + "}{" + (cw == "xrightarrow" ? "\\rightarrow" : "\\leftarrow") + "}")); + // \xrightarrow[under]{over}, its label without braces too (\xrightarrow x) + var arrowOver = "\\overset{" + arrowLabel + "}{" + arrow + "}"; + result.Append(RewriteTeXOnly(arrowUnder == null ? arrowOver : "\\underset{" + arrowUnder + "}{" + arrowOver + "}")); i = afterArrowLabel; continue; } + if (UnderArrows.TryGetValue(cw, out var underArrow) && Argument(tex, j) is var (underBase, afterUnderBase) && afterUnderBase >= 0) + { + result.Append(RewriteTeXOnly("\\underset{" + underArrow + "}{" + underBase + "}")); + i = afterUnderBase; + 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) + { + // \underset{a}{\overset{b}{X}}: the scripts under and over X, munderover, as the writer writes it + result.Append("〖〖").Append(RewriteTeXOnly(overArguments[1])).Append("〗┬(").Append(RewriteTeXOnly(underArguments[0])) + .Append(")┴(").Append(RewriteTeXOnly(overArguments[0])).Append(")〗"); + i = afterUnderset; + continue; + } + if (cw is "underset" or "overset" or "stackrel" && !(NonBlank(tex, j) == "{" && NonBlank(tex, BracedArgument(tex, j).Next) == "{") && + Arguments(tex, j, 2) is var (stackArguments, afterStack)) + { + // the arguments of a stack braced: \underset ab + result.Append(RewriteTeXOnly("\\" + cw + "{" + stackArguments[0] + "}{" + stackArguments[1] + "}")); + i = afterStack; + continue; + } + if (cw == "boxed" && NonBlank(tex, j) != "{" && Argument(tex, j) is var (boxed, afterBoxed) && afterBoxed >= 0) + { + result.Append(RewriteTeXOnly("\\boxed{" + boxed + "}")); + i = afterBoxed; + continue; + } + if (cw == "substack" && BracedArgument(tex, j) is var (rows, afterSubstack) && afterSubstack >= 0) + { + // the rows of a script, as a matrix + result.Append(RewriteTeXOnly("\\begin{smallmatrix}" + rows + "\\end{smallmatrix}")); + i = afterSubstack; + continue; + } + if (cw == "sideset" && Arguments(tex, j, 3) is var (sideset, afterSideset)) + { + // \sideset{_1^2}{_3^4}\sum: the scripts before the operator on its phantom, as MathML writes them + result.Append(RewriteTeXOnly("{\\vphantom{" + sideset[2] + "}}" + sideset[0] + "{" + sideset[2] + "}" + sideset[1])); + i = afterSideset; + continue; + } + if (cw == "pod" && Argument(tex, j) is var (pod, afterPod) && afterPod >= 0) + { + result.Append("\\quad(").Append(RewriteTeXOnly(pod)).Append(')'); + i = afterPod; + continue; + } + if (cw == "not") + { + // \not over the next symbol, the long solidus of UnicodeMath (≠ for \not=), over a group when it is a command + // with arguments (\not\operatorname{R}); alone, a slash + var (negated, afterNegated) = Argument(tex, j); + if (afterNegated < 0) + { + result.Append("\\/"); + i = j; + continue; + } + var negatedText = new Text(negated); + bool symbol = negatedText.Length == 1 || negatedText[0] == "\\" && ControlWord(negatedText, 1).Length == negatedText.Length - 1; + result.Append(symbol ? RewriteTeXOnly(negated) : "(" + RewriteTeXOnly(negated) + ")").Append(HoldMark("\u0338")); + i = afterNegated; + continue; + } + if (cw == "operatorname" && tex[j] == "*") + { + // \operatorname*, a name with limits, read as \operatorname + result.Append("\\operatorname"); + i = j + 1; + continue; + } if (cw == "mathop" && BracedArgument(tex, j) is var (operatorText, afterOperator) && afterOperator >= 0) { // \mathop{\mathrm{argmin}}: an operator name @@ -735,7 +827,11 @@ private static string RewriteTeXOnly(string latex) body = tex.Sub(afterColor, end); afterBody = end; } - result.Append(cw == "color" ? "✎(" : "☁(").Append(color.Trim()).Append("&{").Append(RewriteTeXOnly(body)).Append("})"); + // \color{} has no color: the body alone + if (color.Trim().Length == 0) + result.Append(RewriteTeXOnly(body)); + else + result.Append(cw == "color" ? "✎(" : "☁(").Append(color.Trim()).Append("&{").Append(RewriteTeXOnly(body)).Append("})"); i = afterBody; continue; } @@ -753,7 +849,7 @@ private static string RewriteTeXOnly(string latex) ["mathit"] = 1, ["mathcal"] = 1, ["mathbb"] = 1, ["mathfrak"] = 1, ["mathsf"] = 1, ["mathtt"] = 1, ["mathscr"] = 1, ["boldsymbol"] = 1, ["operatorname"] = 1, ["boxed"] = 1, ["sqrt"] = 1, ["hat"] = 1, ["bar"] = 1, ["vec"] = 1, ["dot"] = 1, ["ddot"] = 1, ["tilde"] = 1, ["widehat"] = 1, ["widetilde"] = 1, ["overline"] = 1, ["underline"] = 1, - ["frac"] = 2, ["dfrac"] = 2, ["tfrac"] = 2, ["binom"] = 2, + ["frac"] = 2, ["dfrac"] = 2, ["tfrac"] = 2, ["cfrac"] = 2, ["binom"] = 2, ["dbinom"] = 2, ["tbinom"] = 2, }; /// The command with its braced arguments at the index (after blanks), and the index after it. @@ -895,9 +991,53 @@ private static (string Text, int Next) Argument(Text tex, int i) i++; if (i < tex.Length && tex[i] == "{") return BracedArgument(tex, i); + // a command with its arguments (\not\operatorname{R}), a command (\frac\alpha\beta, \not\in) or an escaped + // character (\genfrac\{]...) + if (i + 1 < tex.Length && tex[i] == "\\") + { + if (ScriptCommand(tex, i) is var (command, afterCommand) && command != null) + return (command, afterCommand); + var end = i + 1 + Math.Max(1, ControlWord(tex, i + 1).Length); + return (tex.Sub(i, end), end); + } return i < tex.Length && tex[i] != "\\" && tex[i] != "}" ? (tex[i], i + 1) : (null, -1); } + /// Whether a script written now would have no base: at the start of the equation, of a group or of a cell. + private static bool HasNoBase(StringBuilder result) + { + int end = result.Length; + while (end > 0 && result[end - 1] == ' ') + end--; + return end == 0 || result[end - 1] is '{' or '(' or '[' or '&' or '〖' || end > 1 && result[end - 1] == '\\' && result[end - 2] == '\\'; + } + + /// The optional [...] argument at the index (after blanks), null if there is none, and the index after it. + private static (string Text, int Next) OptionalArgument(Text tex, int i) + { + int j = i; + while (j < tex.Length && tex[j] == " ") + j++; + if (tex[j] != "[") + return (null, i); + int end = tex.IndexOf("]", j); + return end < 0 ? (null, i) : (tex.Sub(j + 1, end), end + 1); + } + + // the extensible arrows of amsmath and mathtools, \xrightarrow[under]{over} + private static readonly Dictionary ExtensibleArrows = new Dictionary(StringComparer.Ordinal) + { + ["xrightarrow"] = "\\rightarrow", ["xleftarrow"] = "\\leftarrow", ["xRightarrow"] = "\\Rightarrow", ["xLeftarrow"] = "\\Leftarrow", + ["xleftrightarrow"] = "\\leftrightarrow", ["xLeftrightarrow"] = "\\Leftrightarrow", ["xhookleftarrow"] = "\\hookleftarrow", + ["xhookrightarrow"] = "\\hookrightarrow", ["xmapsto"] = "\\mapsto", + }; + + // the arrows of amsmath under a base, \underleftarrow{a} + private static readonly Dictionary UnderArrows = new Dictionary(StringComparer.Ordinal) + { + ["underleftarrow"] = "\\leftarrow", ["underrightarrow"] = "\\rightarrow", ["underleftrightarrow"] = "\\leftrightarrow", + }; + /// The text of the {...} argument at the index (after blanks) and the index after it; -1 if there is none. private static bool IsAsciiLetterOrDigit(char c) => c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9'; diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 90dcecd..cd4614c 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -68,7 +68,7 @@ public class LaTeXReaderTests [Arguments(@"\begin{alignat}{2}a&=b\end{alignat}", @"\begin{aligned}a&=b\end{aligned}", @"█(𝑎&=𝑏)")] [Arguments(@"\xrightarrow{f}", @"\overset{f}{\to}", @"→┴𝑓")] [Arguments(@"\mathop{\mathrm{argmin}}_x f", @"\operatorname{argmin}_xf", @"argmin⁡_𝑥 𝑓")] - [Arguments(@"x\in\left\{1,2\right\}", @"x\in\{1,2\}", @"𝑥∈{1,2}")] + [Arguments(@"x\in\left\{1,2\right\}", @"x\in\{1,2\}", @"𝑥∈{1, 2}")] [Arguments(@"\{x\mid x>0\}", @"\{x\mid x>0\}", @"{𝑥∣𝑥>0}")] [Arguments(@"a\mid b", @"a\mid b", @"𝑎\∣𝑏")] [Arguments(@"\langle\psi\mid", @"\langle\psi\mid", @"⟨𝜓\∣")] @@ -78,6 +78,16 @@ public class LaTeXReaderTests [Arguments(@"\vphantom{\sum}_1^2\sum_3^4", @"\vphantom{\sum}_1^2\sum_3^4", @"⇳∑₁²∑₃⁴")] [Arguments(@"\_\{\}", @"\_\{\}", @"\_{}")] [Arguments(@"\Gamma=\mathit{\Gamma}", @"\Gamma=\mathit{\Gamma}", @"Γ=𝛤")] + [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(@"\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", @"⬚³+⬚′𝑥")] + [Arguments(@"a_{m,1}+\color{}b", @"a_{m,1}+b", @"𝑎_(𝑚, 1)+𝑏")] + [Arguments(@"\begin{pmatrix*}[r]1&2\end{pmatrix*}", @"\begin{pmatrix}1&2\end{pmatrix}", @"⒨(1&2)")] + [Arguments(@"x ^ { 2 } _ i", @"x_i^2", @"𝑥_𝑖^2")] + [Arguments(@"\sum_{\substack{i string.Concat(equation.ToMat // the bars over and under a base, a mark of mover or munder for latex2mathml, menclose for SharpEquations private static readonly HashSet Bars = new HashSet { "¯", "‾", "―", "_", "\u0305", "\u0332" }; + // a length of 0 in any unit: 0, 0pt, 0.0em + private static bool IsZero(string length) => length.Trim().TrimEnd("abcdefghijklmnopqrstuvwxyz%".ToCharArray()).Trim('0', '.').Length == 0; + private static bool IsSpace(string text) => text.Length > 0 && text.All(c => c == ' ' || c == '\u00A0' || c is >= '\u2000' and <= '\u200B' || c == '\u205F'); @@ -97,9 +100,13 @@ private static string Canonical(Equation equation) var text = e.HasElements ? null : e.Value; switch (name) { - case "mi" when text is { Length: 1 } && !char.IsLetterOrDigit(text[0]): + case "mi" when text is { Length: 1 } && !char.IsLetterOrDigit(text[0]) && text != "⬚": e.Name = M + "mo"; break; + // a digit of a math alphabet (𝟏), one identifier for SharpEquations, as OMML and UnicodeMath read it + case "mn" when text is { Length: 2 } && char.IsSurrogatePair(text, 0) && char.ConvertToUtf32(text, 0) is >= 0x1D7CE and <= 0x1D7FF: + e.Name = M + "mi"; + break; case "mspace": case "mo" or "mtext" when text != null && IsSpace(text): case "mo" when text is "\u2061" or "\u2062": @@ -128,6 +135,24 @@ private static string Canonical(Equation equation) math.Descendants(M + "mtr").Where(r => r.Elements().All(d => !d.HasElements && d.Value.Length == 0)).Remove(); foreach (var e in math.Descendants(M + "mstyle").Where(e => !e.HasAttributes)) e.Name = M + "mrow"; + // the thickness of a fraction bar that is not 0, and the moves of mpadded relative to the content (the labels + // of \xrightarrow), formatting; an operator that holds an element (\mathop{x}), its element; the empty + // placeholders (an empty mrow or mi for latex2mathml, ⬚ for SharpEquations), which hold a place only as the + // argument of a script or a fraction; a length of 0 in any unit (0px), 0 + math.Descendants(M + "mfrac").Attributes("linethickness").Where(a => !IsZero(a.Value)).Remove(); + foreach (var e in math.Descendants(M + "mpadded").Where(e => e.HasAttributes && e.Attributes().All(a => a.Value.StartsWith("+") || a.Value.StartsWith("-"))).ToList()) + e.ReplaceWith(new XElement(M + "mrow", e.Nodes())); + foreach (var a in math.Descendants(M + "mpadded").Attributes().Where(a => IsZero(a.Value))) + a.Value = "0"; + foreach (var e in math.Descendants(M + "mo").Where(e => e.HasElements).ToList()) + e.ReplaceWith(e.Nodes()); + foreach (var e in math.Descendants().Where(e => e.Name.LocalName is "mi" or "mrow" && !e.HasElements && e.Value is "" or "⬚").ToList()) + { + if (InferredRow.Contains(e.Parent!.Name.LocalName)) + e.Remove(); + else + e.ReplaceWith(new XElement(M + "mi")); + } bool changed; do { @@ -156,50 +181,43 @@ private static string Canonical(Equation equation) // commands of MathJax, KaTeX, plain TeX or packages that Word's LaTeX, which the reader follows, does not have: // links, classes, colors, boxes and moves, sizes, \hbox and \verb, \LaTeX, \skew, the primitives of plain TeX - // (\matrix{}, \cr, \eqalign, \displaylines, \above, \atop, \brace) and its font switches (\rm, \cal, \Bbb), - // the classes of \mathord and the like, the bra-kets of the physics package, \mathsfit, \argmax, \widecheck, - // the arrows of mathtools + // (\matrix{}, \cr, \eqalign, \displaylines, \above, \atop, \brace) and its font switches (\rm, \cal), the classes + // of \mathord, \mathbin and \mathrel, the bra-kets of the physics package, \mathsfit, \argmax, \widecheck private static readonly HashSet NotInWordLaTeX = new HashSet { "href", "class", "style", "textcolor", "colorbox", "fcolorbox", "raise", "lower", "moveleft", "moveright", - "shoveleft", "vcenter", "smash", "smash-bottom", "smash-top", "llap", "mathllap", "rule", "mathchoice-block", + "shoveleft", "vcenter", "smash-bottom", "smash-top", "llap", "mathllap", "rule", "mathchoice-block", "mathchoice-inline", "unicode", "verb", "huge", "large", "normalsize-scriptsize", "small-tiny", "footnotesize", "LaTeX", "LaTeX-with-style", "TeX", "TeX-with-style", "hbox", "hbox-with-math-mode", "hbox-with-backslash-in-text", "fbox", "leftroot-uproot", "skew", "skew-with-braces", "displaylines", "eqalign", "matrix-without-begin-and-end", "hfil", "above", "abovewithdelims", "atop-and-atopwithdelims", "brace", "brace-1", "oldstyle", "scr", "tt", - "calligraphic-mode", "fraktur", "blackboard-bold", "global-fonts", "greatest-common-divisor", - "math-commands-that-currently-does-nothing", "mathbin", "mathord", "mathrel", "mathop", "issue-125-4-mathop", - "bra", "ket", "braket", "mathsfit", "argmax", "widecheck", "xLeftarrow-double", "xleftrightarrow", "xhookleftarrow", + "calligraphic-mode", "fraktur", "global-fonts", "greatest-common-divisor", + "math-commands-that-currently-does-nothing", "mathbin", "mathord", "mathrel", "bra", "ket", "braket", "mathsfit", + "argmax", "widecheck", }; - // LaTeX and amsmath that the reader does not read yet: \lg and the operator names of amsmath (\liminf, \injlim, - // \operatorname*), \idotsint, \xrightarrow without braces or with its optional argument, the arrows under a - // base, \sideset, \substack, \genfrac with unbraced delimiters, \dbinom a b, unbraced arguments (\underset ab, - // \boxed\Box), \limits after \mathop, the scripts of \overbrace and \underbrace, \pod, \mathstrut, \middle, \not, - // a bar or a script alone (\rvert, |\,|, ^3, 'x), \color{}, \mathscr, a font over \text and braces (\mathrm{\{b\}}, - // \mathsf{\text{}}, \boldsymbol{1}), a digit of a math alphabet read as an identifier (\mathbf{1}), a number - // with a comma in a script (a_{1,1}), \newenvironment and ## in a macro + // 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 (|\,|); a font over \text and escaped braces (\mathrm{\{b\}}, + // \mathsf{\text{}}, \boldsymbol{1}); \newenvironment and ## in a macro private static readonly HashSet NotReadYet = new HashSet { "lg", "liminf", "limsup", "injlim", "projlim", "varinjlim", "operatornamewithlimits", "operatorname-star", - "idotsint", "xleftarrow", "xleftarrow-with-argument", "xrightarrow", "xrightarrow-with-argument", - "underleftarrow", "underrightarrow", "underleftrightarrow", "sideset", "307-a", "307-b", "issue-75-1-row", - "issue-75-2-rows", "genfrac-displaystyle", "genfrac-textstyle", "genfrac-scriptstyle", "genfrac-scriptscriptstyle", - "binomial-coefficients", "issue-125-3-underset", "boxed-box", "issue-125-4-limits", "overbrace-c", "underbrace-c", - "pod", "mathstrut", "middle", "not", "rvert", "spaces", "exponent-without-base-works", "prime-no-base", - "empty-color-works", "mathscr", "mathrm", "mathsf", "boldsymbol", "mathbf", "mathtt", "issue-33", + "issue-125-4-limits", "overbrace-c", "underbrace-c", "middle", "spaces", "mathrm", "mathsf", "boldsymbol", "newenvironment-multi-node", "newcommand-escaped-hash", }; // the same equation, which SharpEquations holds otherwise: an equation number as the label of its row (\tag), // and none for aligned equations, whose numbers latex2mathml writes as cells; the alignment of align, split and - // cases as marks in one cell; ... as an ellipsis and * as the operator ∗, as Word reads them; an empty script as - // the placeholder ⬚; a letter of \text as an upright identifier; the space of \ and the font of the text + // cases as marks in one cell; ... as an ellipsis and * as the operator ∗, as Word reads them; a letter of \text + // as an upright identifier; \Box as □, as KaTeX and MathJax write it (◻ for latex2mathml); \not as the long + // solidus over the symbol, where latex2mathml puts a slash before it; the space of \ and the font of the text // (\textbf), formatting an equation does not keep private static readonly HashSet HeldOtherwise = new HashSet { "tag", "tag-star", "align1", "align-nonumber", "align-notag", "split", "align0", "issue-106", "issue-60-1", - "issue-60-2", "numbers-identifiers-and-operators", "empty-subscript", "hphantom", "issue-118", "textbf", "textit", + "issue-60-2", "numbers-identifiers-and-operators", "hphantom", "boxed-box", "not", "issue-118", "textbf", "textit", "textsf", "texttt", "emph", }; From 697efd9eb4aaa60c352608a7b0e761c29072d1c7 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 10:56:19 +0100 Subject: [PATCH 06/45] LaTeX reader: spaces before a script ignored, as after \right| (\right| _0) Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 7 +++++++ tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 36aed34..b82143f 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -306,6 +306,13 @@ private static string RewriteTeXOnly(string latex) } if (tex[i] != "\\") { + // the spaces before a script, which LaTeX ignores (\right| _0) and UnicodeMath would read as the end of the base + if (tex[i] == " " && NonBlank(tex, i) is "_" or "^") + { + while (tex[i] == " ") + i++; + continue; + } // a script that is a command with arguments, x_\text{ab}: the script is the command and its arguments if ((tex[i] == "_" || tex[i] == "^") && ScriptCommand(tex, i + 1) is var (command, after) && command != null) { diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 896937a..a0002b1 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -155,6 +155,18 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, await Assert.That(Equation.Parse(expectedLaTeX, EquationFormat.LaTeX)).IsEqualTo(Equation.Parse(latex, EquationFormat.LaTeX)).Because("the LaTeX written reads back"); } + // the demos of math.webc.site, whose LaTeX and UnicodeMath read back to the same equation + [Test] + [Arguments(@"\left. \frac{df}{dx} \right| _0", @"{\left.\frac{df}{dx}\right|}_0", @"├𝑑𝑓/𝑑𝑥 ┤|₀")] + [Arguments(@"\sum _{i=1} ^n i", @"\sum_{i=1}^ni", @"∑_(𝑖=1)^𝑛 𝑖")] + public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) + { + await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); + var equation = Equation.Parse(latex, EquationFormat.LaTeX); + await Assert.That(Equation.Parse(expectedLaTeX, EquationFormat.LaTeX)).IsEqualTo(equation).Because("the LaTeX written reads back"); + await Assert.That(Equation.Parse(expectedUnicodeMath, EquationFormat.UnicodeMath)).IsEqualTo(equation).Because("the UnicodeMath written reads back"); + } + [Test] [Arguments(@"{x}^2", @"x^2")] [Arguments(@"{a+b}^2", @"{a+b}^2")] From c79283f13fa7dc96e084b77d7fd101693782588c Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 10:56:51 +0100 Subject: [PATCH 07/45] LaTeX reader: \lt and \gt, the < and > of MathJax Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 2 +- tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index b82143f..49ae177 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -254,7 +254,7 @@ private static bool IsParenthesized(string arg) { ["dfrac"] = "\\frac", ["tfrac"] = "\\frac", ["cfrac"] = "\\frac", ["dbinom"] = "\\binom", ["tbinom"] = "\\binom", ["overrightarrow"] = "\\vec", ["Vert"] = "‖", ["lVert"] = "‖", ["rVert"] = "‖", ["bmod"] = "\\operatorname{mod}", - ["bm"] = "\\boldsymbol", + ["bm"] = "\\boldsymbol", ["lt"] = "<", ["gt"] = ">", }; // the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ) diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index a0002b1..0507412 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -159,6 +159,8 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, [Test] [Arguments(@"\left. \frac{df}{dx} \right| _0", @"{\left.\frac{df}{dx}\right|}_0", @"├𝑑𝑓/𝑑𝑥 ┤|₀")] [Arguments(@"\sum _{i=1} ^n i", @"\sum_{i=1}^ni", @"∑_(𝑖=1)^𝑛 𝑖")] + [Arguments(@"a \lt 1 \gt b", @"a<1>b", @"𝑎<1>𝑏")] + [Arguments(@"y \lt 1 \tag{1}", @"y<1\tag{1}", @"𝑦<1#(1)")] public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) { await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); From 6a2697fc10565a6638bc82e3c2352d1de4b2ac3d Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:01:07 +0100 Subject: [PATCH 08/45] LaTeX: the font switches of plain TeX ({\rm rxn}), and upright capital Greek written as \Delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {\rm ...}, {\bf ...}, {\it ...}, {\cal ...}, {\sf ...} and {\tt ...} style the rest of their group, as \mathrm{...} and the others. An upright capital Greek letter is written \Delta rather than \text{Δ}, which read back as text: the capitals of LaTeX are upright. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 44 ++++++++++++++----- .../Internal/LaTeX/LaTeXSymbols.cs | 5 ++- .../LaTeX/LaTeXReaderTests.cs | 5 +++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 49ae177..4744778 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -539,30 +539,50 @@ private static string RewriteTeXOnly(string latex) var (body, afterBody) = BracedArgument(tex, afterColor); if (afterBody < 0) { - int depth = 0, end = afterColor; - for (; end < tex.Length; end++) - { - if (tex[end] == "\\") - end++; - else if (tex[end] == "{") - depth++; - else if (tex[end] == "}" && depth-- == 0) - break; - } - body = tex.Sub(afterColor, end); - afterBody = end; + afterBody = GroupEnd(tex, afterColor); + body = tex.Sub(afterColor, afterBody); } result.Append(cw == "color" ? "✎(" : "☁(").Append(color.Trim()).Append("&{").Append(RewriteTeXOnly(body)).Append("})"); i = afterBody; continue; } } + else if (FontSwitches.TryGetValue(cw, out var font)) + { + // an old font switch, {\rm rxn}, styles the rest of its group: \mathrm{rxn} + var end = GroupEnd(tex, j); + result.Append('\\').Append(font).Append('{').Append(RewriteTeXOnly(tex.Sub(j, end).Trim())).Append('}'); + i = end; + continue; + } result.Append(tex.Sub(i, j)); i = j; } return result.ToString(); } + // the font switches of plain TeX, which style the rest of their group, as the LaTeX commands with an argument + private static readonly Dictionary FontSwitches = new Dictionary(StringComparer.Ordinal) + { + ["rm"] = "mathrm", ["bf"] = "mathbf", ["it"] = "mathit", ["cal"] = "mathcal", ["sf"] = "mathsf", ["tt"] = "mathtt", + }; + + /// The index of the brace that closes the group the index is in, or the length of the text. + private static int GroupEnd(Text tex, int i) + { + int depth = 0; + for (; i < tex.Length; i++) + { + if (tex[i] == "\\") + i++; + else if (tex[i] == "{") + depth++; + else if (tex[i] == "}" && depth-- == 0) + break; + } + return Math.Min(i, tex.Length); + } + // the commands with one or two arguments that can be a script without braces, as x_\text{ab} or x^\frac12 private static readonly Dictionary ArgumentCommands = new Dictionary(StringComparer.Ordinal) { diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index 17e9f1a..eafa7ca 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -189,9 +189,10 @@ private static string Character(string ch) /// Upright text in math: \mathrm{x} for a letter, \text{...} for words. public static string Upright(string text) { - // an upright Greek letter is text: \alpha is italic, and \mathrm does not change it + // an upright Greek letter is text: \alpha is italic, and \mathrm does not change it; the capitals of LaTeX, + // \Delta, are upright if (Js.Len(text) == 1 && Commands.ContainsKey(text)) - return char.IsLetter(text, 0) ? Text(text) : Math(text); + return char.IsLetter(text, 0) && !(text[0] >= 'Α' && text[0] <= 'Ω') ? Text(text) : Math(text); return Js.Len(text) == 1 ? "\\mathrm{" + Math(text) + "}" : Text(text); } diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 0507412..87ddd47 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -161,6 +161,11 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, [Arguments(@"\sum _{i=1} ^n i", @"\sum_{i=1}^ni", @"∑_(𝑖=1)^𝑛 𝑖")] [Arguments(@"a \lt 1 \gt b", @"a<1>b", @"𝑎<1>𝑏")] [Arguments(@"y \lt 1 \tag{1}", @"y<1\tag{1}", @"𝑦<1#(1)")] + [Arguments(@"{\rm rxn}", @"\text{rxn}", @"""rxn""")] + [Arguments(@"{\rm d} x", @"\mathrm{d}x", @"""d""𝑥")] + [Arguments(@"{\bf x} + {\it ab} + {\cal F} + {\sf s} + {\tt t}", @"\mathbf{x}+ab+\mathcal{F}+\mathsf{s}+\mathtt{t}", @"𝐱+𝑎𝑏+ℱ+𝗌+𝚝")] + [Arguments(@"\Delta H_{\rm rxn}^\circ = -R T \ln K", @"\Delta H_{\text{rxn}}^\circ=-RT \ln K", @"Δ𝐻_""rxn""^∘ =−𝑅𝑇 ln⁡𝐾")] + [Arguments(@"\Gamma=\Omega", @"\Gamma=\Omega", @"Γ=Ω")] public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) { await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); From 05c7949fe7384cee84f85ba13d677afa3901b710 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:02:11 +0100 Subject: [PATCH 09/45] =?UTF-8?q?LaTeX=20writer:=20the=20degree=20sign=20a?= =?UTF-8?q?s=20\degree,=20not=20\circ=20(=E2=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs | 2 +- tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index eafa7ca..577e3e4 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -36,7 +36,7 @@ internal static partial class LaTeXSymbols // letterlike and miscellaneous ["∞"] = "infty", ["∂"] = "partial", ["∇"] = "nabla", ["ℏ"] = "hbar", ["ℓ"] = "ell", ["℘"] = "wp", ["ℜ"] = "Re", ["ℑ"] = "Im", ["ℵ"] = "aleph", ["∅"] = "emptyset", ["∀"] = "forall", ["∃"] = "exists", ["∄"] = "nexists", ["¬"] = "neg", - ["…"] = "ldots", ["⋯"] = "cdots", ["⋮"] = "vdots", ["⋱"] = "ddots", ["°"] = "circ", ["†"] = "dagger", ["‡"] = "ddagger", + ["…"] = "ldots", ["⋯"] = "cdots", ["⋮"] = "vdots", ["⋱"] = "ddots", ["°"] = "degree", ["†"] = "dagger", ["‡"] = "ddagger", ["∠"] = "angle", ["△"] = "triangle", ["□"] = "square", ["◊"] = "lozenge", ["⊤"] = "top", ["⊥"] = "perp", ["⊺"] = "intercal", // large operators ["∑"] = "sum", ["∏"] = "prod", ["∐"] = "coprod", ["∫"] = "int", ["∬"] = "iint", ["∭"] = "iiint", ["∮"] = "oint", diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 87ddd47..36478f7 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -166,6 +166,8 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, [Arguments(@"{\bf x} + {\it ab} + {\cal F} + {\sf s} + {\tt t}", @"\mathbf{x}+ab+\mathcal{F}+\mathsf{s}+\mathtt{t}", @"𝐱+𝑎𝑏+ℱ+𝗌+𝚝")] [Arguments(@"\Delta H_{\rm rxn}^\circ = -R T \ln K", @"\Delta H_{\text{rxn}}^\circ=-RT \ln K", @"Δ𝐻_""rxn""^∘ =−𝑅𝑇 ln⁡𝐾")] [Arguments(@"\Gamma=\Omega", @"\Gamma=\Omega", @"Γ=Ω")] + [Arguments(@"\theta = 45\degree", @"\theta=45\degree", @"𝜃=45°")] + [Arguments(@"f \circ g", @"f\circ g", @"𝑓∘𝑔")] public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) { await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); From c2cd9971d8c74d8dcb584d246545e443b8345d07 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:05:52 +0100 Subject: [PATCH 10/45] =?UTF-8?q?LaTeX=20writer:=20the=20bar=20on=20top=20?= =?UTF-8?q?(menclose=20top)=20as=20\bar,=20which=20reads=20back=20as=20it,?= =?UTF-8?q?=20not=20\overline=20(=C2=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \bar{x} reads as menclose notation="top", \overline{x} as an mover with ¯: the writer wrote both as \overline, so that \bar{x} read back as another equation. Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs | 3 ++- tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 4 +++- tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 117ef71..8a540ad 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -306,7 +306,8 @@ private string TeX(XElement value, bool noAddParens = false) var notation = Attr(value, "notation"); if (notation == "top" || notation == "bottom") { - ret = Unary(value, notation == "top" ? "\\overline" : "\\underline"); + // the bar on top is the one of \bar (x̅), \overline being the overbar of UnicodeMath (¯x) + ret = Unary(value, notation == "top" ? "\\bar" : "\\underline"); break; } if (standard && notation == "circle") diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 36478f7..75e25e6 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -25,7 +25,7 @@ public class LaTeXReaderTests [Arguments(@"\left\lfloor x \right\rfloor", @"\lfloor x\rfloor", @"⌊𝑥⌋")] [Arguments(@"\bigl( x \bigr)", @"(x)", @"(𝑥)")] [Arguments(@"\Big( x \Big)", @"(x)", @"(𝑥)")] - [Arguments(@"\hat{x}\widehat{xy}\bar{x}\vec{v}\dot{x}\ddot{x}\tilde{x}\check{x}\breve{x}\acute{x}\grave{x}", @"\hat{x}\hat{xy}\overline{x}\vec{v}\dot{x}\ddot{x}\tilde{x}\check{x}\breve{x}\acute{x}\grave{x}", @"𝑥̂(𝑥𝑦)̂̄𝑥 𝑣⃗𝑥̇𝑥̈𝑥̃𝑥̌𝑥̆𝑥́𝑥̀")] + [Arguments(@"\hat{x}\widehat{xy}\bar{x}\vec{v}\dot{x}\ddot{x}\tilde{x}\check{x}\breve{x}\acute{x}\grave{x}", @"\hat{x}\hat{xy}\bar{x}\vec{v}\dot{x}\ddot{x}\tilde{x}\check{x}\breve{x}\acute{x}\grave{x}", @"𝑥̂(𝑥𝑦)̂̄𝑥 𝑣⃗𝑥̇𝑥̈𝑥̃𝑥̌𝑥̆𝑥́𝑥̀")] [Arguments(@"\overline{x+y}\underline{z}", @"\overline{x+y}\underline{z}", @"¯(𝑥+𝑦)▁𝑧")] [Arguments(@"\overbrace{a+b}^{n}", @"\overset{n}{\overbrace{a+b}}", @"⏞(𝑎+𝑏)┴𝑛")] [Arguments(@"\underbrace{a+b}_{n}", @"\underset{n}{\underbrace{a+b}}", @"⏟(𝑎+𝑏)┬𝑛")] @@ -168,6 +168,8 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, [Arguments(@"\Gamma=\Omega", @"\Gamma=\Omega", @"Γ=Ω")] [Arguments(@"\theta = 45\degree", @"\theta=45\degree", @"𝜃=45°")] [Arguments(@"f \circ g", @"f\circ g", @"𝑓∘𝑔")] + [Arguments(@"\bar{x} = \frac{1}{n} \cdot \sum_{i=1}^n x_i", @"\bar{x}=\frac{1}{n}\cdot\sum_{i=1}^nx_i", @"̄𝑥=1/𝑛 ⋅∑_(𝑖=1)^𝑛 𝑥_𝑖")] + [Arguments(@"\bar{A\cup B}+\overline{A\cup B}", @"\bar{A\cup B}+\overline{A\cup B}", @"̄(𝐴∪𝐵)+¯(𝐴∪𝐵)")] public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) { await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); diff --git a/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs b/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs index dfa859f..d2ec54c 100644 --- a/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs +++ b/tests/SharpEquations.Tests/MathML/MathMLWriterTests.cs @@ -20,7 +20,7 @@ public class MathMLWriterTests [Arguments(@"ab⏞", @"\overbrace{ab}", @"\overbrace{𝑎𝑏}", @"⏞𝑎𝑏")] [Arguments(@"ab⏟", @"\underbrace{ab}", @"\underbrace{𝑎𝑏}", @"⏟𝑎𝑏")] [Arguments(@"x", @"\boxed{x}", @"\boxed{𝑥}", @"▭𝑥")] - [Arguments(@"x", @"\overline{x}", @"\overline{𝑥}", @"̄𝑥")] + [Arguments(@"x", @"\bar{x}", @"\bar{𝑥}", @"̄𝑥")] [Arguments(@"x", @"\underline{x}", @"\underline{𝑥}", @"▁𝑥")] [Arguments(@"x", @"\cancel{x}", @"\cancel{𝑥}", @"▭(143&𝑥)")] [Arguments(@"x", @"\bcancel{x}", @"\bcancel{𝑥}", @"▭(79&𝑥)")] From 2fb2c8903de60e137a9f46a58942d914dce05661 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:07:44 +0100 Subject: [PATCH 11/45] LaTeX reader: \overset{a}{b} followed by an identifier stays apart from it, and takes scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script of ┴ took what came after the stack (a\overset{n\to\infty}{\longrightarrow}b read b into it): a space ends it, also after \xrightarrow. \overset{a}{b}^2, which was not valid UnicodeMath, is grouped for its script. Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 16 +++++++++++++++- .../LaTeX/LaTeXReaderTests.cs | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 4744778..8ac87e8 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -458,6 +458,7 @@ private static string RewriteTeXOnly(string latex) if ((cw == "xrightarrow" || cw == "xleftarrow") && BracedArgument(tex, j) is var (arrowLabel, afterArrowLabel) && afterArrowLabel >= 0) { result.Append(RewriteTeXOnly("\\overset{" + arrowLabel + "}{" + (cw == "xrightarrow" ? "\\rightarrow" : "\\leftarrow") + "}")); + EndScript(result, tex, afterArrowLabel); i = afterArrowLabel; continue; } @@ -485,7 +486,10 @@ private static string RewriteTeXOnly(string latex) var (body, afterBody) = afterScript < 0 ? (null, -1) : BracedArgument(tex, afterScript); if (afterBody >= 0) { - result.Append('〖').Append(RewriteTeXOnly(body)).Append('〗').Append(stack).Append('(').Append(RewriteTeXOnly(script)).Append(')'); + var stacked = "〖" + RewriteTeXOnly(body) + "〗" + stack + "(" + RewriteTeXOnly(script) + ")"; + // scripts of the whole, \overset{a}{b}^2, go on it grouped + result.Append(tex[afterBody] is "_" or "^" ? "〖" + stacked + "〗" : stacked); + EndScript(result, tex, afterBody); i = afterBody; continue; } @@ -567,6 +571,16 @@ private static string RewriteTeXOnly(string latex) ["rm"] = "mathrm", ["bf"] = "mathbf", ["it"] = "mathit", ["cal"] = "mathcal", ["sf"] = "mathsf", ["tt"] = "mathtt", }; + /// + /// A space after the script of a stack (┴(a)), which would take what follows too in UnicodeMath + /// (\overset{a}{b}c, \overset{a}{b}\frac12), unless a space, a script or the end of the group follows. + /// + private static void EndScript(StringBuilder result, Text tex, int next) + { + if (next < tex.Length && tex[next] is not (" " or "_" or "^" or "}")) + result.Append(' '); + } + /// The index of the brace that closes the group the index is in, or the length of the text. private static int GroupEnd(Text tex, int i) { diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 75e25e6..79952b8 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -170,6 +170,9 @@ public async Task ReadsSlashesAndBarsAsLaTeX(string latex, string expectedLaTeX, [Arguments(@"f \circ g", @"f\circ g", @"𝑓∘𝑔")] [Arguments(@"\bar{x} = \frac{1}{n} \cdot \sum_{i=1}^n x_i", @"\bar{x}=\frac{1}{n}\cdot\sum_{i=1}^nx_i", @"̄𝑥=1/𝑛 ⋅∑_(𝑖=1)^𝑛 𝑥_𝑖")] [Arguments(@"\bar{A\cup B}+\overline{A\cup B}", @"\bar{A\cup B}+\overline{A\cup B}", @"̄(𝐴∪𝐵)+¯(𝐴∪𝐵)")] + [Arguments(@"a\stackrel{n\to\infty}{\longrightarrow}b", @"a\overset{n\to\infty}{\longrightarrow}b", @"𝑎⟶┴(𝑛→∞) 𝑏")] + [Arguments(@"x\xrightarrow{f}y", @"x\overset{f}{\to}y", @"𝑥→┴𝑓 𝑦")] + [Arguments(@"{\overset{a}{b}}c", @"\overset{a}{b}c", @"𝑏┴𝑎 𝑐")] public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, string expectedUnicodeMath) { await ReadsLaTeX(latex, expectedLaTeX, expectedUnicodeMath); @@ -180,6 +183,7 @@ public async Task ReadsLaTeXThatWritesBack(string latex, string expectedLaTeX, s [Test] [Arguments(@"{x}^2", @"x^2")] + [Arguments(@"\overset{a}{b}^2", @"\overset{a}{b}^2")] [Arguments(@"{a+b}^2", @"{a+b}^2")] [Arguments(@"{a+b}_n", @"{a+b}_n")] [Arguments(@"x_\text{ab}", @"x_{\text{ab}}")] From db91a8f3ae4b1863832d17ffd061f7f0f278f78d Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:12:12 +0100 Subject: [PATCH 12/45] UnicodeMath writer: a fraction after a function or a script grouped, which would be its numerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lim_(ℎ→0) ⁡𝑓(𝑥)/ℎ and ⟶┴(𝑛→∞) 1/2 read back as fractions of all that came before: the function apply sign does not end a numerator, and the space after a script ends the script, not the numerator. The fraction is grouped there: lim_(ℎ→0) ⁡〖𝑓(𝑥)/ℎ〗, ⟶┴(𝑛→∞) 〖1/2〗. Co-Authored-By: Claude Opus 5.5 --- .../UnicodeMath/UnicodeMathSerializer.cs | 21 ++++++++++++++++--- .../UnicodeMath/UnicodeMathTests.cs | 13 ++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index b21a909..7c89095 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -713,6 +713,19 @@ private string ScriptText(XElement script) } /// The base of digit scripts (𝑥₁): grouped when it has scripts of its own or several parts. + /// + /// Whether an element ends with a script (𝑥_𝑖, →┴𝑛), which a space after it ends: not a digit script (𝑥²), an + /// accent (𝑥̂) or the limits of an n-ary operator, whose operand the space starts (∑_𝑖 1/𝑖). + /// + private static bool EndsWithScript(XElement node) + { + while (Name(node) == "mrow" && ChildCount(node) > 0) + node = Last(node); + if (IsNaryHead(node) || IsDigitArg(node) || Name(node) is "mover" or "munder" && (HasAttr(node, "accent") || HasAttr(node, "accentunder") || IsCombining(Text(Last(node))))) + return false; + return Name(node) is "msub" or "msup" or "msubsup" or "mover" or "munder" or "munderover"; + } + private string ScriptBase(XElement node) { var ret = Dump(node); @@ -936,11 +949,13 @@ private string DumpCore(XElement value, bool noAddParens) op = "⒞"; } ret = Binary(value, op); - // after a space operator, which does not end its numerator, the fraction is grouped - if (Attr(parent, "intent") == ":function" || Name(Previous(value)) == "mo" && Text(Previous(value)).Length > 0 && Text(Previous(value)).Trim().Length == 0) + // after a space operator or the function apply sign, which do not end its numerator, the fraction is grouped + if (Attr(parent, "intent") == ":function" || Name(Previous(value)) == "mo" && Text(Previous(value)).Length > 0 && + (Text(Previous(value)).Trim().Length == 0 || Text(Previous(value)) == "⁡")) ret = "〖" + ret.Trim(' ') + "〗"; else if (Previous(value) != null && Name(Previous(value)) != "mo") - ret = " " + ret; + // after a script, which the space ends, the fraction is grouped too: 𝑥_𝑖 〖1/2〗 + ret = " " + (EndsWithScript(Previous(value)) ? "〖" + ret.Trim(' ') + "〗" : ret); break; case "msup": diff --git a/tests/SharpEquations.Tests/UnicodeMath/UnicodeMathTests.cs b/tests/SharpEquations.Tests/UnicodeMath/UnicodeMathTests.cs index b58595c..deb1049 100644 --- a/tests/SharpEquations.Tests/UnicodeMath/UnicodeMathTests.cs +++ b/tests/SharpEquations.Tests/UnicodeMath/UnicodeMathTests.cs @@ -129,6 +129,19 @@ public async Task WritesWhatReadsBack(string unicodeMath, string expected) await Assert.That(Equation.Parse(expected, EquationFormat.UnicodeMath)).IsEqualTo(equation); } + // a fraction after a function or a script, which UnicodeMath would make its numerator + [Test] + [Arguments(@"\lim_{h\to0}\frac{f(x)}{h}", "lim_(ℎ→0) ⁡〖𝑓(𝑥)/ℎ〗")] + [Arguments(@"\sin\frac{x}{2}", "sin⁡〖𝑥/2〗")] + [Arguments(@"a\stackrel{n\to\infty}{\longrightarrow}\frac{1}{2}", "𝑎⟶┴(𝑛→∞) 〖1/2〗")] + [Arguments(@"x_i\frac{1}{2}+x^n\frac{1}{2}", "𝑥_𝑖 〖1/2〗+𝑥^𝑛 〖1/2〗")] + [Arguments(@"x^2\frac{1}{2}+\hat{x}\frac{1}{2}", "𝑥² 1/2+𝑥̂ 1/2")] public async Task WritesFractionsApartFromWhatPrecedes(string latex, string expected) + { + var equation = Equation.Parse(latex, EquationFormat.LaTeX); + await Assert.That(equation.ToString(EquationFormat.UnicodeMath)).IsEqualTo(expected); + await Assert.That(Equation.Parse(expected, EquationFormat.UnicodeMath)).IsEqualTo(equation); + } + [Test] [Arguments("\"\\foo\"", "\\foo")] [Arguments("\"\\p\"", "\\p")] From 9dbafd862a2e8dbee3e25b0bed7168e639379ac6 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 11:13:46 +0100 Subject: [PATCH 13/45] 1.0.5 Co-Authored-By: Claude Opus 5.5 --- BuildVersion.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BuildVersion.props b/BuildVersion.props index c49d0f2..83c5ff3 100644 --- a/BuildVersion.props +++ b/BuildVersion.props @@ -2,7 +2,7 @@ - 1.0.4 + 1.0.5 From 0bebb37c396efb042418569ff0fe3159b0624102 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:01:04 +0100 Subject: [PATCH 14/45] Content MathML read through ctop.xsl; amssymb symbols in LaTeX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content MathML (apply, ci, cn...) outside the annotations is rewritten as presentation MathML by David Carlisle's ctop.xsl (MIT), keeping the presentation MathML around it. White space between elements and around the text of ci/cn is removed first, and the limits ctop leaves empty after. ctop.xsl is fixed for nested mi in bvar, x^{} in partialdiff without a degree, an unprefixed mo, a bare log and ∫ as mi. The 40 equations of ctop's test page are the corpus of the new tests. From the table of entities.xsl (XSLT MathML Library): about 70 amssymb symbols written as commands, 15 commands the LaTeX reader did not know (\square, \surd, \triangleq, \S...), and □ and √ written as \square and \surd in standard LaTeX instead of \□ and \√. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- NOTICE | 32 + README.md | 2 +- docs/ARCHITECTURE.md | 6 + .../Internal/LaTeX/LaTeXReader.cs | 5 + .../Internal/LaTeX/LaTeXSerializer.cs | 3 +- .../Internal/LaTeX/LaTeXSymbols.cs | 23 +- .../Internal/MathML/MathMLParser.cs | 59 + src/SharpEquations/Resources/ctop.xsl | 2116 +++++++++++++++++ .../LaTeX/LaTeXReaderTests.cs | 40 + .../MathML/ContentMathMLTests.cs | 168 ++ .../MathML/MathMLReaderTests.cs | 1 - tests/data/README.md | 5 + tests/data/contentmathml/ctop-01.mml | 19 + tests/data/contentmathml/ctop-02.mml | 16 + tests/data/contentmathml/ctop-03.mml | 11 + tests/data/contentmathml/ctop-04.mml | 13 + tests/data/contentmathml/ctop-05.mml | 7 + tests/data/contentmathml/ctop-06.mml | 8 + tests/data/contentmathml/ctop-07.mml | 3 + tests/data/contentmathml/ctop-08.mml | 12 + tests/data/contentmathml/ctop-09.mml | 7 + tests/data/contentmathml/ctop-10.mml | 7 + tests/data/contentmathml/ctop-11.mml | 12 + tests/data/contentmathml/ctop-12.mml | 3 + tests/data/contentmathml/ctop-13.mml | 6 + tests/data/contentmathml/ctop-14.mml | 41 + tests/data/contentmathml/ctop-15.mml | 13 + tests/data/contentmathml/ctop-16.mml | 20 + tests/data/contentmathml/ctop-17.mml | 5 + tests/data/contentmathml/ctop-18.mml | 9 + tests/data/contentmathml/ctop-19.mml | 23 + tests/data/contentmathml/ctop-20.mml | 23 + tests/data/contentmathml/ctop-21.mml | 23 + tests/data/contentmathml/ctop-22.mml | 11 + tests/data/contentmathml/ctop-23.mml | 6 + tests/data/contentmathml/ctop-24.mml | 14 + tests/data/contentmathml/ctop-25.mml | 19 + tests/data/contentmathml/ctop-26.mml | 16 + tests/data/contentmathml/ctop-27.mml | 15 + tests/data/contentmathml/ctop-28.mml | 15 + tests/data/contentmathml/ctop-29.mml | 15 + tests/data/contentmathml/ctop-30.mml | 3 + tests/data/contentmathml/ctop-31.mml | 3 + tests/data/contentmathml/ctop-32.mml | 7 + tests/data/contentmathml/ctop-33.mml | 11 + tests/data/contentmathml/ctop-34.mml | 11 + tests/data/contentmathml/ctop-35.mml | 14 + tests/data/contentmathml/ctop-36.mml | 5 + tests/data/contentmathml/ctop-37.mml | 5 + tests/data/contentmathml/ctop-38.mml | 4 + tests/data/contentmathml/ctop-39.mml | 4 + tests/data/contentmathml/ctop-40.mml | 5 + 53 files changed, 2922 insertions(+), 5 deletions(-) create mode 100644 src/SharpEquations/Resources/ctop.xsl create mode 100644 tests/SharpEquations.Tests/MathML/ContentMathMLTests.cs create mode 100644 tests/data/contentmathml/ctop-01.mml create mode 100644 tests/data/contentmathml/ctop-02.mml create mode 100644 tests/data/contentmathml/ctop-03.mml create mode 100644 tests/data/contentmathml/ctop-04.mml create mode 100644 tests/data/contentmathml/ctop-05.mml create mode 100644 tests/data/contentmathml/ctop-06.mml create mode 100644 tests/data/contentmathml/ctop-07.mml create mode 100644 tests/data/contentmathml/ctop-08.mml create mode 100644 tests/data/contentmathml/ctop-09.mml create mode 100644 tests/data/contentmathml/ctop-10.mml create mode 100644 tests/data/contentmathml/ctop-11.mml create mode 100644 tests/data/contentmathml/ctop-12.mml create mode 100644 tests/data/contentmathml/ctop-13.mml create mode 100644 tests/data/contentmathml/ctop-14.mml create mode 100644 tests/data/contentmathml/ctop-15.mml create mode 100644 tests/data/contentmathml/ctop-16.mml create mode 100644 tests/data/contentmathml/ctop-17.mml create mode 100644 tests/data/contentmathml/ctop-18.mml create mode 100644 tests/data/contentmathml/ctop-19.mml create mode 100644 tests/data/contentmathml/ctop-20.mml create mode 100644 tests/data/contentmathml/ctop-21.mml create mode 100644 tests/data/contentmathml/ctop-22.mml create mode 100644 tests/data/contentmathml/ctop-23.mml create mode 100644 tests/data/contentmathml/ctop-24.mml create mode 100644 tests/data/contentmathml/ctop-25.mml create mode 100644 tests/data/contentmathml/ctop-26.mml create mode 100644 tests/data/contentmathml/ctop-27.mml create mode 100644 tests/data/contentmathml/ctop-28.mml create mode 100644 tests/data/contentmathml/ctop-29.mml create mode 100644 tests/data/contentmathml/ctop-30.mml create mode 100644 tests/data/contentmathml/ctop-31.mml create mode 100644 tests/data/contentmathml/ctop-32.mml create mode 100644 tests/data/contentmathml/ctop-33.mml create mode 100644 tests/data/contentmathml/ctop-34.mml create mode 100644 tests/data/contentmathml/ctop-35.mml create mode 100644 tests/data/contentmathml/ctop-36.mml create mode 100644 tests/data/contentmathml/ctop-37.mml create mode 100644 tests/data/contentmathml/ctop-38.mml create mode 100644 tests/data/contentmathml/ctop-39.mml create mode 100644 tests/data/contentmathml/ctop-40.mml diff --git a/AGENTS.md b/AGENTS.md index fffb163..c9adb1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,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 6863e21..db46ade 100644 --- a/NOTICE +++ b/NOTICE @@ -182,3 +182,35 @@ 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/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. 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 b38d2dc..9255feb 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 463e184..916f1e4 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -246,6 +246,11 @@ private static bool IsParenthesized(string arg) ["dfrac"] = "\\frac", ["tfrac"] = "\\frac", ["cfrac"] = "\\frac", ["dbinom"] = "\\binom", ["tbinom"] = "\\binom", ["overrightarrow"] = "\\vec", ["Vert"] = "‖", ["lVert"] = "‖", ["rVert"] = "‖", ["bmod"] = "\\operatorname{mod}", ["bm"] = "\\boldsymbol", + // the symbols of LaTeX and amssymb that UnicodeMath has no control word for, and \square, \blacksquare and + // \surd, which are symbols in LaTeX and build a box or a root in UnicodeMath + ["yen"] = "¥", ["circledR"] = "®", ["S"] = "§", ["P"] = "¶", ["mho"] = "℧", ["rightsquigarrow"] = "↝", + ["leftrightsquigarrow"] = "↭", ["measuredangle"] = "∡", ["sphericalangle"] = "∢", ["eqsim"] = "≂", ["doteqdot"] = "≑", + ["triangleq"] = "≜", ["square"] = "\\□", ["blacksquare"] = "\\■", ["surd"] = "\\√", }; // the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 2985525..06a8b58 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -534,8 +534,9 @@ private string TeX(XElement value, bool noAddParens = false) return "\\" + val + " "; if (OperatorMap.TryGetValue(val, out var mapped)) return mapped; + // escaped as the reader reads it, but as a command (\square, \surd) in standard LaTeX, which has one if (IsStructureOperator(val)) - return "\\" + val; + return standard && LaTeXSymbols.IsCommand(val) ? LaTeXSymbols.Math(val) : "\\" + val; if (val == "$") { ret = "\\$"; diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index 4521441..306603d 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -55,7 +55,25 @@ internal static 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 @@ -98,6 +116,9 @@ public static bool TryGetPlain(string ch, out string style, out string plain) return found; } + /// 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/MathML/MathMLParser.cs b/src/SharpEquations/Internal/MathML/MathMLParser.cs index 51a8c43..304c2e9 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 @@ -381,6 +385,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/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 bceefa7..b5d6c5a 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -305,5 +305,45 @@ 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", "¶")] + 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("a" + symbol + "b", 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/MathML/ContentMathMLTests.cs b/tests/SharpEquations.Tests/MathML/ContentMathMLTests.cs new file mode 100644 index 0000000..d9111d1 --- /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("x1", "x+1")] + [Arguments("x2", "x2")] + [Arguments("3x", "x3")] + [Arguments("x", "|x|")] + [Arguments("n", "n!")] + [Arguments("x", "x∈ℝ")] + [Arguments("fx", "f⁡(x)")] + [Arguments("abc", + "a−(b−c)")] + [Arguments("abc", + "(a+b)⁢c")] + [Arguments("2x", + "log2⁡x")] + public async Task ReadsContentMathMLAsPresentation(string content, string expected) + { + await Assert.That(Read(content)).IsEqualTo(expected); + } + + [Test] + [Arguments("ab", "a/b")] + [Arguments("x", @"\sin x")] + [Arguments("x0x", + @"\int_0^\infty e^{-x}dx")] + [Arguments("i1ni", @"\sum_{i=1}^ni")] + [Arguments("xx", @"\frac{d \sin x}{dx}")] + [Arguments("xyfxy", + @"\frac{\partial^2f(x,y)}{\partial x\partial y}")] + [Arguments("x2f", @"\frac{\partial^2f}{\partial x^2}")] + [Arguments("xx0x", + @"\lim_{x\to0}x")] + [Arguments("1001", + @"\begin{pmatrix}1&0\\0&1\end{pmatrix}")] + [Arguments("ab", @"\{a,b\}")] + [Arguments("01", "(0,1]")] + [Arguments("pq", @"p\wedge\neg q")] + [Arguments("plusxy", "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("x24", EquationFormat.MathML); + var presentation = Equation.Parse("x2=4", EquationFormat.MathML); + await Assert.That(content).IsEqualTo(presentation); + } + + [Test] + public async Task KeepsThePresentationMathMLAroundIt() + { + await Assert.That(Read("f=x1")) + .IsEqualTo("f=x+1"); + await Assert.That(Read("xy1")) + .IsEqualTo("x+y1"); + } + + [Test] + public async Task TakesThePresentationAnnotationOfSemantics() + { + await Assert.That(Read("x1y")) + .IsEqualTo("y"); + } + + [Test] + public async Task LeavesContentAnnotationsAlone() + { + const string annotated = "xx"; + 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("x+1", 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("x", 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("" + deep + "", 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("x", "apply")] [Arguments("x", "svg")] public async Task RejectsOtherElementsWhenStrict(string mathML, string element) { diff --git a/tests/data/README.md b/tests/data/README.md index b43b99e..d09cd2c 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -59,3 +59,8 @@ author expects for each (the `results` table of `dist/unicodemathml-integration. [MurrayIII/UnicodeMathML](https://github.com/MurrayIII/UnicodeMathML) (commit 636dc8f), as `{mathml, unicodemath, tex}`: 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. 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 @@ + + + x + + x1 + + +⟶ + + x + + x + +⟶ + + + + + 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 @@ + +rational + + x + + P + + + + y + + Q + + + + 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 @@ + + +ab +cd + +⟶ + +ab +cd + + 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 @@ + +41-234 +⟶ +xyz +⟶ + +x +-2y +xz +1 +2 + + 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 @@ + +123 +⟶ +xyz +⟶ +2x + 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 @@ + + + i + 0 + 100 + xi + + 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 @@ + +ab + 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 @@ + +map + lambda + x + times + 2 + x + + + Z + + 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 @@ + + + x + + 2x + + 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 @@ + + 2 3.1415 +⟶ +nd +⟶ +PQ + 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 @@ + + f + f + f + a + a + + + + + + 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 @@ + + A + 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 @@ + + + DivisionByZero + dividex0 + + 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 @@ + + + 113 + f + +⟶ + + 113 + + x + y + z + fxyz + + +⟶ + + x + y + fxy + +⟶ + + xm + yn + k + f + x + y + + +⟶ + + x + yn + f + x + y + + + 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 @@ + +f +⟶ + + x + x + +⟶ + + x2 + x4 + + 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 @@ + +root + x + 2 + +⟶ + + a + +⟶ + + n + a + +⟶ + + 2 + a + + 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 @@ + + f +⟶ +424 + 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 @@ + + + x + + xx + 0 + + + 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 @@ + + + x + a + b + fx + +⟶ + + x + + xB + + fx + +⟶ + + + B + + f + + 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 @@ + + + x + a + b + fx + +⟶ + + x + + xB + + fx + +⟶ + + + B + + f + + 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 @@ + + + + + +⟶ + + ab + + +⟶ + + x + 0 + 1 + x2 + +⟶ + + C + f + + 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 @@ + +01 +⟶ +01 +⟶ +01 +⟶ +01 +⟶ +01 + 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 @@ + + + 3 + x + + 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 @@ + + + + + 12 + 34 + + 1 + + + 12 + + + 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 @@ + + + i + j + + + + i + 15 + + + j + 59 + + + + ij + + 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 @@ + + + + x + x0 + + + 0 + x0 + + + x + x0 + + + 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 @@ + + + + 0 + x0 + + + 1 + x1 + + + x + + + 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 @@ + +piecewise + piece + 0 + ltx0 + + piece + 1 + gtx1 + + otherwise + x + + + 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 @@ + + + + abc + +listabc + +→ + + + abc + +setabc + + 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 @@ + +123456789 + 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 @@ + + +a +n +12 + + 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 @@ + + + 1729 + + +, +, +, +, +, + 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 @@ + + +, +, +, +, +, +,,gamma +, +, + 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 @@ + +7FE0 +, +1729 +, +1729 +, +1729 +, +complex-cartesian + n + d + + 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 @@ + +a1b +, +x121b + 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 @@ + +a1b +, +x121b + 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 @@ + + + ABC + 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 @@ + + + ABC + 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 @@ + +xa +, +tendstoabovexa + From c053ada75c2b9087bf8119afa27d2e9b43cf8a4a Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:03:01 +0100 Subject: [PATCH 15/45] =?UTF-8?q?Writer=20and=20reader=20fixes:=20bars,=20?= =?UTF-8?q?empty=20slashes,=20operator=20names,=20Word's=20=E2=96=A1=20and?= =?UTF-8?q?=20=E2=88=9A,=20upright=20names=20with=20digits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UnicodeMath writer: a bar that is the base of a script is written ├| (sin├|_ℤ), a slash with nothing before it as ⓡ(/&U+002F), a bar without its pair inside brackets as ⓡ(|&U+007C) ([𝑎|𝑏] read as [𝑎 and the fence |𝑏]), and no ┤ is added before a fenced row at the end of the equation - UnicodeMath grammar: a name marked as a function without an operand (NaN⁡,) is one name, so \operatorname{NaN}, reads back as NaN and stays stable - Word's LaTeX: an identifier that is a UnicodeMath structure operator (□, √) is escaped - OMML reader: an upright name with digits (sharet1) is one identifier Co-Authored-By: Claude Opus 5.5 --- BuildVersion.props | 2 +- .../Internal/LaTeX/LaTeXSerializer.cs | 4 + .../Internal/Omml/OmmlParser.cs | 4 +- .../Internal/Text/AsciiPatterns.cs | 4 + .../UnicodeMath/UnicodeMathGrammar.parlot.cs | 6 +- .../UnicodeMath/UnicodeMathSerializer.cs | 28 ++++- .../Regression/WriterRoundTripTests.cs | 103 ++++++++++++++++++ 7 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs diff --git a/BuildVersion.props b/BuildVersion.props index c49d0f2..83c5ff3 100644 --- a/BuildVersion.props +++ b/BuildVersion.props @@ -2,7 +2,7 @@ - 1.0.4 + 1.0.5 diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 117ef71..6764a7a 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -573,6 +573,10 @@ private string TeX(XElement value, bool noAddParens = false) break; case "mi": + // in Word's LaTeX, an operator of UnicodeMath as an identifier (□, √) is escaped, as it is as an operator: + // alone, it would be read as its structure (√𝑥, a square root) or not at all (□) + if (!standard && IsStructureOperator(Text(value)) && !value.HasElements) + return "\\" + Text(value); if (standard) { ret = StandardIdentifier(value); diff --git a/src/SharpEquations/Internal/Omml/OmmlParser.cs b/src/SharpEquations/Internal/Omml/OmmlParser.cs index 8403b84..691943a 100644 --- a/src/SharpEquations/Internal/Omml/OmmlParser.cs +++ b/src/SharpEquations/Internal/Omml/OmmlParser.cs @@ -284,7 +284,7 @@ private static XElement PrepareOmml(XElement oMath) var value = property.Attribute(M + "val"); property.SetAttributeValue(M + "val", value == null || IsTrue(value.Value) ? "on" : "off"); } - // an upright word (max, argmin) is one identifier, which omml2mml would split letter by letter: it goes + // an upright word (max, argmin, sharet1) is one identifier, which omml2mml would split letter by letter: it goes // through the stylesheet as normal text, marked, and comes back as an mi (see UnmarkNames) foreach (var run in copy.Descendants(M + "r").Where(IsUprightName).ToList()) { @@ -333,7 +333,7 @@ private static bool IsUprightName(XElement run) var texts = run.Elements(M + "t").ToList(); return properties != null && properties.Element(M + "nor") == null && (string)properties.Element(M + "sty")?.Attribute(M + "val") == "p" && !run.Ancestors(M + "fName").Any() && texts.Count == 1 && texts[0].Value.Length > 1 - && AsciiPatterns.Letters().IsMatch(texts[0].Value); + && AsciiPatterns.Name().IsMatch(texts[0].Value); } private static void UnmarkNames(XElement math) diff --git a/src/SharpEquations/Internal/Text/AsciiPatterns.cs b/src/SharpEquations/Internal/Text/AsciiPatterns.cs index c3bd28f..be88e0f 100644 --- a/src/SharpEquations/Internal/Text/AsciiPatterns.cs +++ b/src/SharpEquations/Internal/Text/AsciiPatterns.cs @@ -13,6 +13,10 @@ internal static partial class AsciiPatterns [GeneratedRegex(@"^[A-Za-z]+\z")] public static partial Regex Letters(); + /// A name: a letter and letters or digits after it (sharet1). + [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9]*\z")] + public static partial Regex Name(); + /// Digits only, or nothing. [GeneratedRegex(@"^[0-9]*\z")] public static partial Regex Digits(); diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs index 2d4e38e..37c3bcb 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs @@ -436,7 +436,11 @@ private static Parser Build() return (object)O("function", O(("f", O("atoms", c)), ("of", t.Item3))); }), functionName.AndSkip(Opt(__)).And(operand).AndSkip(Opt(_)) - .Then(t => (object)O("function", O(("f", O("atoms", O("chars", FunctionName(t.Item1, false)))), ("of", t.Item2))))); + .Then(t => (object)O("function", O(("f", O("atoms", O("chars", FunctionName(t.Item1, false)))), ("of", t.Item2)))), + // a name marked as a function without an operand (NaN⁡,) is one name, as a function name is (sin⁡,), and not + // its letters; the function apply sign is read after it (but not one with scripts after the sign, argmax⁡┬(𝑥∈𝑆)) + OneOrMany(alphaN).AndSkip(Not(Not(L("⁡").And(Not(opScript))))) + .Then(t => (object)O("atoms", O(("chars", Join(t.ToList())), ("funct", true))))); // text, overrides, colors, comments, typewriter text.Parser = L("\"").SkipAnd(OneOrMany(OneOf(L("\\\"").Then(x => (object)"\""), Not(L("\"")).SkipAnd(anyChar)))).AndSkip(L("\"")) diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index b21a909..4008b6a 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -169,7 +169,8 @@ private static bool IsFence(XElement mo) /// /// An operator that reading would take for a structure: an enclosure or a root sign without its operand, or an - /// opening bracket that nothing closes inside a row. + /// opening bracket that nothing closes inside a row, or a bar without its pair inside brackets ([𝑎|𝑏], which + /// reading takes for [𝑎 and the fence |𝑏]). /// private static bool IsLoneStructure(XElement mo) { @@ -179,10 +180,23 @@ private static bool IsLoneStructure(XElement mo) if ("▭√∛∜⟡▢◯⬭".Contains(text)) return true; var parent = mo.Parent; + if (text is "|" or "‖" && Role(parent) == ":fenced" && First(parent) != mo && Last(parent) != mo && Text(First(parent)) is not ("|" or "‖" or "⟨")) + return Children(parent).Count(c => c != First(parent) && c != Last(parent) && Name(c) == "mo" && Text(c) == text) % 2 == 1; return (IsOpening(text) || IsOpenDelimiter(text)) && text != "|" && text != "‖" && Name(parent) == "mrow" && First(parent) != mo && !(Last(parent) == mo && IsFence(First(parent))) && Role(parent) != ":fenced" && !mo.ElementsAfterSelf().Any(e => Name(e) == "mo" && IsClosing(Text(e))); } + /// + /// Whether a slash has an operand before it that reading takes for its numerator: not when nothing is written + /// before it (an empty <mn/>) or an operator is (=∕=), where ∕ does not parse. + /// + private static bool HasOperandBefore(XElement mo) + { + var previous = Previous(mo); + return previous != null && (previous.HasElements || Text(previous).Length > 0) && + !(Name(previous) == "mo" && !IsCloseDelimiter(Text(previous)) && !IsClosing(Text(previous))); + } + private static bool IsNaryOperator(string op) => "∑∏∐∫∬∭∮∯∰∱∲∳⨌⨀⨁⨂⨃⨄⨅⨆⋀⋁⋂⋃".Contains(op); /// The elements whose intent or argument name reading the UnicodeMath does not give back, in a parallel walk. @@ -1121,7 +1135,7 @@ private string DumpCore(XElement value, bool noAddParens) break; } // an operator that reading would not take for one (ℏ, an n-ary operator without operand) is in a remark - if (rawOperators.Contains(value) || IsLoneStructure(value)) + if (rawOperators.Contains(value) || IsLoneStructure(value) || val == "/" && !HasOperandBefore(value)) { ret = "ⓡ(" + val + "&U+" + CodePointAt(val, 0).ToString("X4", System.Globalization.CultureInfo.InvariantCulture) + ")"; break; @@ -1132,6 +1146,12 @@ private string DumpCore(XElement value, bool noAddParens) ret = "∕"; break; } + // a bar that is the base of a script (|_ℤ) does not open or close: ├|_ℤ + if (val is "|" or "‖" && Name(parent) is "msub" or "msup" or "msubsup" && First(parent) == value) + { + ret = "├" + val; + break; + } if (val == "|") { ret = val; @@ -1425,9 +1445,11 @@ private string DumpCore(XElement value, bool noAddParens) CodePointAt(Text(First(value)), 0) < 0x2100 && Name(Previous(value)) == "mi") return " " + ret; } + // an opening bracket that nothing closes is closed by ┤, except before a fenced row at the end of the equation + // itself, which reading gives back as a row of the same elements ([𝑎|𝑏], not [𝑎|𝑏]┤) if (First(value) != null && Name(First(value)) == "mo" && IsOpenDelimiter(Text(First(value))) && Attr(First(value), "intent") != ":text") { - if (Name(Last(value)) != "mo" || Text(Last(value)).Length == 0) + if (Name(Last(value)) != "mo" && !(Name(value) == "math" && IsFenced(Last(value))) || Name(Last(value)) == "mo" && Text(Last(value)).Length == 0) ret += "┤"; } // an absolute value of bars (|𝑎|𝑏|𝑐|) or of parentheses keeps its content in parentheses, which only group diff --git a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs new file mode 100644 index 0000000..c7f1168 --- /dev/null +++ b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs @@ -0,0 +1,103 @@ +#nullable enable + +namespace SharpEquations.Tests.Regression +{ + using System.Threading.Tasks; + using System.Xml.Linq; + using SharpEquations; + + /// + /// Presentation MathML that the writers wrote as text they could not read back, or read back to an equation that + /// changed again on the next round trip (found with the Content MathML corpus, but not specific to it). + /// + public class WriterRoundTripTests + { + private static readonly EquationOptions Word = new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }; + + private static Equation MathML(string content) => + Equation.Parse("" + content + "", EquationFormat.MathML); + + private static Equation RoundTrip(Equation equation, EquationFormat format, EquationOptions? options = null) => + format == EquationFormat.Omml + ? Equation.Parse(equation.ToOmml(), EquationFormat.Omml) + : Equation.Parse(equation.ToString(format, options), format, options); + + [Test] + [Arguments("sin|ℤ", "sin├|_ℤ")] + [Arguments("f|ab", "𝑓├|_𝑎^𝑏")] + [Arguments("/", "ⓡ(/&U+002F)")] + [Arguments("=/=", "=ⓡ(/&U+002F)=")] + public async Task UnicodeMathWritesWhatItReads(string mathML, string unicodeMath) + { + var equation = MathML(mathML); + await Assert.That(equation.ToString(EquationFormat.UnicodeMath)).IsEqualTo(unicodeMath); + var back = RoundTrip(equation, EquationFormat.UnicodeMath); + await Assert.That(RoundTrip(back, EquationFormat.UnicodeMath)).IsEqualTo(back); + } + + [Test] + [Arguments("[a|b]")] + [Arguments("[a‖b]")] + [Arguments("(a|b)")] + [Arguments("x+[a|b]")] + public async Task UnicodeMathKeepsABarInsideBrackets(string mathML) + { + var equation = MathML(mathML); + await Assert.That(RoundTrip(equation, EquationFormat.UnicodeMath)).IsEqualTo(equation); + } + + [Test] + public async Task UnicodeMathIsStableOnABarFenceAfterAnOpenBracket() + { + // [𝑎|𝑏] read as UnicodeMath: [, 𝑎 and the fence |𝑏], which is written back as it was, without a ┤ + var equation = Equation.Parse("[𝑎|𝑏]", EquationFormat.UnicodeMath); + await Assert.That(equation.ToString(EquationFormat.UnicodeMath)).IsEqualTo("[𝑎|𝑏]"); + await Assert.That(RoundTrip(equation, EquationFormat.UnicodeMath)).IsEqualTo(equation); + } + + [Test] + public async Task UnicodeMathReadsANameMarkedAsAFunctionWithoutOperand() + { + await Assert.That(Equation.Parse("NaN⁡,", EquationFormat.UnicodeMath)) + .IsEqualTo(MathML("NaN⁡,")); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task LaTeXOperatorNameWithoutOperandIsStable(bool word) + { + var options = word ? Word : null; + var equation = Equation.Parse(@"\operatorname{NaN},\operatorname{true}", EquationFormat.LaTeX, options); + await Assert.That(equation).IsEqualTo(MathML("NaN⁡,true⁡")); + var written = MathML("NaN,true"); + await Assert.That(written.ToString(EquationFormat.LaTeX, options)).IsEqualTo(@"\operatorname{NaN},\operatorname{true}"); + var back = RoundTrip(written, EquationFormat.LaTeX, options); + await Assert.That(back).IsEqualTo(equation); + await Assert.That(RoundTrip(back, EquationFormat.LaTeX, options)).IsEqualTo(back); + } + + [Test] + [Arguments("□", @"\□", "□")] + [Arguments("□+x", @"\□+𝑥", "□+x")] + [Arguments("√x", @"\√𝑥", "√x")] + public async Task WordLaTeXEscapesAStructureOperatorAsAnIdentifier(string mathML, string latex, string read) + { + var equation = MathML(mathML); + await Assert.That(equation.ToString(EquationFormat.LaTeX, Word)).IsEqualTo(latex); + var back = RoundTrip(equation, EquationFormat.LaTeX, Word); + await Assert.That(back).IsEqualTo(MathML(read)); + await Assert.That(RoundTrip(back, EquationFormat.LaTeX, Word)).IsEqualTo(back); + } + + [Test] + [Arguments("sharet1", "sharet1")] + [Arguments("x1", "x1")] + public async Task OmmlKeepsAnUprightNameWithDigits(string mathML, string read) + { + var back = RoundTrip(MathML(mathML), EquationFormat.Omml); + await Assert.That(back).IsEqualTo(MathML(read)); + await Assert.That(RoundTrip(back, EquationFormat.Omml)).IsEqualTo(back); + } + } +} From 1621231cfa73e9e217a84b65bae8f5db62067ccc Mon Sep 17 00:00:00 2001 From: Simone <26844016+simonebortolin@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:08:15 +0100 Subject: [PATCH 16/45] Downgrade version prefix from 1.0.5 to 1.0.4 --- BuildVersion.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BuildVersion.props b/BuildVersion.props index 83c5ff3..c49d0f2 100644 --- a/BuildVersion.props +++ b/BuildVersion.props @@ -2,7 +2,7 @@ - 1.0.5 + 1.0.4 From 051b1ac6fcc0ba63f62fdaa166e44e4df71432ca Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:10:23 +0100 Subject: [PATCH 17/45] =?UTF-8?q?Structure=20operators=20alone=20(?= =?UTF-8?q?=E2=96=A1,=20=E2=88=9A)=20in=20standard=20LaTeX=20and=20Unicode?= =?UTF-8?q?Math?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standard LaTeX writer: □ as \square, √ as {\surd} (the symbol, not a root); the reader reads \square and \Box as □ - UnicodeMath writer: an identifier or an operator that builds a structure in UnicodeMath (□, √, ⒜...) is written as a remark, ⓡ(□&U+25A1), which reads back as that operator Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 2 +- .../Internal/LaTeX/LaTeXSerializer.cs | 25 ++++++++++--- .../UnicodeMath/UnicodeMathSerializer.cs | 9 ++++- .../Regression/WriterRoundTripTests.cs | 36 +++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 36aed34..5cefbf3 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -254,7 +254,7 @@ private static bool IsParenthesized(string arg) { ["dfrac"] = "\\frac", ["tfrac"] = "\\frac", ["cfrac"] = "\\frac", ["dbinom"] = "\\binom", ["tbinom"] = "\\binom", ["overrightarrow"] = "\\vec", ["Vert"] = "‖", ["lVert"] = "‖", ["rVert"] = "‖", ["bmod"] = "\\operatorname{mod}", - ["bm"] = "\\boldsymbol", + ["bm"] = "\\boldsymbol", ["square"] = "\\□", ["Box"] = "\\□", }; // the commands that put a script over a base, \overset{script}{base}, as the UnicodeMath operator (\underset is ⓤ) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index 6764a7a..3feeab4 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -538,7 +538,7 @@ private string TeX(XElement value, bool noAddParens = false) if (OperatorMap.TryGetValue(val, out var mapped)) return mapped; if (IsStructureOperator(val)) - return "\\" + val; + return EscapedStructureOperator(val); if (val == "$") { ret = "\\$"; @@ -573,10 +573,10 @@ private string TeX(XElement value, bool noAddParens = false) break; case "mi": - // in Word's LaTeX, an operator of UnicodeMath as an identifier (□, √) is escaped, as it is as an operator: - // alone, it would be read as its structure (√𝑥, a square root) or not at all (□) - if (!standard && IsStructureOperator(Text(value)) && !value.HasElements) - return "\\" + Text(value); + // an operator of UnicodeMath as an identifier (□, √) is escaped, as it is as an operator: alone, it would + // be read as its structure (√𝑥, a square root) or not at all (□) + if (IsStructureOperator(Text(value)) && !value.HasElements) + return EscapedStructureOperator(Text(value)); if (standard) { ret = StandardIdentifier(value); @@ -791,6 +791,21 @@ private static bool IsUnpairedFence(XElement value, string intent) return (open.Length > 0 || close.Length > 0) && !(IsOneOf(open, OpeningFences) && IsOneOf(close, ClosingFences)); } + /// + /// An operator that builds a structure in UnicodeMath, alone: escaped in Word's LaTeX (\□, \√); in standard + /// LaTeX the symbol's command where it has one (\square, and {\surd}, which a braced group keeps from taking + /// what follows as its radicand), escaped otherwise. + /// + private string EscapedStructureOperator(string val) + { + if (!standard) + return "\\" + val; + if (val == "√") + return "{\\surd}"; + var command = LaTeXSymbols.Math(val); + return command.Length > 1 && command[0] == '\\' && command != val ? command : "\\" + val; + } + /// The operators that build a structure in UnicodeMath (▭, √, ¦…), escaped when they are alone: \▭. internal static bool IsStructureOperator(string text) => text != null && Len(text) == 1 && "▭▁▢◯⟌⬭╱╲╳√∛∜⒭¦⒞⒜ⓒ□⟡⬄⇳⬍⬆⬇⬌■⒨⒩ⓢⓈ⒱█ⓘⓐⓡⓜⓓⓑ▒".Contains(text); diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index 4008b6a..e018d75 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -177,7 +177,7 @@ private static bool IsLoneStructure(XElement mo) var text = Text(mo); if (Len(text) != 1) return false; - if ("▭√∛∜⟡▢◯⬭".Contains(text)) + if ("▭√∛∜⟡▢◯⬭".Contains(text) || LaTeX.LaTeXSerializer.IsStructureOperator(text)) return true; var parent = mo.Parent; if (text is "|" or "‖" && Role(parent) == ":fenced" && First(parent) != mo && Last(parent) != mo && Text(First(parent)) is not ("|" or "‖" or "⟨")) @@ -1191,6 +1191,13 @@ private string DumpCore(XElement value, bool noAddParens) ret = intent; break; } + // an operator that builds a structure (□, √) as an identifier would be read as that structure, or not + // at all: it is in a remark, read back as the operator + if (!value.HasElements && LaTeX.LaTeXSerializer.IsStructureOperator(Text(value))) + { + ret = "ⓡ(" + Text(value) + "&U+" + CodePointAt(Text(value), 0).ToString("X4", System.Globalization.CultureInfo.InvariantCulture) + ")"; + break; + } if (Len(Text(value)) == 1 && !value.HasElements) { var c = Text(value); diff --git a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs index c7f1168..458b869 100644 --- a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs +++ b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs @@ -90,6 +90,42 @@ public async Task WordLaTeXEscapesAStructureOperatorAsAnIdentifier(string mathML await Assert.That(RoundTrip(back, EquationFormat.LaTeX, Word)).IsEqualTo(back); } + [Test] + [Arguments("□", @"\square", "□")] + [Arguments("□x", @"\square x", "□x")] + [Arguments("√x", @"{\surd}x", "√x")] + [Arguments("√x", @"{\surd}x", "√x")] + public async Task StandardLaTeXWritesAStructureOperatorAsItsSymbol(string mathML, string latex, string read) + { + var equation = MathML(mathML); + await Assert.That(equation.ToString(EquationFormat.LaTeX)).IsEqualTo(latex); + var back = RoundTrip(equation, EquationFormat.LaTeX); + await Assert.That(back).IsEqualTo(MathML(read)); + await Assert.That(RoundTrip(back, EquationFormat.LaTeX)).IsEqualTo(back); + } + + [Test] + [Arguments(@"\square")] + [Arguments(@"\Box")] + public async Task LaTeXReadsTheSquareSymbol(string latex) + { + await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX)).IsEqualTo(MathML("□")); + } + + [Test] + [Arguments("□", "ⓡ(□&U+25A1)", "□")] + [Arguments("□x", "ⓡ(□&U+25A1)𝑥", "□x")] + [Arguments("□x", "ⓡ(□&U+25A1)𝑥", "□x")] + [Arguments("√x", "ⓡ(√&U+221A)𝑥", "√x")] + public async Task UnicodeMathWritesAStructureOperatorAsARemark(string mathML, string unicodeMath, string read) + { + var equation = MathML(mathML); + await Assert.That(equation.ToString(EquationFormat.UnicodeMath)).IsEqualTo(unicodeMath); + var back = RoundTrip(equation, EquationFormat.UnicodeMath); + await Assert.That(back).IsEqualTo(MathML(read)); + await Assert.That(RoundTrip(back, EquationFormat.UnicodeMath)).IsEqualTo(back); + } + [Test] [Arguments("sharet1", "sharet1")] [Arguments("x1", "x1")] From 9299685eb41f2e25aa57f8a023ebadfd0d82a3de Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:18:19 +0100 Subject: [PATCH 18/45] LaTeX reader: limits of \mathop{x} and of both scripts of \overbrace, \underbrace \mathop of one symbol is a group, to which \limits attaches its scripts (munderover(x, 0, 1)); \overbrace and \underbrace with both scripts are, as for TeX, operators with limits: \underset{x}{\overset{a}{\overbrace{3}}}. The latex2mathml cases issue-125-4-limits, overbrace-c and underbrace-c pass. [no-new-version] Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 16 +++++++++++++++- .../LaTeX/LaTeXReaderTests.cs | 6 ++++++ .../LaTeX/Latex2MathMLCorpusTests.cs | 4 ++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 50174f4..741cf89 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -648,6 +648,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) @@ -718,8 +726,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)); diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index cd4614c..56c34b7 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -137,6 +137,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", @"𝑥")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 98efa05..ca9d9ab 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -198,13 +198,13 @@ 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 + // words (lim inf); \middle between empty // delimiters and bars with nothing between them (|\,|); a font over \text and escaped braces (\mathrm{\{b\}}, // \mathsf{\text{}}, \boldsymbol{1}); \newenvironment and ## in a macro 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", "mathrm", "mathsf", "boldsymbol", + "middle", "spaces", "mathrm", "mathsf", "boldsymbol", "newenvironment-multi-node", "newcommand-escaped-hash", }; From 848163b80dfec8a364b23cb3384be40120851402 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:19:32 +0100 Subject: [PATCH 19/45] LaTeX reader: \newenvironment and \renewenvironment, expanded as TeX does \begin{name}{arguments}...\end{name} becomes the begin code with its #1... (the first argument optional with a default), the body and the end code, before the other passes; the definitions go to the macros, where later conversions find them. ## in a macro body is # in math mode, which TeX rejects: newcommand-escaped-hash is no LaTeX. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 140 +++++++++++++++++- .../LaTeX/LaTeXReaderTests.cs | 21 +++ .../LaTeX/Latex2MathMLCorpusTests.cs | 13 +- 3 files changed, 165 insertions(+), 9 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 50174f4..80966fe 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -37,7 +37,7 @@ private LaTeXReader(IDictionary macros, bool display) /// /// TeX2UnicodeMath: the UnicodeMath of LaTeX math (without its $ or \[ delimiters). Macros defined with \def - /// or \newcommand go to , where later conversions find them. In a display equation the + /// or \newcommand, and environments defined with \newenvironment, go to , where later conversions find them. In a display equation the /// scripts of a large operator other than an integral (\sum, \prod, \bigcup...) go under and over it, as TeX /// puts them. /// @@ -1060,9 +1060,145 @@ private static (string Text, int Next) BracedArgument(Text tex, int i) return (null, -1); } + /// + /// The environments of \newenvironment{name}[n][default]{begin}{end} (and \renewenvironment) applied: the + /// definitions removed and kept in the macros under {name}, where later conversions find them, and each + /// \begin{name}{arguments}...\end{name} replaced by the begin code with its arguments (#1...), the body and the + /// end code, as TeX expands them. + /// + private string NewEnvironments(string latex) + { + var tex = new Text(latex); + var result = new StringBuilder(); + int expansions = 0; + for (int i = 0; i < tex.Length;) + { + var cw = tex[i] == "\\" ? ControlWord(tex, i + 1) : ""; + int j = i + 1 + cw.Length; + switch (cw) + { + case "newenvironment" or "renewenvironment" when BracedArgument(tex, j) is var (name, afterName) && afterName >= 0 && + EnvironmentDefinition(tex, afterName) is var (_, _, _, _, afterDefinition) && afterDefinition >= 0: + macros["{" + name.Trim() + "}"] = tex.Sub(afterName, afterDefinition); + i = afterDefinition; + continue; + case "begin" when BracedArgument(tex, j) is var (name, afterName) && afterName >= 0 && + macros.TryGetValue("{" + name.Trim() + "}", out var definition) && + EnvironmentDefinition(new Text(definition), 0) is var (count, optional, begin, end, _) && + EnvironmentArguments(tex, afterName, count, optional) is var (arguments, afterArguments) && afterArguments >= 0 && + EnvironmentEnd(tex, afterArguments, name.Trim()) is var (bodyEnd, afterEnd) && bodyEnd >= 0: + if (++expansions > 1000) + throw new FormatException("The LaTeX environment " + name.Trim() + " does not stop expanding."); + // the expansion read again, for the environments it uses + var expansion = new StringBuilder(); + foreach (var part in new[] { Substitute(begin, arguments), tex.Sub(afterArguments, bodyEnd), end, tex.Sub(afterEnd) }) + Join(expansion, part); + tex = new Text(expansion.ToString()); + i = 0; + if (ContinuesControlWord(result, expansion.ToString())) + result.Append(' '); + continue; + default: + var next = cw.Length > 0 ? j : i + (tex[i] == "\\" ? 2 : 1); + result.Append(tex.Sub(i, next)); + i = next; + continue; + } + } + return result.ToString(); + } + + /// The [n][default]{begin}{end} of an environment definition at the index; Next -1 if there is none. + private static (int Count, string Optional, string Begin, string End, int Next) EnvironmentDefinition(Text tex, int i) + { + var (countText, afterCount) = OptionalArgument(tex, i); + int count = 0; + if (countText != null && (!int.TryParse(countText.Trim(), out count) || count < 0 || count > 9)) + return (0, null, null, null, -1); + var (optional, afterOptional) = countText == null ? (null, afterCount) : OptionalArgument(tex, afterCount); + var (begin, afterBegin) = BracedArgument(tex, afterOptional); + var (end, afterEnd) = afterBegin < 0 ? (null, -1) : BracedArgument(tex, afterBegin); + return (count, optional, begin, end, afterEnd); + } + + /// The arguments of an environment at the index (the first one optional when it has a default); Next -1 if missing. + private static (List Arguments, int Next) EnvironmentArguments(Text tex, int i, int count, string optional) + { + var arguments = new List(); + if (optional != null && count > 0) + { + var (given, afterGiven) = OptionalArgument(tex, i); + arguments.Add(given ?? optional); + i = afterGiven; + } + return Arguments(tex, i, count - arguments.Count) is var (rest, next) ? (arguments.Concat(rest).ToList(), next) : (null, -1); + } + + /// The index of the \end{name} that closes an environment whose body starts at the index, and the index after it; -1 if none. + private static (int End, int Next) EnvironmentEnd(Text tex, int i, string name) + { + int depth = 1; + for (; i < tex.Length; i++) + { + if (tex[i] != "\\") + continue; + var cw = ControlWord(tex, i + 1); + if (cw is "begin" or "end" && BracedArgument(tex, i + 1 + cw.Length) is var (environment, after) && after >= 0 && environment.Trim() == name) + { + depth += cw == "begin" ? 1 : -1; + if (depth == 0) + return (i, after); + } + i += Math.Max(1, cw.Length); + } + return (-1, -1); + } + + /// The begin code of an environment with its #1... replaced by the arguments, and ## by #. + private static string Substitute(string code, List arguments) + { + var tex = new Text(code); + var result = new StringBuilder(); + for (int i = 0; i < tex.Length; i++) + { + switch (tex[i]) + { + case "#" when tex[i + 1] == "#": + result.Append('#'); + i++; + break; + case "#" when tex[i + 1] is { Length: 1 } digit && digit[0] is >= '1' and <= '9' && digit[0] - '1' < arguments.Count: + result.Append(arguments[digit[0] - '1']); + i++; + break; + default: + result.Append(tex[i]); + break; + } + } + return result.ToString(); + } + + /// A part of an expansion appended, after a space if it would continue the control word before it (\alpha x). + private static void Join(StringBuilder expansion, string part) + { + if (ContinuesControlWord(expansion, part)) + expansion.Append(' '); + expansion.Append(part); + } + + private static bool ContinuesControlWord(StringBuilder before, string after) + { + var text = new Text(before.ToString()); + int i = text.Length; + while (i > 0 && IsAsciiAlphabetic(text[i - 1])) + i--; + return i < text.Length && text[i - 1] == "\\" && IsAsciiAlphabetic(new Text(after)[0]); + } + private string Convert(string latex) { - var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(latex)))); + var tex = new Text(EscapeOddBars(RewriteTeXOnly(Limits(NewEnvironments(latex))))); var cwPrev = ""; bool needMacroPass = false; var uniTeX = new StringBuilder(); diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index cd4614c..010ad8d 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -293,6 +293,27 @@ public async Task ExpandsMacrosOfTheOptions() await Assert.That(Equation.Parse(@"\half x\in\R", EquationFormat.LaTeX, options).ToString(EquationFormat.LaTeX)).IsEqualTo(@"\frac{1}{2}x\in\mathbb{R}"); } + [Test] + [Arguments(@"\newenvironment{wrapper}{a +}{+ c} \begin{wrapper} b \end{wrapper}", @"a+b+c")] + [Arguments(@"\newenvironment{paren}[1]{\left( #1 +}{\right)} \begin{paren}{x} y \end{paren}", @"\left(x+y\right)")] + [Arguments(@"\newenvironment{e}[2][1]{#1+#2}{} \begin{e}{a} b\end{e}", @"1+ab")] + [Arguments(@"\newenvironment{e}[2][1]{#1+#2}{} \begin{e}[2]{a}\end{e}", @"2+a")] + [Arguments(@"\newenvironment{g}{(}{)} \begin{g} a \begin{g} b \end{g} \end{g}", @"(a(b))")] + [Arguments(@"\newenvironment{al}{\alpha}{\beta}\begin{al}x\end{al}y", @"\alpha x\beta y")] + [Arguments(@"\newenvironment{e}{a}{} \renewenvironment{e}{b}{} \begin{e}\end{e}", @"b")] + [Arguments(@"\newenvironment{m}{\begin{pmatrix}}{\end{pmatrix}} \begin{m} 1 & 2 \end{m}", @"\begin{pmatrix}1&2\end{pmatrix}")] + [Arguments(@"\newenvironment{h}{\newcommand{\f}[1]{##1^2}}{} \begin{h}\f{x}\end{h}", @"x^2")] + public async Task ExpandsEnvironments(string latex, string equivalent) + { + await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX)).IsEqualTo(Equation.Parse(equivalent, EquationFormat.LaTeX)); + } + + [Test] + public async Task RejectsEnvironmentsThatExpandForever() + { + await Assert.That(Equation.TryParse(@"\newenvironment{r}{\begin{r}}{\end{r}} \begin{r} x \end{r}", EquationFormat.LaTeX, out _)).IsFalse(); + } + [Test] [Arguments(@"\foo x", @"Unknown control word \foox in the LaTeX.")] [Arguments(@"a+\undefinedcommand", @"Unknown control word \undefinedcommand in the LaTeX.")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 98efa05..8ca90d3 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -200,12 +200,11 @@ private static string Canonical(Equation equation) // \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 (|\,|); a font over \text and escaped braces (\mathrm{\{b\}}, - // \mathsf{\text{}}, \boldsymbol{1}); \newenvironment and ## in a macro + // \mathsf{\text{}}, \boldsymbol{1}) 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", "mathrm", "mathsf", "boldsymbol", - "newenvironment-multi-node", "newcommand-escaped-hash", }; // the same equation, which SharpEquations holds otherwise: an equation number as the label of its row (\tag), @@ -221,17 +220,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) From 870048cbe2d6181c40d000c6b8ee1f4804b79286 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:26:59 +0100 Subject: [PATCH 20/45] [no-new-version] Run CI, CodeQL and pre-release cleanup on pull requests to any branch Stacked pull requests (based on another PR's branch rather than main) ran no workflow at all, since every pull_request trigger was filtered to main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 -- .github/workflows/codeql.yml | 2 -- .github/workflows/prerelease-cleanup.yml | 2 -- 3 files changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce6d012..5a14b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main workflow_dispatch: permissions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e672b65..1016e0d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,8 +8,6 @@ on: branches: - main pull_request: - branches: - - main schedule: - cron: '27 4 * * 3' workflow_dispatch: diff --git a/.github/workflows/prerelease-cleanup.yml b/.github/workflows/prerelease-cleanup.yml index c020278..fbd42bf 100644 --- a/.github/workflows/prerelease-cleanup.yml +++ b/.github/workflows/prerelease-cleanup.yml @@ -5,8 +5,6 @@ name: PR Pre-release Cleanup on: pull_request: types: [closed] - branches: - - main permissions: contents: read From 463547492eaf32ec8f2ce8d617622e19d4dda93a Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:28:55 +0100 Subject: [PATCH 21/45] Merge fixes: one \Box in Renamed, the latex2mathml cases b6eeafc reads out of NotInWordLaTeX [no-new-version] Co-Authored-By: Claude Opus 5.5 --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 2 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 18065c0..3b22f55 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -378,7 +378,7 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i) ["smallfrown"] = "⌢", ["smallsmile"] = "⌣", ["thicksim"] = "∼", ["thickapprox"] = "≈", ["varsubsetneqq"] = "⫋", ["varsupsetneq"] = "⊋", ["varsupsetneqq"] = "⫌", ["bigcirc"] = "\\◯", ["centerdot"] = "⬝", ["degree"] = "°", ["intop"] = "\\int", ["smallint"] = "\\int", ["diagup"] = "\\╱", ["diagdown"] = "\\╲", ["thickspace"] = "\\;", - ["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["Box"] = "\\□", ["idotsint"] = "\\∫⋯\\∫", + ["And"] = "\\&", ["lvert"] = "|", ["rvert"] = "|", ["idotsint"] = "\\∫⋯\\∫", ["mathscr"] = "\\mathcal", ["operatornamewithlimits"] = "\\operatorname", ["mathstrut"] = "\\vphantom{\\(}", }; diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index aa55505..51c697e 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -180,9 +180,8 @@ private static string Canonical(Equation equation) // commands of MathJax, KaTeX, plain TeX or packages that Word's LaTeX, which the reader follows, does not have: // links, classes, colors, boxes and moves, sizes, \hbox and \verb, \LaTeX, \skew, the primitives of plain TeX - // (\matrix{}, \cr, \eqalign, \displaylines, \above, \atop, \brace) and its font switches (\Bbb, \frak, \scr, and \rm or \tt over text), - // the classes of \mathord and the like, the bra-kets of the physics package, \mathsfit, \argmax, \widecheck, - // the arrows of mathtools + // (\matrix{}, \cr, \eqalign, \displaylines, \above, \atop, \brace) and its font switches (\frak, \scr, \rm over text), + // the classes of \mathord, \mathbin and \mathrel, the bra-kets of the physics package, \mathsfit, \argmax, \widecheck private static readonly HashSet NotInWordLaTeX = new HashSet { "href", "class", "style", "textcolor", "colorbox", "fcolorbox", "raise", "lower", "moveleft", "moveright", @@ -190,10 +189,10 @@ private static string Canonical(Equation equation) "mathchoice-inline", "unicode", "verb", "huge", "large", "normalsize-scriptsize", "small-tiny", "footnotesize", "LaTeX", "LaTeX-with-style", "TeX", "TeX-with-style", "hbox", "hbox-with-math-mode", "hbox-with-backslash-in-text", "fbox", "leftroot-uproot", "skew", "skew-with-braces", "displaylines", "eqalign", "matrix-without-begin-and-end", - "hfil", "above", "abovewithdelims", "atop-and-atopwithdelims", "brace", "brace-1", "oldstyle", "scr", "tt", - "fraktur", "blackboard-bold", "global-fonts", "greatest-common-divisor", - "math-commands-that-currently-does-nothing", "mathbin", "mathord", "mathrel", "mathop", "issue-125-4-mathop", - "bra", "ket", "braket", "mathsfit", "argmax", "widecheck", "xLeftarrow-double", "xleftrightarrow", "xhookleftarrow", + "hfil", "above", "abovewithdelims", "atop-and-atopwithdelims", "brace", "brace-1", "oldstyle", "scr", + "fraktur", "global-fonts", "greatest-common-divisor", + "math-commands-that-currently-does-nothing", "mathbin", "mathord", "mathrel", "bra", "ket", "braket", "mathsfit", + "argmax", "widecheck", }; // LaTeX and amsmath that the reader does not read yet: an operator name with nothing after it (\lg, \liminf, From e2aa20f6651d918d15f51ad46cc3b0b9c573df09 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:30:47 +0100 Subject: [PATCH 22/45] [no-new-version] CI on every pull request, not only those against main The tests, CodeQL and the cleanup of the pre-release run for a pull request whatever its base, so that a PR stacked on another one is tested too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 -- .github/workflows/codeql.yml | 2 -- .github/workflows/prerelease-cleanup.yml | 2 -- 3 files changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce6d012..5a14b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main workflow_dispatch: permissions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e672b65..1016e0d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,8 +8,6 @@ on: branches: - main pull_request: - branches: - - main schedule: - cron: '27 4 * * 3' workflow_dispatch: diff --git a/.github/workflows/prerelease-cleanup.yml b/.github/workflows/prerelease-cleanup.yml index c020278..fbd42bf 100644 --- a/.github/workflows/prerelease-cleanup.yml +++ b/.github/workflows/prerelease-cleanup.yml @@ -5,8 +5,6 @@ name: PR Pre-release Cleanup on: pull_request: types: [closed] - branches: - - main permissions: contents: read From c8afb23b7fae64ca7b4b9f426063034eecf8cd80 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:31:03 +0100 Subject: [PATCH 23/45] LaTeX reader: math fonts as TeX sets them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A font command (\mathrm, \mathbf, \mathsf, \boldsymbol...) styles the letters and digits of its argument also between escaped braces (\mathrm{A \{b\}} is \mathrm{A}\{\mathrm{b}\}), but not the text of \text, which has the text font, nor the argument of an inner font command or a function name (\sin). \mathrm makes letters upright (\mathrm{x_1} is \mathrm{x}_1), and a digit of a bold italic style, which has no such form, is bold upright (\boldsymbol{1} is 𝟏), as in TeX. The latex2mathml snapshots that put the font on the text (mathbf, mathit, mathcal, mathfrak, mathtt, mit, blackboard-bold) and the 1 of \mathsf{1} written as a plain number (mathsf) are corrected; mathrm, mathsf and boldsymbol leave NotReadYet. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 76 +++++++++++++++++-- .../LaTeX/LaTeXReaderTests.cs | 8 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 5 +- tests/data/README.md | 5 +- tests/data/latex/latex2mathml-converter.json | 16 ++-- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 50174f4..cd4df2e 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -1041,6 +1041,73 @@ private static (string Text, int Next) OptionalArgument(Text tex, int i) /// The text of the {...} argument at the index (after blanks) and the index after it; -1 if there is none. private static bool IsAsciiLetterOrDigit(char c) => c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9'; + /// + /// The argument of a font command in the font, as TeX sets it: its ASCII letters and digits, between escaped braces + /// too (\mathrm{\{b\}}), but not the text (\text{var}), which has the text font, nor the argument of an inner font + /// command, which has its own. Upright letters are quoted, as UnicodeMath writes them; a digit, which has no bold + /// italic form, is bold upright in a bold italic style (\boldsymbol{1}, 𝟏). + /// + private static string InFont(string argument, int style) + { + var tex = new Text(argument); + var mathStyle = MathStyles[style]; + var digitStyle = mathStyle switch + { + "mbfit" or "mbfscr" or "mbffrak" => "mbf", + "mbfitsans" => "mbfsans", + _ => mathStyle, + }; + var result = new StringBuilder(); + for (int i = 0; i < tex.Length;) + { + var ch = tex[i]; + int end; + switch (ch) + { + case "ⓣ" when tex[i + 1] == "{" && FindClosingBrace(tex, i + 2) is var close && close != -1: + end = close + 1; + break; + case "Ⅎ" when IsAsciiAlphabetic(tex[i + 1]) && tex[i + 2] == "{" && FindClosingBrace(tex, i + 3) is var close && close != -1: + end = close + 1; + break; + case "\\" when IsAsciiAlphabetic(tex[i + 1]): + end = i + 1 + ControlWord(tex, i + 1).Length; + break; + case "\\": + end = System.Math.Min(i + 2, tex.Length); + break; + case var _ when IsAsciiAlphabetic(ch): + end = i; + while (IsAsciiAlphabetic(tex[end])) + end++; + var letters = tex.Sub(i, end); + // a function name (\sin, which the first pass leaves as its letters) is in its own font + if (IsFunctionName(letters)) + result.Append(letters); + else if (mathStyle == "mup") + result.Append(Quoted(letters)); + else + result.Append(string.Concat(Characters(letters).Select(c => GetMathAlphanumeric(c, mathStyle)))); + i = end; + continue; + case var _ when ch.Length == 1 && char.IsLetter(ch[0]) && mathStyle != "mup": + result.Append(GetMathAlphanumeric(ch, mathStyle)); + i++; + continue; + case var _ when ch.Length == 1 && ch[0] >= '0' && ch[0] <= '9': + result.Append(GetMathAlphanumeric(ch, digitStyle)); + i++; + continue; + default: + end = i + 1; + break; + } + result.Append(tex.Sub(i, end)); + i = end; + } + return result.ToString(); + } + private static (string Text, int Next) BracedArgument(Text tex, int i) { while (i < tex.Length && tex[i] == " ") @@ -1500,10 +1567,10 @@ private string Pass2(string latex) RemoveLast(uniTeX); uniTeX.Append(Quoted(argument)); } - else if (style > 0 && style < MathStyles.Length && argument.Length > 0 && argument.All(IsAsciiLetterOrDigit)) + else if (style > 0 && style < MathStyles.Length && argument.Length > 0 && argument.All(char.IsLetterOrDigit)) { RemoveLast(uniTeX); - uniTeX.Append(string.Concat(argument.Select(c => GetMathAlphanumeric(c.ToString(), MathStyles[style])))); + uniTeX.Append(InFont(argument, style)); } else if (argument.All(char.IsLetterOrDigit)) { @@ -1514,10 +1581,7 @@ private string Pass2(string latex) // other mathematics in a font (\mathbf{b\times c}, \mathrm{x_1}): its ASCII letters and digits in // the font, the rest as it is RemoveLast(uniTeX); - var styled = style > 0 && style < MathStyles.Length - ? string.Concat(Characters(argument).Select(c => c.Length == 1 && IsAsciiLetterOrDigit(c[0]) ? GetMathAlphanumeric(c, MathStyles[style]) : c)) - : argument; - uniTeX.Append(Pass2(styled)); + uniTeX.Append(Pass2(style >= 0 && style < MathStyles.Length ? InFont(argument, style) : argument)); } i = j + 1; } diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index cd4614c..4eb32d1 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -142,7 +142,13 @@ public class LaTeXReaderTests [Arguments(@"\hspace*{2cm}x", @"x", @"𝑥")] [Arguments(@"a\;b\:c\,d\!e\ f", @"a\;b\:c\,de~f", @"𝑎 𝑏 𝑐 𝑑𝑒 𝑓")] [Arguments(@"\mathrm{d}\mathbf{d}\mathit{d}\mathsf{d}\mathtt{d}\mathcal{D}\mathfrak{d}\mathbb{D}", @"\mathrm{d}\mathbf{d}d\mathsf{d}\mathtt{d}\mathcal{D}\mathfrak{d}\mathbb{D}", @"""d""𝐝𝑑𝖽𝚍𝒟𝔡𝔻")] - [Arguments(@"\mathrm{x_1}", @"x_1", @"𝑥₁")] + [Arguments(@"\mathrm{x_1}", @"\mathrm{x}_1", @"""x""₁")] + [Arguments(@"\mathrm{A \{b\}}", @"\mathrm{A}\{\mathrm{b}\}", @"""A""{""b""}")] + [Arguments(@"\mathsf{\text{var} = 1+\{b\}}", @"\text{var}=\mathsf{1}+\{\mathsf{b}\}", @"""var""=𝟣+{𝖻}")] + [Arguments(@"\mathbf{\text{var}\,x}", @"\text{var}\,\mathbf{x}", @"""var"" 𝐱")] + [Arguments(@"\boldsymbol{1}\boldsymbol{x1\alpha}", @"\mathbf{1}\boldsymbol{x}\mathbf{1}\boldsymbol{\alpha}", @"𝟏𝒙𝟏𝜶")] + [Arguments(@"\mathbf{\mathrm{x}y}", @"\mathrm{x}\mathbf{y}", @"""x""𝐲")] + [Arguments(@"\mathbf{\Gamma+\sin x}", @"\mathbf{\Gamma}+\sin\mathbf{x}", @"𝚪+sin⁡𝐱")] [Arguments(@"\mathbf{\alpha}", @"\mathbf{\alpha}", @"𝛂")] [Arguments(@"\tag*{A}", @"\tag{A}", @"#A")] [Arguments(@"{\color{blue} a \{ b} + c", @"a\{b+c", @"𝑎\{𝑏+𝑐")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 98efa05..42eda47 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -199,12 +199,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 (|\,|); a font over \text and escaped braces (\mathrm{\{b\}}, - // \mathsf{\text{}}, \boldsymbol{1}); \newenvironment and ## in a macro + // delimiters and bars with nothing between them (|\,|); \newenvironment and ## in a macro 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", "mathrm", "mathsf", "boldsymbol", + "issue-125-4-limits", "overbrace-c", "underbrace-c", "middle", "spaces", "newenvironment-multi-node", "newcommand-escaped-hash", }; diff --git a/tests/data/README.md b/tests/data/README.md index 525b927..cb9de1c 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -69,7 +69,10 @@ corrected where latex2mathml writes MathML that is not what the LaTeX means: the `\textbf`, `\textit`, `\textrm`, `\textsf`, `\texttt` is a no-break space, not a tilde (`tilde-and-space-in-text`, `textbf`...); the superscript of `( - 25 ) ^ { 2 }` is that of the parenthesized group, not of `)` (`issue-42`); `\omicron` is a letter, not an operator (`omicron`); the limit of `\lim` goes under it in a display equation -(`limit-at-plus-infinity`). +(`limit-at-plus-infinity`). Eight more were corrected for the +fonts of mathematics, which do not reach the text of `\text` (in the text font): `var` is plain text in `mathbf`, +`mathit`, `mathcal`, `mathfrak`, `mathtt`, `mit` and `blackboard-bold`, and the `1` of `\mathsf{1}` is sans-serif +(`mathsf`). `mathml/mml2tex.json`: the scenarios of `xspec/*.xspec` of [transpect/mml2tex](https://github.com/transpect/mml2tex) (commit 9458a7a2f5125609c92eb178fcd0f8b8419e2c40), MathML with the LaTeX mml2tex writes for it, and the MathML of diff --git a/tests/data/latex/latex2mathml-converter.json b/tests/data/latex/latex2mathml-converter.json index 9da7560..8aef64c 100644 --- a/tests/data/latex/latex2mathml-converter.json +++ b/tests/data/latex/latex2mathml-converter.json @@ -639,7 +639,7 @@ "id": "blackboard-bold", "latex": "\\Bbb {\\text{var} = 1+\\{b\\}}", "display": true, - "mathml": "var=1+{b}" + "mathml": "var=1+{b}" }, { "id": "Bbb-group", @@ -987,25 +987,25 @@ "id": "mathbf", "latex": "\\mathbf {\\text{var} = 1+\\{b\\}}", "display": true, - "mathml": "var=1+{b}" + "mathml": "var=1+{b}" }, { "id": "mathcal", "latex": "{\\mathcal {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "mathfrak", "latex": "{\\mathfrak {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "mathit", "latex": "{\\mathit {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "mathrm", @@ -1023,13 +1023,13 @@ "id": "mathsf", "latex": "{\\mathsf {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "mathtt", "latex": "{\\mathtt {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "math-commands-that-currently-does-nothing", @@ -1383,7 +1383,7 @@ "id": "mit", "latex": "{\\mit {\\text{var} = 1+\\{b\\}}} + B", "display": true, - "mathml": "var=1+{b}+B" + "mathml": "var=1+{b}+B" }, { "id": "oldstyle", From 65229f05abf4f9e36902d1a95ae0bed055931e70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:00:34 +0000 Subject: [PATCH 24/45] [no-new-version] Design of an equation editor for Avalonia Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- docs/EDITOR.md | 225 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/EDITOR.md diff --git a/docs/EDITOR.md b/docs/EDITOR.md new file mode 100644 index 0000000..442bd9e --- /dev/null +++ b/docs/EDITOR.md @@ -0,0 +1,225 @@ +# Equation editor for Avalonia (design) + +A design for `MathEditBox`, a real-time equation editor for Avalonia in the manner of the `RichEditBox` of WinUI in +`RichEditMathMode.MathOnly`: the user types UnicodeMath or LaTeX, the equation builds up into its two-dimensional form +as it is typed, and the content is read and written as MathML (`GetMath`/`SetMath` in WinUI). SharpEquations is the +core under it: it reads what is typed, gives the MathML, and writes the linear form back when a part of the equation +is built down. This document is a proposal: nothing of it exists yet. + +## What the user sees + +The behaviour is the one of the math zones of Word and of `RichEditBox` in math mode. + +- **Professional** (built up): the equation is shown in two dimensions. Typing goes into the *linear run* at the caret, + a piece of plain text; when a character that ends an argument is typed (space, an operator, `)`, `]`, Enter, Tab) the + run is *built up*: `a/b␣` becomes a fraction, `x^2␣` a superscript, `\alpha␣` becomes `α`, `\sqrt(x)` a radical. + Text that does not parse yet (`a/`) stays linear, drawn as typed, until it does. +- **Linear**: the whole equation (or the selection) is shown as one line of UnicodeMath or LaTeX and edited as text. + The switch (Ctrl+Shift+=, as Word has it, or a property) builds down to linear and up to professional. +- **Input format**: UnicodeMath or LaTeX (`InputFormat`); LaTeX is read as Word reads it, which is what SharpEquations + already does (`LaTeXReader`), and written back, when built down, in the LaTeX of Word (`LaTeXFlavor.Word`). +- **Navigation** in two dimensions: the arrows move through the slots of the structures (numerator → denominator, + base → script, the cells of a matrix), Home/End to the ends of the current row, Backspace into a structure *selects* + it first and deletes it the second time (as Word), Ctrl+Z/Ctrl+Y undo and redo. +- **Clipboard**: copy puts MathML (`application/mathml+xml`), UnicodeMath or LaTeX (as plain text, in the input format) + and OMML on the clipboard; paste reads any of them through `Equation.Parse`. +- **Autocomplete**: after `\` a list of control words (`\alpha`, `\int`, `\matrix`...) with their symbols. + +## Layers + +```mermaid +flowchart TB + subgraph Core["SharpEquations (exists, unchanged: .NET base library only)"] + P["Equation.Parse(UnicodeMath | LaTeX)"] + W["Equation.ToString(UnicodeMath | LaTeX)"] + X["Equation.ToMathML()"] + end + subgraph Editing["SharpEquations.Editing (new, netstandard2.0 + net10.0, no UI)"] + D["MathDocument: tree of nodes + linear runs"] + C["Caret, selection, navigation"] + B["BuildUp / BuildDown"] + U["Undo stack, commands"] + end + subgraph Layout["SharpEquations.Rendering (new, no UI)"] + M["OpenType MATH table reader"] + L["MathML Core layout → boxes"] + H["Hit testing, caret geometry"] + end + subgraph Avalonia["SharpEquations.Avalonia (new, depends on Avalonia)"] + E["MathEditBox (editor control)"] + V["MathView (read-only control)"] + F["Font adapter, drawing, IME, clipboard"] + end + B --> P + B --> W + D --> X + E --> D + E --> L + V --> L + L --> M + F --> L +``` + +The core stays as it is: the rules of `AGENTS.md` (no dependencies but the base library, no change of the public API +without a reason that is the owner's) hold for it, and the editor is built on its public API. The two middle layers +have no UI dependency, so that they are tested with TUnit like the core and could serve another toolkit (WPF, MAUI, +Uno) later; only the last depends on Avalonia. + +| Project | Target | Depends on | +|---|---|---| +| `src/SharpEquations.Editing/` | netstandard2.0, net10.0 | SharpEquations | +| `src/SharpEquations.Rendering/` | netstandard2.0, net10.0 | SharpEquations.Editing (for the node tree) | +| `src/SharpEquations.Avalonia/` | net8.0, net10.0 | SharpEquations.Rendering, Avalonia 11 | +| `samples/SharpEquations.Avalonia.Demo/` | net10.0 | SharpEquations.Avalonia, Avalonia.Desktop | +| `tests/SharpEquations.Editing.Tests/` | as the core tests | TUnit, Avalonia.Headless for the control | + +They ship as separate packages, so that who uses only the converter does not get Avalonia. + +## The document model (`SharpEquations.Editing`) + +### Why a tree and not a string + +The obvious design keeps the linear text as the source of truth and parses all of it at each keystroke. It does not +work for an editor in two dimensions: the caret is in the rendered equation, and mapping it to an offset in the text +needs the source span of every node, which no parser of SharpEquations gives (the UnicodeMath translator, ported line +by line, has no notion of spans). The model is therefore the one of Word: a tree of built-up objects in which the only +text is the *linear runs* at the caret, and parsing is limited to what is being built up. + +```csharp +abstract class MathNode // immutable; a change gives a new tree (cheap undo, safe rendering) +sealed class MathRow : MathNode // a sequence: the slot of every structure; the caret lives here +sealed class MathToken : MathNode // mi, mn, mo, mtext, ms: one atom of the row, with its attributes +sealed class MathRun : MathNode // linear text not built up yet, drawn as typed +sealed class MathScript : MathNode // msub, msup, msubsup, munder, mover, munderover, mmultiscripts +sealed class MathFraction, MathRadical, MathEnclose, MathTable, MathStyle, MathPadded, MathPhantom, MathSpace ... +``` + +The nodes are the elements of the normalized MathML of `MathMLParser.Normalize`, which is a small set (MathML Core +plus `menclose`, `mlabeledtr`, `ms`, `mstack`/`mlongdiv` read as they are); each knows its *slots*, the children that +are rows the caret can enter (both children of `mfrac`, the base and the scripts of `msubsup`, each `mtd`). A node that +is not a row is wrapped in a row when it is a slot, so that the caret always has a row to stand in. + +- `MathDocument.FromMathML(XElement)`: the normalized MathML (`Equation.Parse(...).ToMathML()`) to nodes. +- `MathDocument.ToMathML()`: nodes to MathML; the runs are built up on the way (a run that does not parse is written as + `merror` with its text, or, with an option, as `mtext`), and the result goes through `Equation.Parse(xml, MathML)` + so that it is normalized like any other equation and `Equation` equality works on what the editor gives. +- `MathDocument.Equation`: the `Equation` of the content, the value a binding of the control sees. + +### Caret and selection + +A caret is a path of slots from the root plus an offset in a row (between two children) or in a run (between two code +points, never two UTF-16 `char`s, as everywhere in SharpEquations). A selection is two carets in the same row (a range +of siblings); extending it out of a row selects the whole structure the row belongs to, as Word does. Navigation is a +table per node kind (which slot is *up*, *down*, *next* and *previous* of which), kept out of the layout: the geometry +is the renderer's, and only *up*/*down* inside a table or a stack ask the layout for the nearest x. + +### Build up + +When a character that ends an argument is typed in a run (the list is the one of UnicodeMath, *Unicode Technical Note +28*, §3.5: space, operators, closing brackets, `&`, `@`, Enter), `BuildUp` takes: + +1. the *argument* before the caret: the run, together with the siblings before it in the row back to the start of the + row or to an operator of lower precedence, since `(a+b)/c` makes a fraction of a bracket already built up; +2. the linear text of those siblings, with `Equation.ToString(UnicodeMath)` (or `LaTeX`, `LaTeXFlavor.Word`) on the + MathML of each; this is exact because UnicodeMath round-trips exactly (`RoundTripPropertyTests`, kept at 100%): + building down and up again gives the same nodes; +3. `Equation.TryParse` of the text in the input format; if it fails the run stays as it is, and nothing is lost; +4. the nodes of the result replace the siblings and the run, and the caret goes after them, in a new empty run if the + character typed was a space (which is consumed, as in Word) or after the operator otherwise. + +`BuildDown` is the opposite: a node (the one at the caret, the selection, or the whole equation for the linear view) +becomes one run with its linear text. Both are commands on the undo stack, so Ctrl+Z after a build-up gives back the +text as it was typed, as in Word. + +Parsing is synchronous: the run is a few characters, and an equation built down whole is the size of the examples of +`tests/data/unicodemath/`; `benchmarks/` gets a case for it so that the time per keystroke is measured, not assumed. + +### Autocorrect and autocomplete + +`\alpha␣` → `α` comes from the parser (a control word followed by a space). The list for the autocomplete popup is the +table of `Internal/UnicodeMath/ControlWords.cs` and `Internal/LaTeX/LaTeXSymbols.cs`, which are internal; see *Open +questions*. + +## Layout and rendering (`SharpEquations.Rendering`) + +Rendering MathML well is the largest piece of work and the one with no shortcut in Avalonia: CSharpMath.Avalonia +renders LaTeX with its own model, not MathML, and would mean a second representation to keep in step with the first. +The renderer is therefore a MathML Core layout over the OpenType `MATH` table, as browsers implement it. + +- **Fonts**: a math font with a `MATH` table, by default Latin Modern Math or STIX Two Math (both SIL OFL, embeddable + as an Avalonia resource; the license goes in `NOTICE`). The table (constants, italic corrections, top accent + attachment, glyph variants and assemblies for stretchy operators) is read from the font bytes, which Avalonia gives + with `IGlyphTypeface.TryGetTable`. +- **Layout**: an abstract `IMathFont` (advances, bounds, the `MATH` table, shaping of a run) and a layout that turns the + node tree into boxes (width, ascent, descent, italic correction) following MathML Core §3: tokens, `mrow` with the + spacing of the operator dictionary (`lspace`/`rspace`, `form` from the position), stretchy operators (brackets to the + height of their row, `∑` large in display), `mfrac`, `msqrt`/`mroot`, scripts and limits, `mtable`, `menclose`, + `mpadded`, `mspace`, display and inline style (`displaystyle`, `scriptlevel`). +- **Editing geometry**: every box keeps the node and slot it comes from, so that the renderer answers the editor's + questions: the rectangle of a caret, the node and offset at a point (hit testing for clicks and drags), the outline + of a selection. Empty slots are drawn as a dotted box (the placeholder `⬚` of Word). +- **Incremental**: the tree is immutable, so the layout of a subtree that did not change is reused (a cache keyed by + node identity and style); a keystroke lays out again only the path from the edited row to the root. + +## The Avalonia control (`SharpEquations.Avalonia`) + +```csharp +public class MathEditBox : TemplatedControl +{ + public static readonly StyledProperty EquationProperty; // two-way; the content as an Equation + public static readonly StyledProperty InputFormatProperty; // UnicodeMath (default) or LaTeX + public static readonly StyledProperty ViewModeProperty; // Professional or Linear + public static readonly StyledProperty IsDisplayProperty; // display or inline style + public static readonly StyledProperty IsReadOnlyProperty; + public static readonly StyledProperty MathFontFamilyProperty; + public static readonly StyledProperty FontSizeProperty; + + public string GetMath(); // MathML, as RichEditTextDocument.GetMath + public void SetMath(string mathML); // as RichEditTextDocument.SetMath + public string GetText(EquationFormat format); + public void BuildUp(); public void BuildDown(); + public event EventHandler? EquationChanged; +} + +public class MathView : Control // read-only: Equation or MathML in, drawing out +``` + +- Text input through `TextInput` and an `ITextInputMethodClient`, so that IMEs and dead keys work and the candidate + window is placed at the caret (Avalonia 11 `TextInputMethodClient`). +- Key bindings as `KeyBinding`s of commands (`MathEditCommands.BuildUp`, `ToggleLinear`...), so that an application + changes them; the defaults are those of Word. +- Accessibility: an `AutomationPeer` that exposes the value as UnicodeMath text (what Narrator and NVDA read in Word), + and the MathML for the readers that speak it. +- Theming: colours from the theme resources (foreground, selection, placeholder), a template with a `ScrollViewer` for + long equations, and the linear view as a `TextBox` part of the template. +- The `EquationProperty` is set on every build-up, not on every keystroke inside a run, so that a binding sees only + equations that parse; `GetMath()` builds up the runs as `ToMathML()` does. + +## Plan + +| Phase | Content | Done when | +|---|---|---| +| 1 | `SharpEquations.Rendering` with `MathView`: `MATH` table, layout of tokens, rows, fractions, scripts, radicals, brackets | the examples of `tests/data/unicodemath/` render without exceptions, screenshot tests of a set of them against reference images | +| 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, `BuildUp`/`BuildDown`, undo | the property test *build down then build up gives the same nodes* for all the examples; typing an example one character at a time gives its equation | +| 3 | `MathEditBox`: input, caret drawing, hit testing, selection, linear view, clipboard | headless tests (Avalonia.Headless) of typing, navigation and clipboard; the demo | +| 4 | Tables and matrices, `menclose`, accents, `mmultiscripts`, autocomplete, accessibility, IME | the rest of the normalized MathML renders; Narrator reads an equation | + +Phase 1 first, because it is useful alone (showing MathML in Avalonia is something people need without editing) and +because the editor cannot be judged without it. + +## Open questions + +These are the owner's to decide. + +1. **Repository**: the three projects in this repository (one solution, one CI, the core tested with them) or in a new + one (`SharpEquations.Avalonia`)? This document assumes this one, with separate packages and a version of their own. +2. **Public API of the core** for the editor; none is needed for phases 1–3, but these would help: + - the list of control words and LaTeX commands with their symbols (`ControlWords`, `LaTeXSymbols`) for autocomplete; + - parsing a *fragment* to MathML without the `math` element and the normalization of a whole equation, if the + build-up of an argument needs it (to be seen in phase 2: wrapping the fragment in an equation may be enough); + - the operator dictionary (`form`, `lspace`, `rspace`, `stretchy`, `largeop`) if the core has or gets one, so that + the layout does not keep a second copy. +3. **Default font**: Latin Modern Math (the look of LaTeX) or STIX Two Math (closer to Cambria Math of Word)? +4. **Inline equations in text**: is the goal a control for one equation (as `RichEditMathMode.MathOnly`), or also math + zones inside a text document (as Word and `RichEditBox` without `MathOnly`)? The design is for the first; the second + is a rich text editor of its own, which Avalonia does not have. From a1ef18d427863056baf3ae031f7294530650819a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 01:01:40 +0000 Subject: [PATCH 25/45] [no-new-version] Equation editor for Avalonia: MathEditBox and MathView A real-time equation editor in the manner of the RichEditBox of WinUI in math mode (math only): UnicodeMath or LaTeX typed at the caret builds up when a space or an operator ends an argument, and the equation is read and written as MathML (GetMath/SetMath) or as an Equation. Three projects, the core unchanged: - SharpEquations.Editing (netstandard2.0, net10.0, no UI): the tree of MathNodes with a row in every slot and runs of linear text, the caret, the selection, undo, build up (with the nodes before the run written back as typed, or as placeholders) and build down to linear text. - SharpEquations.Rendering (netstandard2.0, net10.0, no UI): the layout of MathML Core over the OpenType MATH table, with the ink boxes read from the glyf or CFF outlines of the font, and the geometry of caret and selection. - SharpEquations.Avalonia (net8.0, net10.0): MathEditBox, MathView, the fonts (Cambria Math on Windows, the embedded Libertinus Math elsewhere, OFL). With a demo window, the tests of the three (the controls in a headless session with Skia), CI for them, and docs/EDITOR.md updated to the design as built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- .github/workflows/ci.yml | 17 + AGENTS.md | 27 +- Directory.Packages.props | 5 + NOTICE | 10 + README.md | 26 + SharpEquations.slnx | 7 + docs/EDITOR.md | 331 ++-- .../SharpEquations.Avalonia.Demo/Program.cs | 90 + .../SharpEquations.Avalonia.Demo.csproj | 21 + .../Assets/Fonts/LibertinusMath-Regular.otf | Bin 0 -> 522244 bytes .../Assets/Fonts/OFL.txt | 94 ++ .../AvaloniaMathFont.cs | 111 ++ src/SharpEquations.Avalonia/MathDrawing.cs | 116 ++ src/SharpEquations.Avalonia/MathEditBox.cs | 582 +++++++ src/SharpEquations.Avalonia/MathFonts.cs | 78 + src/SharpEquations.Avalonia/MathView.cs | 114 ++ src/SharpEquations.Avalonia/MathViewMode.cs | 24 + .../SharpEquations.Avalonia.csproj | 34 + src/SharpEquations.Editing/BuildUp.cs | 292 ++++ src/SharpEquations.Editing/MathEditor.cs | 865 ++++++++++ src/SharpEquations.Editing/MathNode.cs | 219 +++ src/SharpEquations.Editing/MathPosition.cs | 100 ++ src/SharpEquations.Editing/MathSchema.cs | 131 ++ src/SharpEquations.Editing/MathTree.cs | 72 + .../SharpEquations.Editing.csproj | 33 + .../Text/CodePointText.cs | 51 + src/SharpEquations.Rendering/IMathFont.cs | 54 + src/SharpEquations.Rendering/Layout/Box.cs | 139 ++ src/SharpEquations.Rendering/Layout/Engine.cs | 1448 +++++++++++++++++ .../Layout/OperatorDictionary.cs | 120 ++ src/SharpEquations.Rendering/Layout/Style.cs | 135 ++ src/SharpEquations.Rendering/MathLayout.cs | 310 ++++ .../MathLayoutEngine.cs | 92 ++ .../OpenType/FontFile.cs | 82 + .../OpenType/GlyphOutlines.cs | 595 +++++++ .../OpenType/MathTable.cs | 307 ++++ .../SharpEquations.Rendering.csproj | 31 + .../Controls/MathEditBoxTests.cs | 129 ++ .../Controls/ScreenshotTests.cs | 73 + tests/SharpEquations.Avalonia.Tests/Corpus.cs | 27 + .../Editing/BuildUpTests.cs | 112 ++ .../Editing/CorpusTests.cs | 74 + .../Editing/EditorTests.cs | 129 ++ .../SharpEquations.Avalonia.Tests/Headless.cs | 34 + .../Rendering/LayoutTests.cs | 104 ++ .../Rendering/MathTableTests.cs | 47 + .../SharpEquations.Avalonia.Tests.csproj | 28 + 47 files changed, 7341 insertions(+), 179 deletions(-) create mode 100644 samples/SharpEquations.Avalonia.Demo/Program.cs create mode 100644 samples/SharpEquations.Avalonia.Demo/SharpEquations.Avalonia.Demo.csproj create mode 100644 src/SharpEquations.Avalonia/Assets/Fonts/LibertinusMath-Regular.otf create mode 100644 src/SharpEquations.Avalonia/Assets/Fonts/OFL.txt create mode 100644 src/SharpEquations.Avalonia/AvaloniaMathFont.cs create mode 100644 src/SharpEquations.Avalonia/MathDrawing.cs create mode 100644 src/SharpEquations.Avalonia/MathEditBox.cs create mode 100644 src/SharpEquations.Avalonia/MathFonts.cs create mode 100644 src/SharpEquations.Avalonia/MathView.cs create mode 100644 src/SharpEquations.Avalonia/MathViewMode.cs create mode 100644 src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj create mode 100644 src/SharpEquations.Editing/BuildUp.cs create mode 100644 src/SharpEquations.Editing/MathEditor.cs create mode 100644 src/SharpEquations.Editing/MathNode.cs create mode 100644 src/SharpEquations.Editing/MathPosition.cs create mode 100644 src/SharpEquations.Editing/MathSchema.cs create mode 100644 src/SharpEquations.Editing/MathTree.cs create mode 100644 src/SharpEquations.Editing/SharpEquations.Editing.csproj create mode 100644 src/SharpEquations.Editing/Text/CodePointText.cs create mode 100644 src/SharpEquations.Rendering/IMathFont.cs create mode 100644 src/SharpEquations.Rendering/Layout/Box.cs create mode 100644 src/SharpEquations.Rendering/Layout/Engine.cs create mode 100644 src/SharpEquations.Rendering/Layout/OperatorDictionary.cs create mode 100644 src/SharpEquations.Rendering/Layout/Style.cs create mode 100644 src/SharpEquations.Rendering/MathLayout.cs create mode 100644 src/SharpEquations.Rendering/MathLayoutEngine.cs create mode 100644 src/SharpEquations.Rendering/OpenType/FontFile.cs create mode 100644 src/SharpEquations.Rendering/OpenType/GlyphOutlines.cs create mode 100644 src/SharpEquations.Rendering/OpenType/MathTable.cs create mode 100644 src/SharpEquations.Rendering/SharpEquations.Rendering.csproj create mode 100644 tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Corpus.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Editing/BuildUpTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Editing/CorpusTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Headless.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Rendering/LayoutTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a14b17..f12539a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ env: SOLUTION: SharpEquations.slnx LIBRARY_PROJECT: src/SharpEquations/SharpEquations.csproj TEST_PROJECT: tests/SharpEquations.Tests/SharpEquations.Tests.csproj + EDITOR_TEST_PROJECT: tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json # Stages: Resolve Version -> Test (Linux, Windows, macOS) -> Pack NuGet @@ -103,6 +104,7 @@ jobs: label: Linux x64 os: ubuntu-latest tfms: net10.0 net8.0 + editor_tfms: net10.0 net8.0 dotnet: | 8.0.x 10.0.x @@ -110,11 +112,13 @@ jobs: label: Windows x64 os: windows-latest tfms: net10.0 net481 + editor_tfms: net10.0 dotnet: 10.0.x - rid: osx-arm64 label: macOS arm64 os: macos-latest tfms: net10.0 + editor_tfms: net10.0 dotnet: 10.0.x steps: @@ -150,6 +154,19 @@ jobs: echo "::endgroup::" done + # the equation editor (Avalonia 12 has no .NET Framework build: net10.0, and net8.0 where it is installed) + - name: Run editor tests + shell: bash + run: | + set -e + for TFM in ${{ matrix.editor_tfms }}; do + echo "::group::editor $TFM" + dotnet run --project "$EDITOR_TEST_PROJECT" -c Release -f "$TFM" --no-build -- \ + --report-trx --report-trx-filename "${{ matrix.rid }}.editor.$TFM.trx" \ + --results-directory "$GITHUB_WORKSPACE/TestResults" + echo "::endgroup::" + done + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/AGENTS.md b/AGENTS.md index 6f22b77..1c7ce4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,12 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other |---|---| | `src/SharpEquations/` | The library: `Public/` (the public API), `Internal/` (one folder per format), `Resources/` (XSLT stylesheets) | | `src/SharpEquations.RegexPolyfill/` | Build time only: the `[GeneratedRegex]` source generator of the netstandard2.0 build | +| `src/SharpEquations.Editing/` | The equation editor's model, no user interface: `MathNode` tree, caret, selection, undo, build up and build down | +| `src/SharpEquations.Rendering/` | The layout of MathML Core over the OpenType MATH table, no user interface: `MathLayoutEngine`, `MathLayout` | +| `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | +| `samples/SharpEquations.Avalonia.Demo/` | A window with the editor | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | +| `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia) | | `tests/data/` | Test data and its provenance (`tests/data/README.md`) | ## Code Style @@ -73,6 +78,24 @@ init-only record: `Display`, `Strict`, `PreserveFormatting`, `LaTeXFlavor` stand UnicodeMath: a slash is a slash, not a fraction, and a bar without its pair is a bar, not an absolute value), and written as standard LaTeX or as the LaTeX Word writes (`LaTeXSerializer`). +## The equation editor + +`docs/EDITOR.md` is its design. The core does not depend on it: the editor is built on the public API of the core only, +and Avalonia is a dependency of `SharpEquations.Avalonia` alone (Editing and Rendering target netstandard2.0 and +net10.0 with no package, as the core). + +- `Editing`: an equation being edited is a tree of `MathNode`s (the normalized MathML with a row, `mrow`, in every + slot) whose only text not built up is in runs. A run builds up with the nodes before it in its row: tokens are written + back as they were typed, structures as placeholders (Yi syllables, which the parsers read as identifiers) put back in + the result; this works because UnicodeMath round-trips exactly. Positions are paths of child indexes; offsets in runs + are code points. +- `Rendering`: `Layout/Engine.cs` lays out after MathML Core with the constants of the MATH table; the ink boxes of the + glyphs come from the outlines of the font (`OpenType/GlyphOutlines.cs`: `glyf`, or the CFF charstrings), never from + the rasterizer, and the tables from a copy of the font file (`OpenType/FontFile.cs`). +- `Avalonia`: `AvaloniaMathFont` reads the font file once with `TryGetStream` (the memory of `TryGetTable` does not stay + valid); the tests that use the headless session are `[NotInParallel("headless")]`, since showing a window or typing + runs the jobs of the dispatcher. + ## Building and Testing ```bash @@ -85,7 +108,9 @@ net481 (Windows, where the test project targets net10.0 and net481). The 738 exa `tests/data/unicodemath/` test the UnicodeMath port, and `Property/RoundTripPropertyTests` checks, for all of them and for the equations of Word documents (`tests/data/omml/`), that an equation written in its own format (UnicodeMath, OMML) reads back to the same equation, and that one written in another format (standard LaTeX, Word's LaTeX, OMML) -loses only the first time what that format cannot hold. Keep these at 100%. +loses only the first time what that format cannot hold. Keep these at 100%. For the editor, an equation of the corpus +loaded in the editor, or built down to linear text and up again, is the same equation (100%); typed one character at a +time it is the equation of the whole text for 93% of the corpus (`Editing/CorpusTests`), which a change must not lower. ## Releases diff --git a/Directory.Packages.props b/Directory.Packages.props index 949f387..fb1e0f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,11 @@ true + + + + + diff --git a/NOTICE b/NOTICE index 5e2c4be..10da68f 100644 --- a/NOTICE +++ b/NOTICE @@ -238,3 +238,13 @@ 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.Avalonia/Assets/Fonts/LibertinusMath-Regular.otf + +Libertinus Math 7.040, from the Libertinus fonts, https://github.com/alerque/libertinus, +the math font that SharpEquations.Avalonia embeds for the systems without Cambria Math. +Copyright © 2012-2021 The Libertinus Project Authors, with Reserved Font Name +"Linux Libertine", "Biolinum", "STIX Fonts". Licensed under the SIL Open Font License, +Version 1.1: src/SharpEquations.Avalonia/Assets/Fonts/OFL.txt (LibertinusMath-OFL.txt in +the package). diff --git a/README.md b/README.md index 01bdc54..6b57ff5 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,32 @@ stream of its OLE object (`Equation.Parse(bytes, EquationFormat.Mtef)`); reading Equations compare by their normalized MathML: an equation written in its own format (UnicodeMath, OMML) reads back to the same equation, and one written in another format loses only what that format cannot hold. +## Equation editor for Avalonia + +`SharpEquations.Avalonia` is a real-time equation editor in the manner of the `RichEditBox` of WinUI in math mode and of +the equations of Word: UnicodeMath or LaTeX typed at the caret builds up into the equation when a space or an operator +ends an argument (`a/b␣` becomes a fraction, `\alpha␣` α), the caret moves through the slots of the structures, and the +equation is read and written as MathML or as an `Equation`. + +```xml + +``` + +```csharp +var editor = new MathEditBox { InputFormat = EquationFormat.LaTeX }; +editor.EquationChanged += (_, e) => Console.WriteLine(e.Equation?.ToString(EquationFormat.MathML)); +string mathML = editor.GetMath(); // as RichEditTextDocument.GetMath +editor.SetMath(mathML); // as RichEditTextDocument.SetMath +editor.ViewMode = MathViewMode.Linear; // the equation as one line of UnicodeMath (Ctrl+Shift+L) +``` + +`MathView` shows an equation without editing it. Both lay out MathML after MathML Core with the OpenType MATH table of +a math font: Cambria Math on Windows, and Libertinus Math (SIL Open Font License), embedded, elsewhere. The editor is +three projects, so that the core keeps no dependency: `SharpEquations.Editing` (the editing model, no user interface), +`SharpEquations.Rendering` (the layout, no user interface) and `SharpEquations.Avalonia` (the controls). The design is in +[docs/EDITOR.md](docs/EDITOR.md); `samples/SharpEquations.Avalonia.Demo` is a window with the editor and its MathML, +UnicodeMath and LaTeX. + ## Architecture How the conversions work, format by format, and how round trips are kept exact: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). diff --git a/SharpEquations.slnx b/SharpEquations.slnx index 7205bd2..4a96c46 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -11,11 +11,18 @@ + + + + + + + diff --git a/docs/EDITOR.md b/docs/EDITOR.md index 442bd9e..15387fb 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -1,225 +1,200 @@ -# Equation editor for Avalonia (design) +# Equation editor for Avalonia -A design for `MathEditBox`, a real-time equation editor for Avalonia in the manner of the `RichEditBox` of WinUI in +`MathEditBox` is a real-time equation editor for Avalonia in the manner of the `RichEditBox` of WinUI in `RichEditMathMode.MathOnly`: the user types UnicodeMath or LaTeX, the equation builds up into its two-dimensional form -as it is typed, and the content is read and written as MathML (`GetMath`/`SetMath` in WinUI). SharpEquations is the -core under it: it reads what is typed, gives the MathML, and writes the linear form back when a part of the equation -is built down. This document is a proposal: nothing of it exists yet. +as it is typed, and the content is read and written as MathML (`GetMath`/`SetMath`, as in WinUI) or as an `Equation`. +SharpEquations is the core under it: it reads what is typed, gives the MathML, and writes the linear form back when a +part of the equation is built down. The editor edits one equation (math only), not math zones in a text document. + +Phases 1 to 3 of the plan below are done; phase 4 is what remains. ## What the user sees -The behaviour is the one of the math zones of Word and of `RichEditBox` in math mode. - -- **Professional** (built up): the equation is shown in two dimensions. Typing goes into the *linear run* at the caret, - a piece of plain text; when a character that ends an argument is typed (space, an operator, `)`, `]`, Enter, Tab) the - run is *built up*: `a/b␣` becomes a fraction, `x^2␣` a superscript, `\alpha␣` becomes `α`, `\sqrt(x)` a radical. - Text that does not parse yet (`a/`) stays linear, drawn as typed, until it does. -- **Linear**: the whole equation (or the selection) is shown as one line of UnicodeMath or LaTeX and edited as text. - The switch (Ctrl+Shift+=, as Word has it, or a property) builds down to linear and up to professional. -- **Input format**: UnicodeMath or LaTeX (`InputFormat`); LaTeX is read as Word reads it, which is what SharpEquations - already does (`LaTeXReader`), and written back, when built down, in the LaTeX of Word (`LaTeXFlavor.Word`). -- **Navigation** in two dimensions: the arrows move through the slots of the structures (numerator → denominator, - base → script, the cells of a matrix), Home/End to the ends of the current row, Backspace into a structure *selects* - it first and deletes it the second time (as Word), Ctrl+Z/Ctrl+Y undo and redo. -- **Clipboard**: copy puts MathML (`application/mathml+xml`), UnicodeMath or LaTeX (as plain text, in the input format) - and OMML on the clipboard; paste reads any of them through `Equation.Parse`. -- **Autocomplete**: after `\` a list of control words (`\alpha`, `\int`, `\matrix`...) with their symbols. +The behaviour is the one of the equations of Word and of `RichEditBox` in math mode. + +- **Professional** (built up): the equation is shown in two dimensions. Typing goes into the *run* at the caret, a + piece of linear text; when a character that ends an argument is typed (a space, or an operator: `+ - = < > , ; :`, + `± × · ÷`, the relations and arrows) the run is *built up*: `a/b␣` becomes a fraction, `x^2+` a superscript and a + plus, `\alpha␣` becomes `α`, `\sqrt(x)␣` a radical. The space that builds up is not kept. Text that does not parse + yet, or that is not finished (an open bracket or quote, an operator right after `^ _ / \ (`, as in `e^-x`), stays + linear, drawn as typed, until it does. +- **Linear**: the whole equation is shown as one run of UnicodeMath or LaTeX and edited as text + (`ViewMode = MathViewMode.Linear`, or Ctrl+Shift+L, `ToggleViewModeGesture`). Switching back builds it up. +- **Input format**: `InputFormat` is UnicodeMath or LaTeX; LaTeX is read as Word reads it (`LaTeXReader`) and written, + when built down, as the LaTeX of Word (`LaTeXFlavor.Word`). +- **Navigation**: Left and Right go through the slots of the structures (numerator, denominator, base, scripts, the + index of a root before its base, the cells of a table); Up and Down go to the slot above or below (numerator and + denominator, superscript and subscript, limits, the rows of a table), to the position nearest to the caret; Home and + End go to the ends of the row, then of the equation; Shift extends the selection, which covers a whole structure when + it leaves a slot. Backspace and Delete remove a code point of a run or of a token (a token becomes a run then), and + select a structure first and delete it the second time, as in Word. Ctrl+Z and Ctrl+Y undo and redo (an undo after a + build up gives back the text as it was typed); Ctrl+A selects all; Enter builds up all the runs. The shortcuts of copy, + paste, undo... are those of the platform (`PlatformHotkeyConfiguration`). +- **Mouse**: a click places the caret at the nearest position of the deepest row under the pointer; a drag selects. +- **Clipboard**: copy puts the selection as linear text in the input format and as MathML (`MathML` on Windows, + `application/mathml+xml` elsewhere); paste reads MathML, then the text in the input format or as MathML, and inserts + plain text as a run when it does not parse. +- **Empty slots** are drawn as a dashed box, as the placeholder `⬚` of Word. ## Layers ```mermaid flowchart TB - subgraph Core["SharpEquations (exists, unchanged: .NET base library only)"] - P["Equation.Parse(UnicodeMath | LaTeX)"] + subgraph Core["SharpEquations (unchanged: .NET base library only)"] + P["Equation.Parse(UnicodeMath | LaTeX | MathML)"] W["Equation.ToString(UnicodeMath | LaTeX)"] X["Equation.ToMathML()"] end - subgraph Editing["SharpEquations.Editing (new, netstandard2.0 + net10.0, no UI)"] - D["MathDocument: tree of nodes + linear runs"] - C["Caret, selection, navigation"] - B["BuildUp / BuildDown"] - U["Undo stack, commands"] + subgraph Editing["SharpEquations.Editing (netstandard2.0, net10.0)"] + D["MathNode tree with runs"] + C["MathEditor: caret, selection, undo, commands"] + B["BuildUp: build up, build down"] end - subgraph Layout["SharpEquations.Rendering (new, no UI)"] - M["OpenType MATH table reader"] - L["MathML Core layout → boxes"] - H["Hit testing, caret geometry"] + subgraph Rendering["SharpEquations.Rendering (netstandard2.0, net10.0)"] + M["OpenType: MathTable, GlyphOutlines, FontFile"] + L["Layout/Engine: MathML Core layout"] + H["MathLayout: glyphs, shapes, caret geometry, hit testing"] end - subgraph Avalonia["SharpEquations.Avalonia (new, depends on Avalonia)"] - E["MathEditBox (editor control)"] - V["MathView (read-only control)"] - F["Font adapter, drawing, IME, clipboard"] + subgraph Avalonia["SharpEquations.Avalonia (net8.0, net10.0)"] + E["MathEditBox"] + V["MathView"] + F["AvaloniaMathFont, MathFonts, MathDrawing"] end B --> P B --> W - D --> X - E --> D - E --> L - V --> L + C --> X + E --> C + E --> H + V --> H L --> M - F --> L + F --> M ``` -The core stays as it is: the rules of `AGENTS.md` (no dependencies but the base library, no change of the public API -without a reason that is the owner's) hold for it, and the editor is built on its public API. The two middle layers -have no UI dependency, so that they are tested with TUnit like the core and could serve another toolkit (WPF, MAUI, -Uno) later; only the last depends on Avalonia. - -| Project | Target | Depends on | -|---|---|---| -| `src/SharpEquations.Editing/` | netstandard2.0, net10.0 | SharpEquations | -| `src/SharpEquations.Rendering/` | netstandard2.0, net10.0 | SharpEquations.Editing (for the node tree) | -| `src/SharpEquations.Avalonia/` | net8.0, net10.0 | SharpEquations.Rendering, Avalonia 11 | -| `samples/SharpEquations.Avalonia.Demo/` | net10.0 | SharpEquations.Avalonia, Avalonia.Desktop | -| `tests/SharpEquations.Editing.Tests/` | as the core tests | TUnit, Avalonia.Headless for the control | - -They ship as separate packages, so that who uses only the converter does not get Avalonia. +The core is unchanged and the editor is built on its public API only. Editing and Rendering have no dependency on a +user interface (they target netstandard2.0 and net10.0, with no package, as the core), so that they are tested without +a toolkit and could serve another one; only `SharpEquations.Avalonia` depends on Avalonia (12.1). Each is a project of +its own in this repository; `samples/SharpEquations.Avalonia.Demo` is a window with the editor and the equation in every +format, and `tests/SharpEquations.Avalonia.Tests` tests the three. -## The document model (`SharpEquations.Editing`) +## The model (`SharpEquations.Editing`) ### Why a tree and not a string -The obvious design keeps the linear text as the source of truth and parses all of it at each keystroke. It does not -work for an editor in two dimensions: the caret is in the rendered equation, and mapping it to an offset in the text -needs the source span of every node, which no parser of SharpEquations gives (the UnicodeMath translator, ported line -by line, has no notion of spans). The model is therefore the one of Word: a tree of built-up objects in which the only -text is the *linear runs* at the caret, and parsing is limited to what is being built up. - -```csharp -abstract class MathNode // immutable; a change gives a new tree (cheap undo, safe rendering) -sealed class MathRow : MathNode // a sequence: the slot of every structure; the caret lives here -sealed class MathToken : MathNode // mi, mn, mo, mtext, ms: one atom of the row, with its attributes -sealed class MathRun : MathNode // linear text not built up yet, drawn as typed -sealed class MathScript : MathNode // msub, msup, msubsup, munder, mover, munderover, mmultiscripts -sealed class MathFraction, MathRadical, MathEnclose, MathTable, MathStyle, MathPadded, MathPhantom, MathSpace ... -``` - -The nodes are the elements of the normalized MathML of `MathMLParser.Normalize`, which is a small set (MathML Core -plus `menclose`, `mlabeledtr`, `ms`, `mstack`/`mlongdiv` read as they are); each knows its *slots*, the children that -are rows the caret can enter (both children of `mfrac`, the base and the scripts of `msubsup`, each `mtd`). A node that -is not a row is wrapped in a row when it is a slot, so that the caret always has a row to stand in. - -- `MathDocument.FromMathML(XElement)`: the normalized MathML (`Equation.Parse(...).ToMathML()`) to nodes. -- `MathDocument.ToMathML()`: nodes to MathML; the runs are built up on the way (a run that does not parse is written as - `merror` with its text, or, with an option, as `mtext`), and the result goes through `Equation.Parse(xml, MathML)` - so that it is normalized like any other equation and `Equation` equality works on what the editor gives. -- `MathDocument.Equation`: the `Equation` of the content, the value a binding of the control sees. - -### Caret and selection - -A caret is a path of slots from the root plus an offset in a row (between two children) or in a run (between two code -points, never two UTF-16 `char`s, as everywhere in SharpEquations). A selection is two carets in the same row (a range -of siblings); extending it out of a row selects the whole structure the row belongs to, as Word does. Navigation is a -table per node kind (which slot is *up*, *down*, *next* and *previous* of which), kept out of the layout: the geometry -is the renderer's, and only *up*/*down* inside a table or a stack ask the layout for the nearest x. +Keeping the linear text as the source of truth and parsing all of it at each keystroke does not work for an editor in +two dimensions: the caret is in the rendered equation, and mapping it to an offset in the text needs the source span of +every node, which the parsers of SharpEquations do not give (the UnicodeMath translator, ported line by line, has no +notion of spans). The model is therefore the one of Word: a tree of built-up structures whose only text is in runs, and +parsing is limited to what is being built up. For the same reason a streaming API in the core (input received a piece +at a time) is not needed: the pieces are short, and each is parsed whole. + +`MathNode` is an element of MathML (`Name`, `Attributes`, `Text` for tokens, `Children`) or a run (`IsRun`), immutable: +an edit gives a new tree that shares the nodes it did not change, which makes undo a stack of trees. Every slot of a +structure is a row (`mrow`), even where MathML has a single element or an inferred row (`msqrt`, `mtd`...), so that the +caret always has a row to stand in. `MathNode.FromMathML` adds these rows; `ToMathML` writes them back, and without a +row the slots that hold a single node, which is the normalized form (the normalization recognizes the operand of an +n-ary operator only that way). Elements the editor does not enter (`semantics`, `mstack`, `mlongdiv`) are kept as they +were read. + +A position (`MathPosition`) is the path of a row (offset between two children) or of a run (offset between two code +points, never UTF-16 units), as child indexes from the `math` node. A position between a run and its neighbour is the +end or the start of the run. A selection (`MathSelection`) is a range of the children of a row, or of the code points of +a run; when its two ends are in different rows, it is the range of the deepest row that holds both, where an end inside +a child covers the child. ### Build up -When a character that ends an argument is typed in a run (the list is the one of UnicodeMath, *Unicode Technical Note -28*, §3.5: space, operators, closing brackets, `&`, `@`, Enter), `BuildUp` takes: - -1. the *argument* before the caret: the run, together with the siblings before it in the row back to the start of the - row or to an operator of lower precedence, since `(a+b)/c` makes a fraction of a bracket already built up; -2. the linear text of those siblings, with `Equation.ToString(UnicodeMath)` (or `LaTeX`, `LaTeXFlavor.Word`) on the - MathML of each; this is exact because UnicodeMath round-trips exactly (`RoundTripPropertyTests`, kept at 100%): - building down and up again gives the same nodes; -3. `Equation.TryParse` of the text in the input format; if it fails the run stays as it is, and nothing is lost; -4. the nodes of the result replace the siblings and the run, and the caret goes after them, in a new empty run if the - character typed was a space (which is consumed, as in Word) or after the operator otherwise. - -`BuildDown` is the opposite: a node (the one at the caret, the selection, or the whole equation for the linear view) -becomes one run with its linear text. Both are commands on the undo stack, so Ctrl+Z after a build-up gives back the -text as it was typed, as in Word. - -Parsing is synchronous: the run is a few characters, and an equation built down whole is the size of the examples of -`tests/data/unicodemath/`; `benchmarks/` gets a case for it so that the time per keystroke is measured, not assumed. +`BuildUp.Parse` builds up a run together with the nodes before it in its row, since the operand of an operator typed in +the run can be one of them: `(a+b)/c` makes a fraction of a bracket built up earlier, `x^2` a superscript of the `x` +before. Those nodes are written in the text as they were typed when they are tokens without attributes (`a`, `+`, `12`, +`sin`) or brackets of tokens, so that the parser reads them as it would have read the text; any other node (a structure, +a token with attributes) is written as a placeholder, a character of the Yi syllables (U+A000...) that the parsers read +as an identifier, and the node is put back in place of the identifier in the result. The result is discarded if a +placeholder is lost, merged or repeated. -### Autocorrect and autocomplete +This is exact because UnicodeMath round-trips exactly (`RoundTripPropertyTests`): an equation of the corpus loaded in +the editor, or built down to linear text and up again, is the same equation, for all of them +(`Editing/CorpusTests`). Typed one character at a time, building up as it goes, an equation of the corpus is the +equation of its whole text for 93% of the corpus: the others are text that UnicodeMath reads otherwise when it is read a +piece at a time (prescripts typed before their base, `^+-` that becomes `^±`, spaces that end an operand), where what +builds up at an operator stays built up, as in Word. -`\alpha␣` → `α` comes from the parser (a control word followed by a space). The list for the autocomplete popup is the -table of `Internal/UnicodeMath/ControlWords.cs` and `Internal/LaTeX/LaTeXSymbols.cs`, which are internal; see *Open -questions*. +`MathEditor.ToEquation()` builds up a copy of the tree, with the runs that do not parse as `merror`, and reads it as +MathML, so that the equation an editor gives is normalized like any other and compares with `==`. ## Layout and rendering (`SharpEquations.Rendering`) -Rendering MathML well is the largest piece of work and the one with no shortcut in Avalonia: CSharpMath.Avalonia -renders LaTeX with its own model, not MathML, and would mean a second representation to keep in step with the first. -The renderer is therefore a MathML Core layout over the OpenType `MATH` table, as browsers implement it. - -- **Fonts**: a math font with a `MATH` table, by default Latin Modern Math or STIX Two Math (both SIL OFL, embeddable - as an Avalonia resource; the license goes in `NOTICE`). The table (constants, italic corrections, top accent - attachment, glyph variants and assemblies for stretchy operators) is read from the font bytes, which Avalonia gives - with `IGlyphTypeface.TryGetTable`. -- **Layout**: an abstract `IMathFont` (advances, bounds, the `MATH` table, shaping of a run) and a layout that turns the - node tree into boxes (width, ascent, descent, italic correction) following MathML Core §3: tokens, `mrow` with the - spacing of the operator dictionary (`lspace`/`rspace`, `form` from the position), stretchy operators (brackets to the - height of their row, `∑` large in display), `mfrac`, `msqrt`/`mroot`, scripts and limits, `mtable`, `menclose`, - `mpadded`, `mspace`, display and inline style (`displaystyle`, `scriptlevel`). -- **Editing geometry**: every box keeps the node and slot it comes from, so that the renderer answers the editor's - questions: the rectangle of a caret, the node and offset at a point (hit testing for clicks and drags), the outline - of a selection. Empty slots are drawn as a dotted box (the placeholder `⬚` of Word). -- **Incremental**: the tree is immutable, so the layout of a subtree that did not change is reused (a cache keyed by - node identity and style); a keystroke lays out again only the path from the edited row to the root. - -## The Avalonia control (`SharpEquations.Avalonia`) +The renderer lays out the nodes after MathML Core with the OpenType MATH table of a math font: there is no MathML +renderer for Avalonia (CSharpMath renders LaTeX with its own model, which would be a second representation to keep in +step with the first). + +- **Fonts**: `IMathFont` gives the glyphs, the advances, the ink boxes and the MATH table; `MathTable` reads the + constants, the italic corrections, the top accent attachments and the vertical and horizontal variants and + assemblies. The ink boxes come from the outlines of the font, not from a rasterizer: `GlyphOutlines` reads the boxes of + the `glyf` table of a TrueType font (Cambria Math) or runs the charstrings of the `CFF` table (Libertinus Math), within + 0.12 design units of Skia. (Avalonia's own glyph metrics are empty for CFF outlines, and the bounds of a glyph run in + Skia can come back empty.) +- **Layout** (`Layout/Engine.cs`): tokens (a single letter of `mi` in mathematical italic), rows with the spaces of the + operator dictionary by form (none in scripts, as TeX), vertical stretchy operators to the height of their row + (symmetric about the axis for fences), large operators in display style, fractions (`linethickness`, stacks without + rule), radicals and roots, scripts (with italic correction and the baseline drops), under and over scripts (accents on + the top accent attachment, limits, movable limits as scripts in inline style, horizontal stretchy operators and bars as + wide as the base), `mmultiscripts`, tables (`columnalign`, `columnspacing`, `rowspacing`, labels), `menclose`, + `mstyle`, `mpadded`, `mphantom`, `mspace`, `merror`, `ms`, `mathcolor` and `mathbackground`. +- **Result** (`MathLayout`): glyphs and shapes with absolute coordinates for a toolkit to draw, and the geometry of every + row and run for an editor: the caret rectangle of a position, the rectangles of a selection, the position at a point + (`HitTest`: the deepest row under the point, or the nearest), the nearest position in a row (`IMathGeometry`, which + the editor uses to move up and down). + +## The controls (`SharpEquations.Avalonia`) ```csharp -public class MathEditBox : TemplatedControl +public class MathEditBox : Control { - public static readonly StyledProperty EquationProperty; // two-way; the content as an Equation - public static readonly StyledProperty InputFormatProperty; // UnicodeMath (default) or LaTeX - public static readonly StyledProperty ViewModeProperty; // Professional or Linear - public static readonly StyledProperty IsDisplayProperty; // display or inline style - public static readonly StyledProperty IsReadOnlyProperty; - public static readonly StyledProperty MathFontFamilyProperty; - public static readonly StyledProperty FontSizeProperty; - - public string GetMath(); // MathML, as RichEditTextDocument.GetMath - public void SetMath(string mathML); // as RichEditTextDocument.SetMath + public Equation? Equation { get; set; } // two-way; null when empty + public EquationFormat InputFormat { get; set; } // UnicodeMath (default) or LaTeX + public MathViewMode ViewMode { get; set; } // Professional or Linear + public bool IsDisplay { get; set; } // display (default) or inline + public bool IsReadOnly { get; set; } + public double FontSize { get; set; } + public IBrush? Foreground, Background, BorderBrush, SelectionBrush, CaretBrush, PlaceholderBrush { get; set; } + public FontFamily? MathFontFamily { get; set; } // null: MathFonts.Default + public KeyGesture? ToggleViewModeGesture { get; set; } // Ctrl+Shift+L + public MathEditor Editor { get; } // the model, for commands of an application + + public string GetMath(); // as RichEditTextDocument.GetMath + public void SetMath(string mathML); // as RichEditTextDocument.SetMath public string GetText(EquationFormat format); public void BuildUp(); public void BuildDown(); + public Task CopyAsync(); public Task CutAsync(); public Task PasteAsync(); public event EventHandler? EquationChanged; } -public class MathView : Control // read-only: Equation or MathML in, drawing out +public class MathView : Control // read-only: Equation (or MathML) in, drawing out ``` -- Text input through `TextInput` and an `ITextInputMethodClient`, so that IMEs and dead keys work and the candidate - window is placed at the caret (Avalonia 11 `TextInputMethodClient`). -- Key bindings as `KeyBinding`s of commands (`MathEditCommands.BuildUp`, `ToggleLinear`...), so that an application - changes them; the defaults are those of Word. -- Accessibility: an `AutomationPeer` that exposes the value as UnicodeMath text (what Narrator and NVDA read in Word), - and the MathML for the readers that speak it. -- Theming: colours from the theme resources (foreground, selection, placeholder), a template with a `ScrollViewer` for - long equations, and the linear view as a `TextBox` part of the template. -- The `EquationProperty` is set on every build-up, not on every keystroke inside a run, so that a binding sees only - equations that parse; `GetMath()` builds up the runs as `ToMathML()` does. +- **Fonts**: `MathFonts.Default` is Cambria Math on Windows, where it is a font of the system, and Libertinus Math + (SIL Open Font License, embedded as an Avalonia resource) elsewhere or when Cambria Math is missing; `MathFontFamily` + takes any family with a MATH table. Characters the font lacks come from a fallback font of the system. + `AvaloniaMathFont` reads the font file once (`TryGetStream`): the memory of `IFontMemory.TryGetTable` does not stay + valid after a garbage collection. +- **Text input** comes from `TextInput`, so that IMEs and dead keys work, and a `TextInputMethodClient` gives the IME the + rectangle of the caret. +- The `Equation` property is set on every change of the content, with the runs built up where they parse. ## Plan -| Phase | Content | Done when | +| Phase | Content | State | |---|---|---| -| 1 | `SharpEquations.Rendering` with `MathView`: `MATH` table, layout of tokens, rows, fractions, scripts, radicals, brackets | the examples of `tests/data/unicodemath/` render without exceptions, screenshot tests of a set of them against reference images | -| 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, `BuildUp`/`BuildDown`, undo | the property test *build down then build up gives the same nodes* for all the examples; typing an example one character at a time gives its equation | -| 3 | `MathEditBox`: input, caret drawing, hit testing, selection, linear view, clipboard | headless tests (Avalonia.Headless) of typing, navigation and clipboard; the demo | -| 4 | Tables and matrices, `menclose`, accents, `mmultiscripts`, autocomplete, accessibility, IME | the rest of the normalized MathML renders; Narrator reads an equation | - -Phase 1 first, because it is useful alone (showing MathML in Avalonia is something people need without editing) and -because the editor cannot be judged without it. - -## Open questions - -These are the owner's to decide. - -1. **Repository**: the three projects in this repository (one solution, one CI, the core tested with them) or in a new - one (`SharpEquations.Avalonia`)? This document assumes this one, with separate packages and a version of their own. -2. **Public API of the core** for the editor; none is needed for phases 1–3, but these would help: - - the list of control words and LaTeX commands with their symbols (`ControlWords`, `LaTeXSymbols`) for autocomplete; - - parsing a *fragment* to MathML without the `math` element and the normalization of a whole equation, if the - build-up of an argument needs it (to be seen in phase 2: wrapping the fragment in an equation may be enough); - - the operator dictionary (`form`, `lspace`, `rspace`, `stretchy`, `largeop`) if the core has or gets one, so that - the layout does not keep a second copy. -3. **Default font**: Latin Modern Math (the look of LaTeX) or STIX Two Math (closer to Cambria Math of Word)? -4. **Inline equations in text**: is the goal a control for one equation (as `RichEditMathMode.MathOnly`), or also math - zones inside a text document (as Word and `RichEditBox` without `MathOnly`)? The design is for the first; the second - is a rich text editor of its own, which Avalonia does not have. +| 1 | `SharpEquations.Rendering` and `MathView`: MATH table, outlines, layout of MathML Core | done: the corpus lays out (`Rendering/LayoutTests`) | +| 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, selection, build up and down, undo | done: 100% of the corpus loads and round-trips through linear text, 93% typed a character at a time | +| 3 | `MathEditBox`: input, caret, hit testing, selection, linear view, clipboard, demo | done: headless tests of typing, keys, mouse, MathML in and out | +| 4 | Autocomplete of control words, accessibility (an `AutomationPeer` that reads the equation as UnicodeMath), keyboard navigation in tables (Tab, new rows), the `ssty` variants of scripts, cut lines of long equations, NuGet packages of the three projects | to do | + +## Open points + +- **Public API of the core** (for its next major version): the list of the control words of UnicodeMath and of the + LaTeX commands with their symbols (`ControlWords`, `LaTeXSymbols`, internal today), for the autocomplete of phase 4. +- **Packages**: the three projects are packable, but the CI packs and publishes only `SharpEquations`; publishing the + editor (with its own version or that of the core) is to be decided. +- **Reference images**: the rendering is tested by the geometry of the layout, not by pictures; the tests write + pictures of a set of equations when `SHARPEQUATIONS_SCREENSHOTS` names a directory, for a look. diff --git a/samples/SharpEquations.Avalonia.Demo/Program.cs b/samples/SharpEquations.Avalonia.Demo/Program.cs new file mode 100644 index 0000000..c3360f5 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/Program.cs @@ -0,0 +1,90 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.ApplicationLifetimes; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using global::Avalonia.Themes.Fluent; + using SharpEquations; + + public static class Program + { + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UsePlatformDetect(); + } + + public sealed class App : Application + { + public override void Initialize() => Styles.Add(new FluentTheme()); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + } + + /// + /// The editor, with what it holds in every format: type UnicodeMath (or LaTeX) and it builds up as in Word; + /// Ctrl+Shift+L shows it as linear text and back. + /// + public sealed class MainWindow : Window + { + public MainWindow() + { + Title = "SharpEquations — MathEditBox"; + Width = 900; + Height = 640; + + var editor = new MathEditBox { FontSize = 30, MinHeight = 80, BorderBrush = Brushes.SteelBlue, Padding = new Thickness(12) }; + var format = new ComboBox { ItemsSource = new[] { "UnicodeMath", "LaTeX" }, SelectedIndex = 0 }; + format.SelectionChanged += (_, _) => editor.InputFormat = format.SelectedIndex == 1 ? EquationFormat.LaTeX : EquationFormat.UnicodeMath; + var linear = new CheckBox { Content = "Linear (Ctrl+Shift+L)" }; + linear.IsCheckedChanged += (_, _) => editor.ViewMode = linear.IsChecked == true ? MathViewMode.Linear : MathViewMode.Professional; + var display = new CheckBox { Content = "Display", IsChecked = true }; + display.IsCheckedChanged += (_, _) => editor.IsDisplay = display.IsChecked == true; + + TextBox Output() => new TextBox { IsReadOnly = true, TextWrapping = TextWrapping.Wrap, FontFamily = new FontFamily("monospace"), MinHeight = 32 }; + var mathML = Output(); + var unicodeMath = Output(); + var latex = Output(); + var view = new MathView { FontSize = 22 }; + + editor.EquationChanged += (_, e) => + { + linear.IsChecked = editor.ViewMode == MathViewMode.Linear; + mathML.Text = e.Equation?.ToString(EquationFormat.MathML); + unicodeMath.Text = e.Equation?.ToString(EquationFormat.UnicodeMath); + latex.Text = e.Equation == null ? null : $"{e.Equation:L}"; + view.Equation = e.Equation; + }; + + Control Labelled(string label, Control control) => new StackPanel { Spacing = 4, Children = { new TextBlock { Text = label, FontWeight = FontWeight.SemiBold }, control } }; + + Content = new ScrollViewer + { + Content = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12, + Children = + { + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 16, Children = { format, linear, display } }, + editor, + new TextBlock { Text = "Type a/b, x^2, \\sqrt(x), \\alpha, (a+b)/c, \\sum_(i=1)^n i, then a space or an operator to build up. Backspace selects a structure, then deletes it.", TextWrapping = TextWrapping.Wrap, Opacity = 0.7 }, + Labelled("MathView", view), + Labelled("MathML", mathML), + Labelled("UnicodeMath", unicodeMath), + Labelled("LaTeX", latex), + }, + }, + }; + Opened += (_, _) => editor.Focus(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Demo/SharpEquations.Avalonia.Demo.csproj b/samples/SharpEquations.Avalonia.Demo/SharpEquations.Avalonia.Demo.csproj new file mode 100644 index 0000000..06ca7db --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/SharpEquations.Avalonia.Demo.csproj @@ -0,0 +1,21 @@ + + + + + WinExe + net10.0 + false + enable + SharpEquations.Avalonia.Demo + + + + + + + + + + + + diff --git a/src/SharpEquations.Avalonia/Assets/Fonts/LibertinusMath-Regular.otf b/src/SharpEquations.Avalonia/Assets/Fonts/LibertinusMath-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..8e32720df0ec90c6f175024bce61c21b47be073e GIT binary patch literal 522244 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 z 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 font of a glyph typeface. + public AvaloniaMathFont(GlyphTypeface typeface) + { + Typeface = typeface ?? throw new ArgumentNullException(nameof(typeface)); + file = new Lazy(ReadFile); + math = new Lazy(ReadMath); + outlines = new Lazy(() => GlyphOutlines.Create(Table)); + } + + /// The glyph typeface. + public GlyphTypeface Typeface { get; } + + /// + public double UnitsPerEm => Typeface.Metrics.DesignEmHeight; + + /// + public MathTable? Math => math.Value; + + /// + public bool TryGetGlyph(int codePoint, out ushort glyph) => Typeface.CharacterToGlyphMap.TryGetGlyph(codePoint, out glyph) && glyph != 0; + + /// + public double GetAdvance(ushort glyph) + { + if (!advances.TryGetValue(glyph, out var advance)) + { + advance = Typeface.TryGetHorizontalGlyphAdvance(glyph, out var a) ? a : 0; + advances[glyph] = advance; + } + return advance; + } + + /// + public GlyphBounds GetBounds(ushort glyph) => outlines.Value?.Bounds(glyph) ?? default; + + /// + public IMathFont? GetFallback(int codePoint) + { + if (!FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface)) + return null; + var key = typeface.FontFamily.Name; + if (!fallbacks.TryGetValue(key, out var font)) + { + font = FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs) && glyphs.FamilyName != Typeface.FamilyName + ? new AvaloniaMathFont(glyphs) + : null; + fallbacks[key] = font; + } + return font; + } + + /// + /// A table of the font, from a copy of the font file: the memory of the tables that Avalonia gives is not valid + /// for long. The ink boxes come from the outlines of the font rather than from the rasterizer, whose bounds of a + /// glyph run can be empty (and the metrics of Avalonia are, for CFF outlines). + /// + private byte[]? Table(string tag) => file.Value?.Table(tag); + + private FontFile? ReadFile() + { + if (!Typeface.PlatformTypeface.TryGetStream(out var stream) || stream == null) + return null; + using (stream) + { + using var copy = new MemoryStream(); + stream.CopyTo(copy); + try + { + return FontFile.Parse(copy.ToArray(), Typeface.GlyphCount); + } + catch (FormatException) + { + return null; + } + } + } + + private MathTable? ReadMath() + { + if (Table("MATH") is not { } table) + return null; + try + { + return MathTable.Parse(table); + } + catch (FormatException) + { + return null; + } + } + } +} diff --git a/src/SharpEquations.Avalonia/MathDrawing.cs b/src/SharpEquations.Avalonia/MathDrawing.cs new file mode 100644 index 0000000..c4d13f0 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathDrawing.cs @@ -0,0 +1,116 @@ +namespace SharpEquations.Avalonia +{ + using System; + using System.Collections.Generic; + using global::Avalonia; + using global::Avalonia.Media; + using global::Avalonia.Media.Immutable; + using global::Avalonia.Media.TextFormatting; + using SharpEquations.Rendering; + + /// The brushes an equation is drawn with. + internal readonly struct MathBrushes + { + public MathBrushes(IBrush? foreground, IBrush? placeholder, IBrush? error) + { + Foreground = foreground; + Placeholder = placeholder; + Error = error; + } + + public IBrush? Foreground { get; } + + public IBrush? Placeholder { get; } + + public IBrush? Error { get; } + } + + /// A laid out equation with the glyph runs of Avalonia it is drawn with. + internal sealed class MathDrawing : IDisposable + { + private static readonly Dictionary ColorBrushes = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List<(GlyphRun Run, string? Color)> runs = new List<(GlyphRun, string?)>(); + + public MathDrawing(MathLayout layout) + { + Layout = layout; + foreach (var glyph in layout.Glyphs) + { + if (glyph.Font is not AvaloniaMathFont font) + continue; + var info = new[] { new GlyphInfo(glyph.Glyph, 0, font.GetAdvance(glyph.Glyph) * glyph.Size / font.UnitsPerEm) }; + runs.Add((new GlyphRun(font.Typeface, glyph.Size, "".AsMemory(), info, new Point(glyph.X, glyph.Y)), glyph.Color)); + } + } + + public MathLayout Layout { get; } + + public void Draw(DrawingContext context, Point origin, MathBrushes brushes) + { + using (context.PushTransform(Matrix.CreateTranslation(origin.X, origin.Y))) + { + foreach (var shape in Layout.Shapes) + DrawShape(context, shape, brushes); + foreach (var (run, color) in runs) + { + var brush = Brush(color) ?? brushes.Foreground; + if (brush != null) + context.DrawGlyphRun(brush, run); + } + } + } + + private static void DrawShape(DrawingContext context, MathShape shape, MathBrushes brushes) + { + var brush = shape.Role switch + { + MathShapeRole.Placeholder => brushes.Placeholder, + MathShapeRole.Error => brushes.Error, + MathShapeRole.Background => Brush(shape.Color), + _ => Brush(shape.Color) ?? brushes.Foreground, + }; + if (brush == null) + return; + + var rect = new Rect(shape.X, shape.Y, Math.Max(0, shape.Width), Math.Max(0, shape.Height)); + switch (shape.Kind) + { + case MathShapeKind.Fill: + context.FillRectangle(brush, rect); + break; + case MathShapeKind.Outline: + var pen = new Pen(brush, shape.Thickness, shape.Role == MathShapeRole.Placeholder ? new DashStyle(new double[] { 2, 2 }, 0) : null); + context.DrawRectangle(null, pen, rect); + break; + case MathShapeKind.Line: + context.DrawLine(new Pen(brush, shape.Thickness), new Point(shape.X, shape.Y), new Point(shape.X2, shape.Y2)); + break; + case MathShapeKind.Ellipse: + context.DrawEllipse(null, new Pen(brush, shape.Thickness), rect); + break; + } + } + + private static IBrush? Brush(string? color) + { + if (color == null) + return null; + lock (ColorBrushes) + { + if (!ColorBrushes.TryGetValue(color, out var brush)) + { + brush = Color.TryParse(color, out var c) ? new ImmutableSolidColorBrush(c) : null; + ColorBrushes[color] = brush; + } + return brush; + } + } + + public void Dispose() + { + foreach (var (run, _) in runs) + run.Dispose(); + runs.Clear(); + } + } +} diff --git a/src/SharpEquations.Avalonia/MathEditBox.cs b/src/SharpEquations.Avalonia/MathEditBox.cs new file mode 100644 index 0000000..9de4f35 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathEditBox.cs @@ -0,0 +1,582 @@ +namespace SharpEquations.Avalonia +{ + using System; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Documents; + using global::Avalonia.Data; + using global::Avalonia.Input; + using global::Avalonia.Input.Platform; + using global::Avalonia.Input.TextInput; + using global::Avalonia.Media; + using global::Avalonia.Media.Immutable; + using global::Avalonia.Threading; + using SharpEquations.Editing; + using SharpEquations.Rendering; + + /// + /// A real-time equation editor, in the manner of the RichEditBox of WinUI in math mode and of the math zones of Word: + /// UnicodeMath or LaTeX typed at the caret builds up into the equation when a space or an operator ends an argument, + /// and the content is read and written as MathML (, ) or as an + /// . + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001", Justification = "The drawing is released when the equation is laid out again and when the control leaves the visual tree.")] + public class MathEditBox : Control + { + /// The equation edited; null when the editor is empty. Two-way by default. + public static readonly StyledProperty EquationProperty = + AvaloniaProperty.Register(nameof(Equation), defaultBindingMode: BindingMode.TwoWay); + + /// The format of what is typed: UnicodeMath (the default) or LaTeX. + public static readonly StyledProperty InputFormatProperty = + AvaloniaProperty.Register(nameof(InputFormat), EquationFormat.UnicodeMath, validate: f => f is EquationFormat.UnicodeMath or EquationFormat.LaTeX); + + /// Whether the equation is shown built up or as linear text. + public static readonly StyledProperty ViewModeProperty = + AvaloniaProperty.Register(nameof(ViewMode)); + + /// Whether the equation is a display equation rather than inline. + public static readonly StyledProperty IsDisplayProperty = + AvaloniaProperty.Register(nameof(IsDisplay), true); + + /// Whether the user cannot change the equation. + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + /// The size of the font. + public static readonly StyledProperty FontSizeProperty = + TextElement.FontSizeProperty.AddOwner(); + + /// The brush of the equation. + public static readonly StyledProperty ForegroundProperty = + TextElement.ForegroundProperty.AddOwner(); + + /// The brush of the background. + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background), Brushes.Transparent); + + /// The brush of the border. + public static readonly StyledProperty BorderBrushProperty = + AvaloniaProperty.Register(nameof(BorderBrush)); + + /// The thickness of the border. + public static readonly StyledProperty BorderThicknessProperty = + AvaloniaProperty.Register(nameof(BorderThickness), 1); + + /// The space between the border and the equation. + public static readonly StyledProperty PaddingProperty = + AvaloniaProperty.Register(nameof(Padding), new Thickness(6, 4)); + + /// The brush of the selection. + public static readonly StyledProperty SelectionBrushProperty = + AvaloniaProperty.Register(nameof(SelectionBrush), new ImmutableSolidColorBrush(Color.FromArgb(0x60, 0x33, 0x99, 0xFF))); + + /// The brush of the caret; null for the foreground. + public static readonly StyledProperty CaretBrushProperty = + AvaloniaProperty.Register(nameof(CaretBrush)); + + /// The brush of the boxes of the empty slots. + public static readonly StyledProperty PlaceholderBrushProperty = + AvaloniaProperty.Register(nameof(PlaceholderBrush), Brushes.Gray); + + /// The math font, a family with an OpenType MATH table; null for . + public static readonly StyledProperty MathFontFamilyProperty = + AvaloniaProperty.Register(nameof(MathFontFamily)); + + /// The gesture that switches between the built up and the linear view. + public static readonly StyledProperty ToggleViewModeGestureProperty = + AvaloniaProperty.Register(nameof(ToggleViewModeGesture), new KeyGesture(Key.L, KeyModifiers.Control | KeyModifiers.Shift)); + + private readonly DispatcherTimer blink; + private readonly InputClient inputClient; + private MathDrawing? drawing; + private bool caretVisible = true; + private bool selecting; + private bool updatingEquation; + + static MathEditBox() + { + FocusableProperty.OverrideDefaultValue(true); + CursorProperty.OverrideDefaultValue(new Cursor(StandardCursorType.Ibeam)); + AffectsMeasure(FontSizeProperty, MathFontFamilyProperty, PaddingProperty, BorderThicknessProperty); + AffectsRender(ForegroundProperty, BackgroundProperty, BorderBrushProperty, SelectionBrushProperty, CaretBrushProperty, PlaceholderBrushProperty); + } + + /// An empty editor. + public MathEditBox() + { + Editor = new MathEditor { IsDisplay = true }; + Editor.Changed += OnEditorChanged; + Editor.ContentChanged += OnEditorContentChanged; + inputClient = new InputClient(this); + TextInputMethodClientRequested += (_, e) => e.Client = inputClient; + blink = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) }; + blink.Tick += (_, _) => + { + caretVisible = !caretVisible; + InvalidateVisual(); + }; + } + + /// Raised when the equation changes, by an edit or by . + public event EventHandler? EquationChanged; + + /// The editing model: the tree of the equation, the caret and the selection, and the commands. + public MathEditor Editor { get; } + + /// The equation edited, with what is not built up yet built up where it parses; null when empty. + public Equation? Equation + { + get => GetValue(EquationProperty); + set => SetValue(EquationProperty, value); + } + + /// The format of what is typed. + public EquationFormat InputFormat + { + get => GetValue(InputFormatProperty); + set => SetValue(InputFormatProperty, value); + } + + /// Whether the equation is shown built up or as linear text. + public MathViewMode ViewMode + { + get => GetValue(ViewModeProperty); + set => SetValue(ViewModeProperty, value); + } + + /// Whether the equation is a display equation rather than inline. + public bool IsDisplay + { + get => GetValue(IsDisplayProperty); + set => SetValue(IsDisplayProperty, value); + } + + /// Whether the user cannot change the equation. + public bool IsReadOnly + { + get => GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + + /// The size of the font. + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// The brush of the equation. + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// The brush of the background. + public IBrush? Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + /// The brush of the border. + public IBrush? BorderBrush + { + get => GetValue(BorderBrushProperty); + set => SetValue(BorderBrushProperty, value); + } + + /// The thickness of the border. + public double BorderThickness + { + get => GetValue(BorderThicknessProperty); + set => SetValue(BorderThicknessProperty, value); + } + + /// The space between the border and the equation. + public Thickness Padding + { + get => GetValue(PaddingProperty); + set => SetValue(PaddingProperty, value); + } + + /// The brush of the selection. + public IBrush? SelectionBrush + { + get => GetValue(SelectionBrushProperty); + set => SetValue(SelectionBrushProperty, value); + } + + /// The brush of the caret; null for the foreground. + public IBrush? CaretBrush + { + get => GetValue(CaretBrushProperty); + set => SetValue(CaretBrushProperty, value); + } + + /// The brush of the boxes of the empty slots. + public IBrush? PlaceholderBrush + { + get => GetValue(PlaceholderBrushProperty); + set => SetValue(PlaceholderBrushProperty, value); + } + + /// The math font; null for . + public FontFamily? MathFontFamily + { + get => GetValue(MathFontFamilyProperty); + set => SetValue(MathFontFamilyProperty, value); + } + + /// The gesture that switches between the built up and the linear view (Ctrl+Shift+L by default). + public KeyGesture? ToggleViewModeGesture + { + get => GetValue(ToggleViewModeGestureProperty); + set => SetValue(ToggleViewModeGestureProperty, value); + } + + /// The layout of the equation, after a measure. + public MathLayout? Layout => drawing?.Layout; + + private Point Origin + { + get + { + var b = BorderThickness; + return new Point(Padding.Left + b, Padding.Top + b); + } + } + + /// The MathML of the equation, as RichEditTextDocument.GetMath. + public string GetMath() => Editor.ToMathML().ToString(System.Xml.Linq.SaveOptions.DisableFormatting); + + /// Replaces the equation with MathML, as RichEditTextDocument.SetMath. + /// The MathML is not valid. + public void SetMath(string mathML) => Editor.Load(string.IsNullOrWhiteSpace(mathML) ? null : Equation.Parse(mathML, EquationFormat.MathML)); + + /// The equation in a format; for LaTeX, the LaTeX of Word. + public string GetText(EquationFormat format) => Editor.GetText(format); + + /// Builds up all that is typed and not built up yet. + public void BuildUp() => Editor.BuildUpAll(); + + /// Builds down the selection, or the whole equation, into linear text. + public void BuildDown() => Editor.BuildDown(); + + /// Copies the selection to the clipboard: as linear text in the input format, and as MathML. + public async Task CopyAsync() + { + if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard || Editor.GetSelectedEquation() is not { } equation) + return; + var item = new DataTransferItem(); + item.SetText(Editor.InputFormat == EquationFormat.LaTeX + ? equation.ToString(EquationFormat.LaTeX, new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }) + : equation.ToString(EquationFormat.UnicodeMath)); + item.Set(MathMLFormat, equation.ToString(EquationFormat.MathML)); + var data = new DataTransfer(); + data.Add(item); + await clipboard.SetDataAsync(data).ConfigureAwait(true); + } + + /// Copies the selection to the clipboard and deletes it. + public async Task CutAsync() + { + if (IsReadOnly) + return; + await CopyAsync().ConfigureAwait(true); + Editor.DeleteSelection(); + } + + /// Pastes the clipboard: MathML, or text read in the input format (or as MathML), or plain text. + public async Task PasteAsync() + { + if (IsReadOnly || TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) + return; + var formats = await clipboard.GetDataFormatsAsync().ConfigureAwait(true); + if (formats.Contains(MathMLFormat) && await clipboard.TryGetValueAsync(MathMLFormat).ConfigureAwait(true) is { Length: > 0 } mathML + && Editor.Paste(mathML, EquationFormat.MathML)) + { + return; + } + var text = await clipboard.TryGetTextAsync().ConfigureAwait(true); + if (string.IsNullOrEmpty(text)) + return; + if (text!.TrimStart() is { Length: > 0 } trimmed && trimmed[0] == '<' && Equation.TryParse(text, EquationFormat.MathML, out _)) + Editor.Paste(text, EquationFormat.MathML); + else + Editor.Paste(text, Editor.InputFormat); + } + + private static DataFormat MathMLFormat { get; } = + DataFormat.CreateStringPlatformFormat(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MathML" : "application/mathml+xml"); + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == EquationProperty && !updatingEquation) + { + var equation = change.GetNewValue(); + Editor.Load(equation); + if (equation != null) + SetCurrentValue(IsDisplayProperty, equation.IsDisplay); + } + else if (change.Property == InputFormatProperty) + { + Editor.InputFormat = change.GetNewValue(); + } + else if (change.Property == ViewModeProperty) + { + Editor.SetLinear(change.GetNewValue() == MathViewMode.Linear); + } + else if (change.Property == IsDisplayProperty) + { + Editor.IsDisplay = change.GetNewValue(); + } + else if (change.Property == IsFocusedProperty) + { + if (IsFocused) + { + caretVisible = true; + blink.Start(); + } + else + { + blink.Stop(); + } + InvalidateVisual(); + } + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + drawing?.Dispose(); + var layout = MathLayoutEngine.Layout(Editor.Root, MathFonts.Get(MathFontFamily), new MathLayoutOptions { FontSize = FontSize, ShowPlaceholders = true }); + drawing = new MathDrawing(layout); + var b = BorderThickness * 2; + var width = Math.Max(layout.Width, FontSize * 0.6); + var height = Math.Max(layout.Height, FontSize * 1.2); + return new Size(width + Padding.Left + Padding.Right + b, height + Padding.Top + Padding.Bottom + b); + } + + /// + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + var half = BorderThickness / 2; + context.DrawRectangle(Background, BorderBrush != null && BorderThickness > 0 ? new Pen(BorderBrush, BorderThickness) : null, bounds.Deflate(half)); + if (drawing == null) + return; + + var origin = Origin; + var layout = drawing.Layout; + var offsetY = Math.Max(0, (Bounds.Height - Padding.Top - Padding.Bottom - BorderThickness * 2 - layout.Height) / 2); + origin = new Point(origin.X, origin.Y + offsetY); + + if (Editor.Selection is { } selection && SelectionBrush != null) + { + foreach (var rect in layout.GetSelectionRects(selection)) + context.FillRectangle(SelectionBrush, new Rect(rect.X + origin.X, rect.Y + origin.Y, rect.Width, rect.Height)); + } + + drawing.Draw(context, origin, new MathBrushes(Foreground, PlaceholderBrush, Brushes.MistyRose)); + + if (IsFocused && caretVisible && !IsReadOnly && layout.GetCaretRect(Editor.Caret) is { } caret) + { + var brush = CaretBrush ?? Foreground ?? Brushes.Black; + context.FillRectangle(brush, new Rect(caret.X + origin.X - 0.5, caret.Y + origin.Y, 1.2, caret.Height)); + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + blink.Stop(); + drawing?.Dispose(); + drawing = null; + } + + /// + protected override void OnTextInput(TextInputEventArgs e) + { + base.OnTextInput(e); + if (e.Handled || IsReadOnly || string.IsNullOrEmpty(e.Text)) + return; + Editor.Insert(e.Text!); + e.Handled = true; + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Handled) + return; + + var shift = (e.KeyModifiers & KeyModifiers.Shift) != 0; + var hotkeys = Application.Current?.PlatformSettings?.HotkeyConfiguration; + bool Matches(System.Collections.Generic.List? gestures, Key key) => + gestures != null ? gestures.Any(g => g.Matches(e)) : e.Key == key && (e.KeyModifiers & KeyModifiers.Control) != 0; + + e.Handled = true; + if (ToggleViewModeGesture?.Matches(e) == true) + ViewMode = ViewMode == MathViewMode.Linear ? MathViewMode.Professional : MathViewMode.Linear; + else if (Matches(hotkeys?.Copy, Key.C)) + _ = CopyAsync(); + else if (Matches(hotkeys?.Cut, Key.X)) + _ = CutAsync(); + else if (Matches(hotkeys?.Paste, Key.V)) + _ = PasteAsync(); + else if (Matches(hotkeys?.Undo, Key.Z) && !IsReadOnly) + Editor.Undo(); + else if (Matches(hotkeys?.Redo, Key.Y) && !IsReadOnly) + Editor.Redo(); + else if (Matches(hotkeys?.SelectAll, Key.A)) + Editor.SelectAll(); + else + { + switch (e.Key) + { + case Key.Left: + Editor.MoveLeft(shift); + break; + case Key.Right: + Editor.MoveRight(shift); + break; + case Key.Up: + Editor.MoveUp(shift, Layout); + break; + case Key.Down: + Editor.MoveDown(shift, Layout); + break; + case Key.Home: + Editor.MoveHome(shift); + break; + case Key.End: + Editor.MoveEnd(shift); + break; + case Key.Back when !IsReadOnly: + Editor.Backspace(); + break; + case Key.Delete when !IsReadOnly: + Editor.Delete(); + break; + case Key.Enter when !IsReadOnly: + Editor.BuildUpAll(); + break; + default: + e.Handled = false; + break; + } + } + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + Focus(); + if (HitTest(e.GetPosition(this)) is { } position) + Editor.SetCaret(position, (e.KeyModifiers & KeyModifiers.Shift) != 0); + selecting = true; + e.Pointer.Capture(this); + e.Handled = true; + } + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (selecting && HitTest(e.GetPosition(this)) is { } position) + Editor.SetCaret(position, true); + } + + /// + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (selecting) + { + selecting = false; + e.Pointer.Capture(null); + } + } + + private MathPosition? HitTest(Point point) + { + if (drawing == null) + return null; + var origin = Origin; + var offsetY = Math.Max(0, (Bounds.Height - Padding.Top - Padding.Bottom - BorderThickness * 2 - drawing.Layout.Height) / 2); + return drawing.Layout.HitTest(point.X - origin.X, point.Y - origin.Y - offsetY); + } + + private void OnEditorChanged(object? sender, EventArgs e) + { + caretVisible = true; + InvalidateMeasure(); + InvalidateVisual(); + inputClient.RaiseCursorChanged(); + } + + private void OnEditorContentChanged(object? sender, EventArgs e) + { + var equation = Editor.ToEquation(); + updatingEquation = true; + try + { + SetCurrentValue(EquationProperty, equation); + } + finally + { + updatingEquation = false; + } + if (ViewMode == MathViewMode.Linear != Editor.IsLinear) + SetCurrentValue(ViewModeProperty, Editor.IsLinear ? MathViewMode.Linear : MathViewMode.Professional); + EquationChanged?.Invoke(this, new EquationChangedEventArgs(equation)); + } + + /// The client of the input method: where the caret is, so that the window of the candidates follows it. + private sealed class InputClient : TextInputMethodClient + { + private readonly MathEditBox owner; + + public InputClient(MathEditBox owner) => this.owner = owner; + + public override Visual TextViewVisual => owner; + + public override bool SupportsPreedit => false; + + public override bool SupportsSurroundingText => false; + + public override string SurroundingText => string.Empty; + + public override Rect CursorRectangle + { + get + { + if (owner.Layout?.GetCaretRect(owner.Editor.Caret) is not { } caret) + return default; + var origin = owner.Origin; + return new Rect(caret.X + origin.X, caret.Y + origin.Y, 1, caret.Height); + } + } + + public override TextSelection Selection + { + get => default; + set { } + } + + public void RaiseCursorChanged() => RaiseCursorRectangleChanged(); + } + } +} diff --git a/src/SharpEquations.Avalonia/MathFonts.cs b/src/SharpEquations.Avalonia/MathFonts.cs new file mode 100644 index 0000000..4dabdd7 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathFonts.cs @@ -0,0 +1,78 @@ +namespace SharpEquations.Avalonia +{ + using System; + using System.Runtime.InteropServices; + using global::Avalonia.Media; + using global::Avalonia.Media.Fonts; + + /// + /// The math fonts of the controls: Cambria Math on Windows, where it is a font of the system, and Libertinus Math + /// (SIL Open Font License), embedded in this assembly, everywhere else. + /// + public static class MathFonts + { + private const string CollectionKey = "fonts:SharpEquations"; + private static readonly object Gate = new object(); + private static bool registered; + private static AvaloniaMathFont? defaultFont; + + /// The family of the embedded Libertinus Math. + public static FontFamily LibertinusMath + { + get + { + Register(); + return new FontFamily(CollectionKey + "#Libertinus Math"); + } + } + + /// The default math font: Cambria Math on Windows when it is installed, Libertinus Math otherwise. + public static AvaloniaMathFont Default + { + get + { + lock (Gate) + { + if (defaultFont != null) + return defaultFont; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && TryGet(new FontFamily("Cambria Math"), out var cambria)) + defaultFont = cambria; + else if (TryGet(LibertinusMath, out var libertinus)) + defaultFont = libertinus; + else + throw new InvalidOperationException("The embedded Libertinus Math font could not be loaded."); + return defaultFont!; + } + } + } + + /// The math font of a family, if it is installed and has an OpenType MATH table. + public static bool TryGet(FontFamily family, out AvaloniaMathFont? font) + { + font = null; + if (!FontManager.Current.TryGetGlyphTypeface(new Typeface(family), out var glyphs)) + return false; + var candidate = new AvaloniaMathFont(glyphs); + if (candidate.Math == null) + return false; + font = candidate; + return true; + } + + /// The math font of a family, or the default one if the family is null or not a math font. + public static AvaloniaMathFont Get(FontFamily? family) => family != null && TryGet(family, out var font) ? font! : Default; + + private static void Register() + { + lock (Gate) + { + if (registered) + return; + FontManager.Current.AddFontCollection(new EmbeddedFontCollection( + new Uri(CollectionKey, UriKind.Absolute), + new Uri("avares://SharpEquations.Avalonia/Assets/Fonts", UriKind.Absolute))); + registered = true; + } + } + } +} diff --git a/src/SharpEquations.Avalonia/MathView.cs b/src/SharpEquations.Avalonia/MathView.cs new file mode 100644 index 0000000..41d37e6 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathView.cs @@ -0,0 +1,114 @@ +namespace SharpEquations.Avalonia +{ + using System; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Documents; + using global::Avalonia.Media; + using SharpEquations.Rendering; + + /// Shows an equation, laid out after MathML Core with a math font. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001", Justification = "The drawing is released when the equation is laid out again and when the control leaves the visual tree.")] + public class MathView : Control + { + /// The equation shown. + public static readonly StyledProperty EquationProperty = + AvaloniaProperty.Register(nameof(Equation)); + + /// The size of the font. + public static readonly StyledProperty FontSizeProperty = + TextElement.FontSizeProperty.AddOwner(); + + /// The brush of the equation. + public static readonly StyledProperty ForegroundProperty = + TextElement.ForegroundProperty.AddOwner(); + + /// The math font, a family with an OpenType MATH table; null for . + public static readonly StyledProperty MathFontFamilyProperty = + AvaloniaProperty.Register(nameof(MathFontFamily)); + + /// Whether the equation is laid out in display style even if it is inline. + public static readonly StyledProperty IsDisplayProperty = + AvaloniaProperty.Register(nameof(IsDisplay)); + + private MathDrawing? drawing; + + static MathView() + { + AffectsMeasure(EquationProperty, FontSizeProperty, MathFontFamilyProperty, IsDisplayProperty); + AffectsRender(ForegroundProperty); + } + + /// The equation shown. + public Equation? Equation + { + get => GetValue(EquationProperty); + set => SetValue(EquationProperty, value); + } + + /// The MathML of the equation shown; setting it reads the MathML. + /// The MathML is not valid. + public string? MathML + { + get => Equation?.ToString(EquationFormat.MathML); + set => Equation = string.IsNullOrEmpty(value) ? null : Equation.Parse(value!, EquationFormat.MathML); + } + + /// The size of the font. + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// The brush of the equation. + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// The math font; null for . + public FontFamily? MathFontFamily + { + get => GetValue(MathFontFamilyProperty); + set => SetValue(MathFontFamilyProperty, value); + } + + /// Whether the equation is laid out in display style even if it is inline. + public bool IsDisplay + { + get => GetValue(IsDisplayProperty); + set => SetValue(IsDisplayProperty, value); + } + + /// The layout of the equation, after a measure; null without an equation. + public MathLayout? Layout => drawing?.Layout; + + /// + protected override Size MeasureOverride(Size availableSize) + { + drawing?.Dispose(); + drawing = null; + if (Equation is not { } equation) + return default; + var layout = MathLayoutEngine.Layout(equation, MathFonts.Get(MathFontFamily), new MathLayoutOptions { FontSize = FontSize, Display = IsDisplay }); + drawing = new MathDrawing(layout); + return new Size(layout.Width, layout.Height); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + drawing?.Dispose(); + drawing = null; + } + + /// + public override void Render(DrawingContext context) + { + drawing?.Draw(context, default, new MathBrushes(Foreground, null, Brushes.MistyRose)); + } + } +} diff --git a/src/SharpEquations.Avalonia/MathViewMode.cs b/src/SharpEquations.Avalonia/MathViewMode.cs new file mode 100644 index 0000000..030ea1b --- /dev/null +++ b/src/SharpEquations.Avalonia/MathViewMode.cs @@ -0,0 +1,24 @@ +namespace SharpEquations.Avalonia +{ + using System; + + /// How shows the equation. + public enum MathViewMode + { + /// Built up, in two dimensions, as typed text builds up (Word's professional format). + Professional, + + /// As one line of UnicodeMath or LaTeX, edited as text (Word's linear format). + Linear, + } + + /// The arguments of . + public sealed class EquationChangedEventArgs : EventArgs + { + /// Arguments with the new equation. + public EquationChangedEventArgs(Equation? equation) => Equation = equation; + + /// The equation, or null when the editor is empty. + public Equation? Equation { get; } + } +} diff --git a/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj b/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj new file mode 100644 index 0000000..4752691 --- /dev/null +++ b/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj @@ -0,0 +1,34 @@ + + + + + net8.0;net10.0 + SharpEquations.Avalonia + SharpEquations.Avalonia + SharpEquations.Avalonia + A real-time equation editor (MathEditBox) and viewer (MathView) for Avalonia: UnicodeMath or LaTeX typed and built up as in Word, the equation read and written as MathML through SharpEquations. + equation math mathml editor avalonia unicodemath latex richeditbox + enable + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpEquations.Editing/BuildUp.cs b/src/SharpEquations.Editing/BuildUp.cs new file mode 100644 index 0000000..fdb99ca --- /dev/null +++ b/src/SharpEquations.Editing/BuildUp.cs @@ -0,0 +1,292 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + using SharpEquations.Editing.Text; + + /// + /// The build up of linear text into structures, and the build down of structures into linear text, through the + /// parsers and the writers of SharpEquations. + /// + /// + /// + /// A run is built up together with the nodes before it in its row, since the operand of an operator typed in the + /// run can be one of them: (a+b)/c makes a fraction of a bracket built up earlier. Those nodes are written + /// in the linear text as they were typed when they are tokens (a, +, 12) or brackets of + /// tokens, so that the parser reads them as it would have read the text; any other node (a structure, a token with + /// attributes) is written as a placeholder, a character of the Yi syllables that the parsers read as an identifier, + /// and the node is put back in place of the identifier in the result. + /// + /// + internal static class BuildUp + { + private const int PlaceholderFirst = 0xA000; + private const int PlaceholderLast = 0xA48C; + + private static readonly EquationOptions WordLaTeX = new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }; + + public static bool IsPlaceholder(int codePoint) => codePoint >= PlaceholderFirst && codePoint <= PlaceholderLast; + + /// The characters that end an argument and build up what is before them, besides the space. + public static bool IsTrigger(Rune rune, EquationFormat format) + { + if (rune.Value == ' ') + return true; + switch (rune.Value) + { + case '+' or '-' or '=' or '<' or '>' or ',' or ';' or ':' or '!' or '?': + case 0x2212 or 0x00B1 or 0x2213 or 0x00D7 or 0x00B7 or 0x00F7 or 0x2217 or 0x22C5: + case 0x2264 or 0x2265 or 0x2260 or 0x2248 or 0x2261 or 0x2192 or 0x2190 or 0x2194 or 0x21D2 or 0x21D4: + case 0x2208 or 0x2209 or 0x2282 or 0x2283 or 0x2286 or 0x2287 or 0x222A or 0x2229: + return true; + default: + return false; + } + } + + /// The characters after which an argument is still to come: an operator typed after them does not build up. + public static bool ExpectsOperand(Rune rune, EquationFormat format) + { + if (format == EquationFormat.LaTeX) + return rune.Value is '^' or '_' or '\\' or '{' or '(' or '[' or '&'; + return rune.Value is '^' or '_' or '/' or '\\' or '(' or '[' or '{' or '&' or '@' + or 0x252C or 0x2534 or 0x221A or 0x221B or 0x221C or 0x2592 or 0x27E8 or 0x3016 or 0x251C or 0x00A6 or 0x25A0 + or 0x2061 or 0x2044 or 0x2215 or 0x2298; + } + + /// Whether the brackets (UnicodeMath) or the groups and environments (LaTeX) of the text are closed. + public static bool IsBalanced(string text, EquationFormat format) + { + var depth = 0; + var runes = CodePointText.Runes(text); + // UnicodeMath: the text between quotes is one piece of text, spaces included + if (format == EquationFormat.UnicodeMath && runes.Count(r => r.Value == '"') % 2 == 1) + return false; + for (var i = 0; i < runes.Count; i++) + { + var c = runes[i].Value; + if (format == EquationFormat.LaTeX) + { + if (c == '\\') + { + var word = new StringBuilder(); + var j = i + 1; + for (; j < runes.Count && IsAsciiLetter(runes[j].Value); j++) + word.Append((char)runes[j].Value); + switch (word.ToString()) + { + case "begin" or "left": + depth++; + break; + case "end" or "right": + depth--; + break; + case "": + j++; + break; + } + i = j - 1; + continue; + } + depth += c switch { '{' => 1, '}' => -1, _ => 0 }; + } + else + { + depth += c switch + { + '(' or '[' or '{' or 0x27E8 or 0x3016 or 0x251C => 1, + ')' or ']' or '}' or 0x27E9 or 0x3017 or 0x2524 => -1, + _ => 0, + }; + } + if (depth < 0) + depth = 0; + } + return depth == 0; + } + + /// + /// The nodes of the text, read in the format after the nodes before it in its row; null if the text does not + /// parse, or if the result cannot hold the nodes before it as they were. + /// + public static IReadOnlyList? Parse(IReadOnlyList before, string text, EquationFormat format, bool display) + { + if (text.EnumerateRunes().Any(r => IsPlaceholder(r.Value))) + return null; + + var placeholders = new List(); + var sb = new StringBuilder(); + foreach (var node in before) + Append(sb, node, placeholders, format); + // what was built up before ended with a space or an operator: the text starts a new operand + if (sb.Length > 0 && text.Length > 0 && char.IsLetterOrDigit(text, 0)) + sb.Append(' '); + AppendSeparated(sb, text); + + if (!Equation.TryParse(sb.ToString(), format, new EquationOptions { Display = display }, out var equation) || equation == null) + return null; + + var used = new bool[placeholders.Count]; + var nodes = new List(); + foreach (var element in equation.ToMathML().Elements()) + { + var node = Substitute(MathNode.FromMathML(element), placeholders, used); + if (node == null) + return null; + nodes.Add(node); + } + return used.All(u => u) ? nodes : null; + } + + /// The nodes of the row with every run built up where it parses. + public static MathNode BuildAll(MathNode node, EquationFormat format, bool display) + { + if (node.IsRun || node.IsOpaque || node.Children.Count == 0) + return node; + + var children = node.Children.Select(c => BuildAll(c, format, display)).ToList(); + if (node.IsRow) + { + for (var j = 0; j < children.Count; j++) + { + if (!children[j].IsRun) + continue; + var built = Parse(children.Take(j).ToList(), children[j].Text, format, display); + if (built == null) + continue; + var after = children.Skip(j + 1).ToList(); + children = built.Concat(after).ToList(); + j = built.Count - 1; + } + } + return node.WithChildren(children); + } + + /// The equation of nodes, with the runs that do not parse written as merror; null if it is not valid MathML. + public static Equation? ToEquation(IEnumerable nodes, bool display) => + ToEquation(nodes, display ? new[] { new KeyValuePair("display", "block") } : Array.Empty>()); + + /// The equation of nodes in a math element with the attributes (display, dir...). + public static Equation? ToEquation(IEnumerable nodes, IEnumerable> attributes) + { + var math = new XElement(MathMLNamespaces.MathML + "math", nodes.SelectMany(n => n.ToMathML(MathNode.UnbuiltRun))); + foreach (var attribute in attributes) + math.SetAttributeValue(XName.Get(attribute.Key), attribute.Value); + try + { + return Equation.Parse(math, EquationFormat.MathML); + } + catch (FormatException) + { + return null; + } + } + + /// The linear text of an equation in the format: UnicodeMath, or the LaTeX of Word. + public static string ToLinear(Equation equation, EquationFormat format) => + format == EquationFormat.LaTeX ? equation.ToString(EquationFormat.LaTeX, WordLaTeX) : equation.ToString(EquationFormat.UnicodeMath); + + private static void Append(StringBuilder sb, MathNode node, List placeholders, EquationFormat format) + { + if (node.IsRun) + { + AppendSeparated(sb, node.Text); + return; + } + + if (node.IsToken && node.Attributes.Count == 0 && IsLiteral(node, format)) + { + AppendSeparated(sb, node.Text); + return; + } + + if (node.IsRow && node.Attributes.Count == 0 && node.Children.Count >= 2 + && node.Children[0].Name == "mo" && node.Children[node.Children.Count - 1].Name == "mo" + && IsFence(node.Children[0].Text) && IsFence(node.Children[node.Children.Count - 1].Text)) + { + foreach (var child in node.Children) + Append(sb, child, placeholders, format); + return; + } + + if (placeholders.Count > PlaceholderLast - PlaceholderFirst) + throw new InvalidOperationException("Too many structures in a row."); + AppendSeparated(sb, char.ConvertFromUtf32(PlaceholderFirst + placeholders.Count)); + placeholders.Add(node); + } + + private static bool IsLiteral(MathNode token, EquationFormat format) + { + if (token.Text.Length == 0 || token.Name is "mtext" or "ms") + return false; + foreach (var rune in token.Text.EnumerateRunes()) + { + if (IsPlaceholder(rune.Value) || rune.Value < 0x20) + return false; + if (format == EquationFormat.LaTeX && rune.Value is '\\' or '{' or '}' or '^' or '_' or '&' or '%' or '#' or '$' or '~') + return false; + if (format == EquationFormat.UnicodeMath && rune.Value is '\\' or '"') + return false; + } + if (token.Name == "mi" && !CodePointText.IsSingle(token.Text, out _)) + return format == EquationFormat.UnicodeMath && token.Text.All(c => c < 0x80 && char.IsLetter(c)); + return true; + } + + private static bool IsFence(string text) => text is "(" or ")" or "[" or "]" or "{" or "}" or "|" or "‖" or "⟨" or "⟩" or "⌊" or "⌋" or "⌈" or "⌉"; + + /// Appends text, with a space where two letters or digits would otherwise make one token. + private static void AppendSeparated(StringBuilder sb, string text) + { + if (sb.Length > 0 && text.Length > 0 && (IsWordChar(sb[sb.Length - 1]) || sb[sb.Length - 1] is ',' or ';') && IsWordChar(text[0])) + sb.Append(' '); + sb.Append(text); + } + + private static bool IsWordChar(char c) => c < 0x80 && char.IsLetterOrDigit(c) || char.IsSurrogate(c) || c >= PlaceholderFirst && c <= PlaceholderLast; + + private static bool IsAsciiLetter(int c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z'; + + private static MathNode? Substitute(MathNode node, List placeholders, bool[] used) + { + if (node.IsToken) + { + var hasPlaceholder = node.Text.EnumerateRunes().Any(r => IsPlaceholder(r.Value)); + if (!hasPlaceholder) + return node; + if (node.Name != "mi" || !CodePointText.IsSingle(node.Text, out var rune)) + return null; + var index = rune.Value - PlaceholderFirst; + if (index >= placeholders.Count || used[index]) + return null; + used[index] = true; + return placeholders[index]; + } + + if (node.Children.Count == 0) + return node; + + var children = new MathNode[node.Children.Count]; + var changed = false; + for (var i = 0; i < children.Length; i++) + { + var child = Substitute(node.Children[i], placeholders, used); + if (child == null) + return null; + changed |= !ReferenceEquals(child, node.Children[i]); + children[i] = child; + } + + if (node.IsRow) + { + // a placeholder that was a row in the slot of a structure stands alone in the row the slot was given + if (children.Length == 1 && children[0].IsRow && node.Attributes.Count == 0 && !ReferenceEquals(children[0], node.Children[0])) + return children[0]; + } + return changed ? node.WithChildren(children) : node; + } + } +} diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs new file mode 100644 index 0000000..0845d0a --- /dev/null +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -0,0 +1,865 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + using SharpEquations.Editing.Text; + + /// + /// An equation being edited, as in the math zones of Word and the math mode of the RichEditBox of WinUI: the user + /// types UnicodeMath or LaTeX at the caret, and the text builds up into structures when a character that ends an + /// argument is typed (a space, an operator). + /// + /// + /// The equation is a tree of s whose only text not built up is in runs. The editor has no + /// user interface: a control gives it the keys and the text typed, and draws , the + /// and the . + /// + public sealed class MathEditor + { + private readonly Stack undo = new Stack(); + private readonly Stack redo = new Stack(); + private EquationFormat inputFormat = EquationFormat.UnicodeMath; + private MathPosition? typingAt; + + /// An editor of the equation, or of an empty equation. + public MathEditor(Equation? equation = null) + { + Root = Empty(false); + Caret = Anchor = new MathPosition(MathTree.RootRow, 0); + Load(equation); + undo.Clear(); + } + + /// Raised when the equation, the caret or the selection change. + public event EventHandler? Changed; + + /// Raised when the equation changes. + public event EventHandler? ContentChanged; + + /// The math node of the equation; its only child is the row of the equation. + public MathNode Root { get; private set; } + + /// The position of the caret. + public MathPosition Caret { get; private set; } + + /// The other end of the selection; the caret when nothing is selected. + public MathPosition Anchor { get; private set; } + + /// The format of what is typed: or . + /// Another format. + public EquationFormat InputFormat + { + get => inputFormat; + set + { + if (value is not (EquationFormat.UnicodeMath or EquationFormat.LaTeX)) + throw new ArgumentException("The input format is UnicodeMath or LaTeX.", nameof(value)); + inputFormat = value; + } + } + + /// The equation is shown as linear text (one run) rather than built up. + public bool IsLinear { get; private set; } + + /// The equation is a display equation rather than inline. + public bool IsDisplay + { + get => Root.GetAttribute("display") == "block"; + set + { + if (value != IsDisplay) + { + Root = Root.WithAttribute("display", value ? "block" : null); + OnChanged(true); + } + } + } + + /// The selection, or null when nothing is selected. + public MathSelection? Selection => ComputeSelection(Anchor, Caret); + + /// Whether has something to undo. + public bool CanUndo => undo.Count > 0; + + /// Whether has something to redo. + public bool CanRedo => redo.Count > 0; + + /// The equation is empty. + public bool IsEmpty => RootRow.Children.Count == 0; + + private MathNode RootRow => Root.Children[0]; + + /// Replaces the equation, with the caret at its end. + public void Load(Equation? equation) + { + PushUndo(); + Root = equation == null ? Empty(IsDisplay) : FromEquation(equation); + if (IsLinear) + Root = Linearize(Root); + SetCaretAtEnd(); + OnChanged(true); + } + + /// + /// The equation, with the runs built up where they parse and written as merror where they do not; null if + /// it is empty. + /// + public Equation? ToEquation() => IsEmpty ? null : BuildUp.ToEquation(Built().Children[0].Children, Root.Attributes); + + /// The MathML of the equation, as ; an empty math element if it is empty. + public XElement ToMathML() => ToEquation()?.ToMathML() ?? new XElement(MathMLNamespaces.MathML + "math"); + + /// The equation in a format; for LaTeX, the LaTeX of Word. + public string GetText(EquationFormat format) + { + if (IsLinear && format == InputFormat) + return RootRow.Children.Count == 1 && RootRow.Children[0].IsRun ? RootRow.Children[0].Text : string.Empty; + var equation = ToEquation(); + if (equation == null) + return string.Empty; + return format is EquationFormat.UnicodeMath or EquationFormat.LaTeX ? BuildUp.ToLinear(equation, format) : equation.ToString(format); + } + + /// Shows the equation as linear text, or builds it up again. + public void SetLinear(bool linear) + { + if (linear == IsLinear) + return; + PushUndo(); + Root = linear ? Linearize(Root) : Built(); + IsLinear = linear; + SetCaretAtEnd(); + OnChanged(true); + } + + /// Builds up every run of the equation that parses. + public void BuildUpAll() + { + if (IsLinear) + { + SetLinear(false); + return; + } + var built = Built(); + if (ReferenceEquals(built, Root)) + return; + PushUndo(); + Root = built; + SetCaretAtEnd(); + OnChanged(true); + } + + /// Builds down the selection, or the whole equation, into a run of linear text. + public void BuildDown() + { + var selection = Selection; + if (selection == null || selection.IsInRun) + { + SetLinear(true); + return; + } + + var row = MathTree.Get(Root, selection.Path); + var nodes = row.Children.Skip(selection.Start).Take(selection.End - selection.Start).ToList(); + var equation = BuildUp.ToEquation(nodes.Select(n => BuildUp.BuildAll(n, InputFormat, IsDisplay)), IsDisplay); + if (equation == null) + return; + PushUndo(); + var children = row.Children.ToList(); + children.RemoveRange(selection.Start, selection.End - selection.Start); + var run = MathNode.Run(BuildUp.ToLinear(equation, InputFormat)); + children.Insert(selection.Start, run); + ReplaceChildren(selection.Path.ToArray(), children, selection.Start, CodePointText.Length(run.Text)); + OnChanged(true); + } + + /// Types text at the caret, replacing the selection, building up what the text ends. + public void Insert(string text) + { + if (string.IsNullOrEmpty(text)) + return; + + var started = false; + var built = false; + foreach (var rune in text.EnumerateRunes()) + { + if (rune.Value is '\r' or '\n' or '\t') + { + if (!IsLinear) + BuildUpAll(); + continue; + } + if (!started) + { + if (typingAt == null || typingAt != Caret || Selection != null) + PushUndo(); + started = true; + } + if (Selection != null) + RemoveSelection(); + InsertRune(rune); + if (!IsLinear && BuildUp.IsTrigger(rune, InputFormat)) + built |= TryBuildUpAtCaret(rune.Value == ' '); + } + typingAt = built ? null : Caret; + OnChanged(true); + } + + /// Inserts nodes at the caret, replacing the selection. + public void InsertNodes(IEnumerable nodes) + { + var list = nodes.ToList(); + if (list.Count == 0) + return; + PushUndo(); + if (Selection != null) + RemoveSelection(); + + var (rowPath, index) = SplitRunAtCaret(); + var row = MathTree.Get(Root, rowPath); + var children = row.Children.ToList(); + children.InsertRange(index, list); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + SetCaret(rowPath, index + list.Count); + OnChanged(true); + } + + /// + /// Pastes text: an equation in the format inserted as nodes, or the text as a run if it does not parse. Returns + /// whether the text was read as an equation. + /// + public bool Paste(string text, EquationFormat format) + { + if (string.IsNullOrEmpty(text)) + return false; + if (Equation.TryParse(text, format, new EquationOptions { Display = IsDisplay }, out var equation) && equation != null) + { + InsertNodes(FromEquation(equation).Children[0].Children); + return true; + } + + PushUndo(); + if (Selection != null) + RemoveSelection(); + foreach (var rune in text.EnumerateRunes()) + InsertRune(rune); + OnChanged(true); + return false; + } + + /// The selected nodes, with a selection in a run as a run of the selected text. + public IReadOnlyList GetSelectedNodes() + { + var selection = Selection; + if (selection == null) + return Array.Empty(); + var node = MathTree.Get(Root, selection.Path); + if (selection.IsInRun) + return new[] { MathNode.Run(CodePointText.Substring(node.Text, selection.Start, selection.End - selection.Start)) }; + return node.Children.Skip(selection.Start).Take(selection.End - selection.Start).ToList(); + } + + /// The selection as an equation, or null when nothing is selected. + public Equation? GetSelectedEquation() + { + var nodes = GetSelectedNodes(); + if (nodes.Count == 0) + return null; + var row = BuildUp.BuildAll(MathNode.Row(nodes), InputFormat, IsDisplay); + return BuildUp.ToEquation(row.Children, IsDisplay); + } + + /// Deletes the selection; returns whether something was selected. + public bool DeleteSelection() + { + if (Selection == null) + return false; + PushUndo(); + RemoveSelection(); + OnChanged(true); + return true; + } + + /// + /// Deletes the code point or the token before the caret; a structure before the caret is selected first, and + /// deleted the second time, as in Word. + /// + public void Backspace() => DeleteAt(-1); + + /// Deletes the code point or the token after the caret, as forward. + public void Delete() => DeleteAt(1); + + /// Moves the caret one position to the left, into the structures it meets; with extend, selects. + public void MoveLeft(bool extend = false) => MoveHorizontal(-1, extend); + + /// Moves the caret one position to the right, into the structures it meets; with extend, selects. + public void MoveRight(bool extend = false) => MoveHorizontal(1, extend); + + /// Moves the caret to the slot above (the numerator, the superscript, the row above in a table). + public void MoveUp(bool extend = false, IMathGeometry? geometry = null) => MoveVertical(-1, extend, geometry); + + /// Moves the caret to the slot below (the denominator, the subscript, the row below in a table). + public void MoveDown(bool extend = false, IMathGeometry? geometry = null) => MoveVertical(1, extend, geometry); + + /// Moves the caret to the start of its row, or of the equation if it is there already. + public void MoveHome(bool extend = false) + { + var (row, index) = RowPosition(Caret); + var target = index == 0 ? MathTree.RootRow : row; + Move(Canonical(new MathPosition(target, 0)), extend); + } + + /// Moves the caret to the end of its row, or of the equation if it is there already. + public void MoveEnd(bool extend = false) + { + var (row, index) = RowPosition(Caret); + var count = MathTree.Get(Root, row).Children.Count; + var target = index == count ? MathTree.RootRow : row; + Move(Canonical(new MathPosition(target, MathTree.Get(Root, target).Children.Count)), extend); + } + + /// Selects the whole equation. + public void SelectAll() + { + Anchor = new MathPosition(MathTree.RootRow, 0); + Caret = new MathPosition(MathTree.RootRow, RootRow.Children.Count); + typingAt = null; + OnChanged(false); + } + + /// Moves the caret to a position (one the renderer found under the pointer); with extend, selects. + /// The position is not in the equation. + public void SetCaret(MathPosition position, bool extend = false) + { + if (!IsValid(position)) + throw new ArgumentException("The position is not in the equation.", nameof(position)); + Move(Canonical(position), extend); + } + + /// Undoes the last edit. + public void Undo() + { + if (undo.Count == 0) + return; + redo.Push(Snapshot()); + Restore(undo.Pop()); + } + + /// Redoes the last edit undone. + public void Redo() + { + if (redo.Count == 0) + return; + undo.Push(Snapshot()); + Restore(redo.Pop()); + } + + private static MathNode Empty(bool display) => MathNode.Element( + "math", + new[] { MathNode.Row(Array.Empty()) }, + display ? new[] { new KeyValuePair("display", "block") } : null); + + private static MathNode FromEquation(Equation equation) => MathNode.FromMathML(equation.ToMathML()); + + private MathNode Built() => BuildUp.BuildAll(Root, InputFormat, IsDisplay); + + private MathNode Linearize(MathNode root) + { + var row = root.Children[0]; + if (row.Children.Count == 0) + return root; + var built = BuildUp.BuildAll(row, InputFormat, IsDisplay); + var equation = BuildUp.ToEquation(built.Children, root.Attributes); + var text = equation == null ? string.Concat(row.Children.Select(c => c.IsRun ? c.Text : string.Empty)) : BuildUp.ToLinear(equation, InputFormat); + return root.WithChild(0, MathNode.Row(text.Length == 0 ? Array.Empty() : new[] { MathNode.Run(text) })); + } + + private void InsertRune(Rune rune) + { + var caret = Canonical(Caret); + var node = MathTree.Get(Root, caret.Path); + if (node.IsRun) + { + Root = MathTree.Replace(Root, caret.Path, node.WithText(CodePointText.Insert(node.Text, caret.Offset, rune.ToString()))); + Caret = Anchor = new MathPosition(caret.Path, caret.Offset + 1); + } + else + { + var children = node.Children.ToList(); + children.Insert(caret.Offset, MathNode.Run(rune.ToString())); + Root = MathTree.Replace(Root, caret.Path, node.WithChildren(children)); + Caret = Anchor = new MathPosition(MathTree.Append(caret.Path, caret.Offset), 1); + } + } + + /// Builds up the argument before the caret, in the run of the caret, after a trigger was typed. + private bool TryBuildUpAtCaret(bool space) + { + var caret = Caret; + var run = MathTree.Get(Root, caret.Path); + if (!run.IsRun) + return false; + + var rowPath = MathTree.Parent(caret.Path); + var j = caret.Path[caret.Path.Count - 1]; + var row = MathTree.Get(Root, rowPath); + var runes = CodePointText.Runes(run.Text); + var before = runes.Take(caret.Offset).ToList(); + var after = CodePointText.Join(runes.Skip(caret.Offset)); + var argument = space ? before.Take(before.Count - 1).ToList() : before; + + if (argument.All(r => r.Value == ' ')) + { + if (space) + { + // a space alone builds nothing, and is not kept + var rest = CodePointText.Join(argument) + after; + ReplaceRun(rowPath, j, rest, argument.Count); + } + return false; + } + + var last = space ? argument[argument.Count - 1] : argument.Count >= 2 ? argument[argument.Count - 2] : (Rune?)null; + if (last is { } l && BuildUp.ExpectsOperand(l, InputFormat)) + return false; + var text = CodePointText.Join(argument); + if (!BuildUp.IsBalanced(text, InputFormat)) + return false; + + var nodes = BuildUp.Parse(row.Children.Take(j).ToList(), text, InputFormat, IsDisplay); + if (nodes == null) + return false; + + undo.Push(Snapshot()); + redo.Clear(); + var children = nodes.ToList(); + if (after.Length > 0) + children.Add(MathNode.Run(after)); + children.AddRange(row.Children.Skip(j + 1)); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + Caret = Anchor = Canonical(new MathPosition(rowPath, nodes.Count)); + return true; + } + + private void ReplaceRun(int[] rowPath, int index, string text, int caretOffset) + { + var row = MathTree.Get(Root, rowPath); + var children = row.Children.ToList(); + if (text.Length == 0) + { + children.RemoveAt(index); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + Caret = Anchor = Canonical(new MathPosition(rowPath, index)); + } + else + { + children[index] = MathNode.Run(text); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + Caret = Anchor = new MathPosition(MathTree.Append(rowPath, index), caretOffset); + } + } + + private void DeleteAt(int direction) + { + if (DeleteSelection()) + return; + + var caret = Canonical(Caret); + var node = MathTree.Get(Root, caret.Path); + if (node.IsRun) + { + var length = CodePointText.Length(node.Text); + var at = direction < 0 ? caret.Offset - 1 : caret.Offset; + if (at >= 0 && at < length) + { + PushUndo(); + ReplaceRun(MathTree.Parent(caret.Path), caret.Path[caret.Path.Count - 1], CodePointText.Remove(node.Text, at, 1), at); + OnChanged(true); + return; + } + } + + var (rowPath, index) = RowPosition(caret); + var row = MathTree.Get(Root, rowPath); + var target = direction < 0 ? index - 1 : index; + if (target < 0 || target >= row.Children.Count) + { + // at the edge of a slot: select the structure it belongs to + if (MathTree.Owner(Root, rowPath) is { } owner) + { + var parent = MathTree.Parent(owner.Item); + var item = owner.Item[owner.Item.Length - 1]; + Anchor = new MathPosition(parent, item); + Caret = new MathPosition(parent, item + 1); + typingAt = null; + OnChanged(false); + } + return; + } + + var child = row.Children[target]; + if (child.IsToken || child.IsRun) + { + PushUndo(); + var children = row.Children.ToList(); + var length = CodePointText.Length(child.Text); + var rest = direction < 0 ? CodePointText.Substring(child.Text, 0, Math.Max(0, length - 1)) : CodePointText.Substring(child.Text, Math.Min(1, length)); + if (rest.Length == 0) + { + children.RemoveAt(target); + ReplaceChildren(rowPath, children, target); + } + else + { + children[target] = MathNode.Run(rest); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + var runPath = MathTree.Append(rowPath, target); + SetCaret(runPath, direction < 0 ? CodePointText.Length(rest) : 0); + } + OnChanged(true); + } + else if (child.Children.Count == 0 && !child.IsOpaque) + { + PushUndo(); + var children = row.Children.ToList(); + children.RemoveAt(target); + ReplaceChildren(rowPath, children, target); + OnChanged(true); + } + else + { + // a structure is selected first, and deleted the second time + Anchor = new MathPosition(rowPath, target); + Caret = new MathPosition(rowPath, target + 1); + typingAt = null; + OnChanged(false); + } + } + + private void RemoveSelection() + { + var selection = Selection; + if (selection == null) + return; + + var node = MathTree.Get(Root, selection.Path); + if (selection.IsInRun) + { + var text = CodePointText.Remove(node.Text, selection.Start, selection.End - selection.Start); + ReplaceRun(MathTree.Parent(selection.Path), selection.Path[selection.Path.Count - 1], text, selection.Start); + return; + } + + var children = node.Children.ToList(); + children.RemoveRange(selection.Start, selection.End - selection.Start); + ReplaceChildren(selection.Path.ToArray(), children, selection.Start); + } + + /// + /// Merges the runs that are next to each other, keeping track of a position among the children: before the + /// child at the index, or in the run at the index when the offset is not null. + /// + private static List MergeRuns(List children, ref int index, ref int? offset) + { + for (var i = children.Count - 1; i >= 1; i--) + { + if (!children[i - 1].IsRun || !children[i].IsRun) + continue; + var length = CodePointText.Length(children[i - 1].Text); + children[i - 1] = MathNode.Run(children[i - 1].Text + children[i].Text); + children.RemoveAt(i); + if (index == i) + { + index = i - 1; + offset = (offset ?? 0) + length; + } + else if (index > i) + { + index--; + } + } + return children; + } + + private void ReplaceChildren(int[] rowPath, List children, int index, int? offset = null) + { + var row = MathTree.Get(Root, rowPath); + children = MergeRuns(children, ref index, ref offset); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + SetCaret(offset is { } o ? MathTree.Append(rowPath, index) : rowPath, offset ?? index); + } + + /// Splits the run of the caret there, and gives the row and the index where nodes go in. + private (int[] RowPath, int Index) SplitRunAtCaret() + { + var caret = Canonical(Caret); + var node = MathTree.Get(Root, caret.Path); + if (!node.IsRun) + return (caret.Path.ToArray(), caret.Offset); + + var rowPath = MathTree.Parent(caret.Path); + var j = caret.Path[caret.Path.Count - 1]; + var length = CodePointText.Length(node.Text); + if (caret.Offset == 0) + return (rowPath, j); + if (caret.Offset == length) + return (rowPath, j + 1); + + var row = MathTree.Get(Root, rowPath); + var children = row.Children.ToList(); + children[j] = MathNode.Run(CodePointText.Substring(node.Text, 0, caret.Offset)); + children.Insert(j + 1, MathNode.Run(CodePointText.Substring(node.Text, caret.Offset))); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + return (rowPath, j + 1); + } + + private void MoveHorizontal(int direction, bool extend) + { + var selection = Selection; + if (!extend && selection != null) + { + Move(Canonical(direction < 0 ? selection.StartPosition : selection.EndPosition), false); + return; + } + + var next = Step(Canonical(Caret), direction, extend); + if (next != null) + Move(next, extend); + } + + /// The position after one step to the left or the right; with atomic, structures are stepped over. + private MathPosition? Step(MathPosition caret, int direction, bool atomic) + { + var node = MathTree.Get(Root, caret.Path); + if (node.IsRun) + { + var length = CodePointText.Length(node.Text); + var offset = caret.Offset + direction; + if (offset > 0 && offset < length) + return new MathPosition(caret.Path, offset); + var rowPath = MathTree.Parent(caret.Path); + var j = caret.Path[caret.Path.Count - 1]; + if (offset <= 0) + return offset == 0 ? Canonical(new MathPosition(rowPath, j)) : Step(new MathPosition(rowPath, j), direction, atomic); + return offset == length ? Canonical(new MathPosition(rowPath, j + 1)) : Step(new MathPosition(rowPath, j + 1), direction, atomic); + } + + var row = node; + var index = caret.Offset; + var target = direction < 0 ? index - 1 : index; + if (target >= 0 && target < row.Children.Count) + { + var child = row.Children[target]; + var childPath = MathTree.Append(caret.Path, target); + if (child.IsRun) + { + var length = CodePointText.Length(child.Text); + if (length > 1) + return new MathPosition(childPath, direction < 0 ? length - 1 : 1); + return Canonical(new MathPosition(caret.Path, index + direction)); + } + + var slots = atomic ? Array.Empty() : MathSchema.Slots(child); + if (slots.Count > 0) + { + var slot = MathTree.Append(childPath, direction < 0 ? slots[slots.Count - 1] : slots[0]); + var slotRow = MathTree.Get(Root, slot); + return Canonical(new MathPosition(slot, direction < 0 ? slotRow.Children.Count : 0)); + } + return Canonical(new MathPosition(caret.Path, index + direction)); + } + + // at the edge of the row: to the next slot of the structure, or out of it + if (MathTree.Owner(Root, caret.Path) is not { } owner) + return null; + var nextSlot = owner.Index + direction; + if (!atomic && nextSlot >= 0 && nextSlot < owner.Slots.Length) + { + var slot = owner.Slots[nextSlot]; + return Canonical(new MathPosition(slot, direction < 0 ? MathTree.Get(Root, slot).Children.Count : 0)); + } + var parent = MathTree.Parent(owner.Item); + var item = owner.Item[owner.Item.Length - 1]; + return Canonical(new MathPosition(parent, direction < 0 ? item : item + 1)); + } + + private void MoveVertical(int direction, bool extend, IMathGeometry? geometry) + { + var caret = Canonical(Caret); + var (rowPath, _) = RowPosition(caret); + int[]? target = null; + var current = rowPath; + while (target == null) + { + if (MathTree.Owner(Root, current) is not { } owner) + return; + var relative = current.Skip(owner.Item.Length).ToArray(); + if (MathSchema.Vertical(MathTree.Get(Root, owner.Item), relative, direction) is { } slot) + { + target = MathTree.Append(owner.Item, slot); + break; + } + current = MathTree.Parent(owner.Item); + } + + MathPosition? position = null; + if (geometry != null && geometry.GetCaretX(caret) is { } x) + position = geometry.GetNearestPosition(target, x); + position ??= new MathPosition(target, MathTree.Get(Root, target).Children.Count); + if (IsValid(position)) + Move(Canonical(position), extend); + } + + private void Move(MathPosition position, bool extend) + { + Caret = position; + if (!extend) + Anchor = position; + typingAt = null; + OnChanged(false); + } + + private void SetCaret(IReadOnlyList path, int offset) + { + Caret = Anchor = Canonical(new MathPosition(path, offset)); + } + + private void SetCaretAtEnd() => SetCaret(MathTree.RootRow, RootRow.Children.Count); + + private bool IsValid(MathPosition position) + { + if (!MathTree.TryGet(Root, position.Path, out var node)) + return false; + if (node.IsRun) + return position.Offset <= CodePointText.Length(node.Text); + return node.IsRow && position.Offset <= node.Children.Count; + } + + /// The position, inside the run next to it if there is one: a position between a run and its neighbour is the end or the start of the run. + internal MathPosition Canonical(MathPosition position) + { + var node = MathTree.Get(Root, position.Path); + if (!node.IsRow) + return position; + var i = position.Offset; + if (i > 0 && node.Children[i - 1].IsRun) + return new MathPosition(MathTree.Append(position.Path, i - 1), CodePointText.Length(node.Children[i - 1].Text)); + if (i < node.Children.Count && node.Children[i].IsRun) + return new MathPosition(MathTree.Append(position.Path, i), 0); + return position; + } + + /// The row of a position and the index of the child after it; inside a run, the index of the run. + private (int[] Row, int Index) RowPosition(MathPosition position) + { + var node = MathTree.Get(Root, position.Path); + if (!node.IsRun) + return (position.Path.ToArray(), position.Offset); + var j = position.Path[position.Path.Count - 1]; + var row = MathTree.Parent(position.Path); + if (position.Offset == 0) + return (row, j); + return (row, position.Offset >= CodePointText.Length(node.Text) ? j + 1 : j); + } + + private MathSelection? ComputeSelection(MathPosition anchor, MathPosition caret) + { + if (anchor == caret) + return null; + + var anchorNode = MathTree.Get(Root, anchor.Path); + if (anchorNode.IsRun && anchor.Path.SequenceEqual(caret.Path)) + return new MathSelection(anchor.Path.ToArray(), Math.Min(anchor.Offset, caret.Offset), Math.Max(anchor.Offset, caret.Offset), true); + + var a = Chain(anchor); + var c = Chain(caret); + for (var i = Math.Min(a.Count, c.Count) - 1; i >= 0; i--) + { + if (!a[i].Row.SequenceEqual(c[i].Row)) + continue; + var start = Math.Min(a[i].Start, c[i].Start); + var end = Math.Max(a[i].End, c[i].End); + return start == end ? null : new MathSelection(a[i].Row, start, end, false); + } + return null; + } + + /// For each row from the root down to the position, the range of its children the position is in. + private List<(int[] Row, int Start, int End)> Chain(MathPosition position) + { + var node = MathTree.Get(Root, position.Path); + int[] rowPath; + int start, end; + if (node.IsRun) + { + rowPath = MathTree.Parent(position.Path); + var j = position.Path[position.Path.Count - 1]; + var length = CodePointText.Length(node.Text); + (start, end) = position.Offset == 0 ? (j, j) : position.Offset >= length ? (j + 1, j + 1) : (j, j + 1); + } + else + { + rowPath = position.Path.ToArray(); + start = end = position.Offset; + } + + var chain = new List<(int[] Row, int Start, int End)>(); + for (var depth = 0; depth < rowPath.Length; depth++) + { + var prefix = rowPath.Take(depth).ToArray(); + if (MathTree.Get(Root, prefix).IsRow) + chain.Add((prefix, rowPath[depth], rowPath[depth] + 1)); + } + chain.Add((rowPath, start, end)); + return chain; + } + + private State Snapshot() => new State(Root, Caret, Anchor, IsLinear); + + private void PushUndo() + { + undo.Push(Snapshot()); + redo.Clear(); + typingAt = null; + } + + private void Restore(State state) + { + Root = state.Root; + Caret = state.Caret; + Anchor = state.Anchor; + IsLinear = state.IsLinear; + typingAt = null; + OnChanged(true); + } + + private void OnChanged(bool content) + { + if (content) + ContentChanged?.Invoke(this, EventArgs.Empty); + Changed?.Invoke(this, EventArgs.Empty); + } + + private sealed class State + { + public State(MathNode root, MathPosition caret, MathPosition anchor, bool linear) + { + Root = root; + Caret = caret; + Anchor = anchor; + IsLinear = linear; + } + + public MathNode Root { get; } + + public MathPosition Caret { get; } + + public MathPosition Anchor { get; } + + public bool IsLinear { get; } + } + } +} diff --git a/src/SharpEquations.Editing/MathNode.cs b/src/SharpEquations.Editing/MathNode.cs new file mode 100644 index 0000000..91065af --- /dev/null +++ b/src/SharpEquations.Editing/MathNode.cs @@ -0,0 +1,219 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Xml.Linq; + + /// + /// A node of an equation being edited: an element of presentation MathML, or a run of linear text that is + /// not built up yet. Nodes are immutable: an edit gives a new tree, which shares the nodes it did not change. + /// + /// + /// Every slot of a structure (the numerator of a fraction, the base and the scripts of a script, a cell of a table) + /// is a row, mrow, even where MathML has a single element or an inferred row, so that the caret always has a + /// row to stand in. adds these rows and writes them back as + /// mrow, which the normalization of an removes where they group nothing. + /// + public sealed class MathNode + { + /// The of a run of linear text. + public const string RunName = "#run"; + + private static readonly IReadOnlyList NoChildren = Array.Empty(); + private static readonly IReadOnlyList> NoAttributes = Array.Empty>(); + + private readonly XElement? source; + + private MathNode(string name, IReadOnlyList>? attributes, string text, IReadOnlyList? children, XElement? source = null) + { + Name = name; + Attributes = attributes ?? NoAttributes; + Text = text; + Children = children ?? NoChildren; + this.source = source; + } + + /// The local name of the MathML element (mi, mfrac...), or . + public string Name { get; } + + /// The attributes of the element, by the of each written as a string. + public IReadOnlyList> Attributes { get; } + + /// The text of a token element (mi, mn, mo, mtext, ms) or of a run. + public string Text { get; } + + /// The children; those of a structure that the caret can enter are rows. + public IReadOnlyList Children { get; } + + /// A run of linear text, not built up yet. + public bool IsRun => Name == RunName; + + /// A row, mrow: a sequence of nodes, where the caret stands. + public bool IsRow => Name == "mrow"; + + /// A token element: mi, mn, mo, mtext or ms. + public bool IsToken => MathSchema.IsToken(Name); + + /// An element kept as it was read, which the caret does not enter (semantics, mstack...). + public bool IsOpaque => source != null; + + /// The MathML of an node, as it was read. + public XElement? Source => source == null ? null : new XElement(source); + + /// A new run of linear text. + public static MathNode Run(string text) => new MathNode(RunName, null, text ?? throw new ArgumentNullException(nameof(text)), null); + + /// A new row of the nodes. + public static MathNode Row(IEnumerable children, IReadOnlyList>? attributes = null) => + new MathNode("mrow", attributes, string.Empty, children.ToArray()); + + /// A new token element. + public static MathNode Token(string name, string text, IReadOnlyList>? attributes = null) => + new MathNode(name, attributes, text, null); + + /// A new element with children (a structure, a leaf such as mspace, or math). + public static MathNode Element(string name, IEnumerable children, IReadOnlyList>? attributes = null) => + new MathNode(name, attributes, string.Empty, children.ToArray()); + + /// The value of an attribute without namespace, or null. + public string? GetAttribute(string name) + { + foreach (var attribute in Attributes) + { + if (attribute.Key == name) + return attribute.Value; + } + return null; + } + + /// A copy with other children. + public MathNode WithChildren(IEnumerable children) => new MathNode(Name, Attributes, Text, children.ToArray(), source); + + /// A copy with one child replaced. + public MathNode WithChild(int index, MathNode child) + { + var children = Children.ToArray(); + children[index] = child; + return new MathNode(Name, Attributes, Text, children, source); + } + + /// A copy with another text. + public MathNode WithText(string text) => new MathNode(Name, Attributes, text, Children, source); + + /// A copy with an attribute set, or removed when the value is null. + public MathNode WithAttribute(string name, string? value) + { + var attributes = Attributes.Where(a => a.Key != name).ToList(); + if (value != null) + attributes.Add(new KeyValuePair(name, value)); + return new MathNode(Name, attributes, Text, Children, source); + } + + /// The nodes of a MathML element, with a row in every slot. + public static MathNode FromMathML(XElement element) + { + if (element == null) + throw new ArgumentNullException(nameof(element)); + + var name = element.Name.LocalName; + var attributes = ReadAttributes(element); + switch (MathSchema.KindOf(name)) + { + case ElementKind.Token: + return new MathNode(name, attributes, element.Value, null); + case ElementKind.Leaf: + return new MathNode(name, attributes, string.Empty, null); + case ElementKind.Row: + return new MathNode(name, attributes, string.Empty, element.Elements().Select(FromMathML).ToArray()); + case ElementKind.InferredRow: + // the children in a row of their own, even a single mrow, which groups them (a bracket) + return new MathNode(name, attributes, string.Empty, new[] { Row(element.Elements().Select(FromMathML)) }); + case ElementKind.Fixed: + return new MathNode(name, attributes, string.Empty, element.Elements().Select(Slot).ToArray()); + case ElementKind.Table: + return new MathNode(name, attributes, string.Empty, element.Elements().Select(FromMathML).ToArray()); + default: + return new MathNode(name, attributes, element.Value, null, new XElement(element)); + } + + static MathNode Slot(XElement child) => child.Name.LocalName switch + { + "mprescripts" => new MathNode("mprescripts", null, string.Empty, null), + "none" => Row(Array.Empty()), + _ when IsPlainRow(child) => FromMathML(child), + _ => Row(new[] { FromMathML(child) }), + }; + } + + /// The MathML of the node; a run is written as merror with its text. + public XElement ToMathML() => ToMathML(UnbuiltRun).Single(); + + internal static IEnumerable UnbuiltRun(MathNode run) + { + yield return new XElement(MathMLNamespaces.MathML + "merror", new XElement(MathMLNamespaces.MathML + "mtext", run.Text)); + } + + internal IEnumerable ToMathML(Func> runs) + { + var ns = MathMLNamespaces.MathML; + if (IsRun) + { + foreach (var element in runs(this)) + yield return element; + yield break; + } + + if (source != null) + { + yield return new XElement(source); + yield break; + } + + var result = new XElement(ns + Name, Attributes.Select(a => new XAttribute(XName.Get(a.Key), a.Value))); + switch (MathSchema.KindOf(Name)) + { + case ElementKind.Token: + result.Add(Text); + break; + case ElementKind.Leaf: + break; + case ElementKind.InferredRow when Children.Count == 1 && Children[0].IsRow && Children[0].Attributes.Count == 0: + result.Add(Children[0].Children.SelectMany(c => c.ToMathML(runs))); + break; + case ElementKind.Fixed: + foreach (var child in Children) + { + if (Name == "mmultiscripts" && child.IsRow && child.Children.Count == 0 && child.Attributes.Count == 0) + result.Add(new XElement(ns + "none")); + else if (child.IsRow && child.Attributes.Count == 0 && child.Children.Count == 1 && !child.Children[0].IsRun) + result.Add(child.Children[0].ToMathML(runs)); + else + result.Add(child.ToMathML(runs)); + } + break; + default: + result.Add(Children.SelectMany(c => c.ToMathML(runs))); + break; + } + yield return result; + } + + /// + public override string ToString() => IsRun ? "«" + Text + "»" : ToMathML().ToString(SaveOptions.DisableFormatting); + + private static bool IsPlainRow(XElement element) => element.Name.LocalName == "mrow" && !element.Attributes().Any(a => !a.IsNamespaceDeclaration); + + private static List>? ReadAttributes(XElement element) + { + List>? list = null; + foreach (var attribute in element.Attributes()) + { + if (attribute.IsNamespaceDeclaration) + continue; + (list ??= new List>()).Add(new KeyValuePair(attribute.Name.ToString(), attribute.Value)); + } + return list; + } + } +} diff --git a/src/SharpEquations.Editing/MathPosition.cs b/src/SharpEquations.Editing/MathPosition.cs new file mode 100644 index 0000000..816b5a8 --- /dev/null +++ b/src/SharpEquations.Editing/MathPosition.cs @@ -0,0 +1,100 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// A position of the caret: the path of a row (offset between two of its children) or of a run (offset between two + /// code points of its text), as the indexes of the children from the math element down. + /// + public sealed class MathPosition : IEquatable + { + private readonly int[] path; + + /// A position in the node at the path. + public MathPosition(IEnumerable path, int offset) + { + this.path = (path ?? throw new ArgumentNullException(nameof(path))).ToArray(); + if (offset < 0) + throw new ArgumentOutOfRangeException(nameof(offset)); + Offset = offset; + } + + /// The path of the row or the run, from the math element. + public IReadOnlyList Path => path; + + /// The index of the child after the caret in a row, or the code point after the caret in a run. + public int Offset { get; } + + /// + public bool Equals(MathPosition? other) => other is not null && Offset == other.Offset && path.SequenceEqual(other.path); + + /// + public override bool Equals(object? obj) => Equals(obj as MathPosition); + + /// + public override int GetHashCode() + { + var hash = Offset; + foreach (var index in path) + hash = hash * 31 + index; + return hash; + } + + /// + public override string ToString() => "/" + string.Join("/", path) + ":" + Offset; + + /// Equality of the positions. + public static bool operator ==(MathPosition? left, MathPosition? right) => left is null ? right is null : left.Equals(right); + + /// Inequality of the positions. + public static bool operator !=(MathPosition? left, MathPosition? right) => !(left == right); + } + + /// + /// A selection: a range of the children of a row, or of the code points of a run. A selection that leaves a row + /// covers the whole structure the row belongs to. + /// + public sealed class MathSelection + { + internal MathSelection(IReadOnlyList path, int start, int end, bool inRun) + { + Path = path; + Start = start; + End = end; + IsInRun = inRun; + } + + /// The path of the row or the run. + public IReadOnlyList Path { get; } + + /// The first child or code point selected. + public int Start { get; } + + /// The child or code point after the last selected. + public int End { get; } + + /// A range of the text of a run rather than of the children of a row. + public bool IsInRun { get; } + + /// The position before the selection. + public MathPosition StartPosition => new MathPosition(Path, Start); + + /// The position after the selection. + public MathPosition EndPosition => new MathPosition(Path, End); + } + + /// + /// The geometry of a laid out equation, which the editor asks to move the caret up and down to the nearest + /// position; the renderer implements it. + /// + public interface IMathGeometry + { + /// The horizontal coordinate of the caret at the position, or null if the position is not laid out. + double? GetCaretX(MathPosition position); + + /// The position in the row (or in a run of it) nearest to the horizontal coordinate. + MathPosition? GetNearestPosition(IReadOnlyList rowPath, double x); + } +} diff --git a/src/SharpEquations.Editing/MathSchema.cs b/src/SharpEquations.Editing/MathSchema.cs new file mode 100644 index 0000000..13f8ef1 --- /dev/null +++ b/src/SharpEquations.Editing/MathSchema.cs @@ -0,0 +1,131 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + + internal enum ElementKind + { + Opaque, + Token, + Leaf, + Row, + InferredRow, + Fixed, + Table, + } + + /// What the editor knows of each element of MathML: its kind, its slots and how the caret moves between them. + internal static class MathSchema + { + private static readonly int[] RootOrder = { 1, 0 }; + + public static bool IsToken(string name) => name is "mi" or "mn" or "mo" or "mtext" or "ms"; + + public static ElementKind KindOf(string name) => name switch + { + "mi" or "mn" or "mo" or "mtext" or "ms" => ElementKind.Token, + "mspace" or "none" or "mprescripts" or "malignmark" or "maligngroup" or "mglyph" => ElementKind.Leaf, + "mrow" => ElementKind.Row, + "math" or "msqrt" or "mstyle" or "merror" or "mpadded" or "mphantom" or "menclose" or "mtd" => ElementKind.InferredRow, + "mfrac" or "mroot" or "msub" or "msup" or "msubsup" or "munder" or "mover" or "munderover" or "mmultiscripts" => ElementKind.Fixed, + "mtable" or "mtr" or "mlabeledtr" => ElementKind.Table, + _ => ElementKind.Opaque, + }; + + /// + /// The slots of a node that is an item of a row, as paths relative to the node, in the order the caret goes + /// through them from left to right. A row is its own slot (the empty path). + /// + public static IReadOnlyList Slots(MathNode node) + { + if (node.IsRun || node.IsOpaque) + return Array.Empty(); + + switch (KindOf(node.Name)) + { + case ElementKind.Row: + return new[] { Array.Empty() }; + case ElementKind.InferredRow: + return node.Children.Count == 1 && node.Children[0].IsRow ? new[] { new[] { 0 } } : Array.Empty(); + case ElementKind.Fixed: + return SlotOrder(node).Where(i => i < node.Children.Count && node.Children[i].IsRow).Select(i => new[] { i }).ToArray(); + case ElementKind.Table when node.Name == "mtable": + var slots = new List(); + for (var r = 0; r < node.Children.Count; r++) + { + var row = node.Children[r]; + for (var c = 0; c < row.Children.Count; c++) + { + if (row.Children[c].Name == "mtd" && row.Children[c].Children.Count == 1 && row.Children[c].Children[0].IsRow) + slots.Add(new[] { r, c, 0 }); + } + } + return slots; + default: + return Array.Empty(); + } + } + + /// The children of a structure in the order they are read: the index of a root before its base, prescripts before the base. + private static IEnumerable SlotOrder(MathNode node) + { + switch (node.Name) + { + case "mroot": + return RootOrder; + case "mmultiscripts": + var pre = -1; + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i].Name == "mprescripts") + pre = i; + } + if (pre < 0) + return Enumerable.Range(0, node.Children.Count); + return Enumerable.Range(pre + 1, node.Children.Count - pre - 1) + .Concat(Enumerable.Repeat(0, 1)) + .Concat(Enumerable.Range(1, pre - 1)); + default: + return Enumerable.Range(0, node.Children.Count); + } + } + + /// + /// The slot above (direction -1) or below (+1) a slot of a structure, as a path relative to the structure, or + /// null when the structure has none in that direction. + /// + public static int[]? Vertical(MathNode node, int[] slot, int direction) + { + if (slot.Length == 0) + return null; + + var up = direction < 0; + var s = slot[0]; + int? target = node.Name switch + { + "mfrac" => up ? (s == 1 ? 0 : null) : (s == 0 ? 1 : null), + "msub" or "munder" => up ? (s == 1 ? 0 : null) : (s == 0 ? 1 : null), + "msup" or "mover" => up ? (s == 0 ? 1 : null) : (s == 1 ? 0 : null), + "msubsup" => up ? (s is 0 or 1 ? 2 : null) : (s is 0 or 2 ? 1 : null), + "munderover" => up ? (s switch { 0 => 2, 1 => 0, _ => null }) : (s switch { 0 => 1, 2 => 0, _ => null }), + "mroot" => up ? (s == 0 ? 1 : null) : (s == 1 ? 0 : null), + _ => null, + }; + if (target is { } t) + return new[] { t }; + + if (node.Name == "mtable" && slot.Length == 3) + { + var row = slot[0] + direction; + if (row < 0 || row >= node.Children.Count) + return null; + var cells = node.Children[row].Children.Count; + if (cells == 0) + return null; + return new[] { row, Math.Min(slot[1], cells - 1), 0 }; + } + return null; + } + } +} diff --git a/src/SharpEquations.Editing/MathTree.cs b/src/SharpEquations.Editing/MathTree.cs new file mode 100644 index 0000000..02d18cd --- /dev/null +++ b/src/SharpEquations.Editing/MathTree.cs @@ -0,0 +1,72 @@ +namespace SharpEquations.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// Paths in a tree of s. + internal static class MathTree + { + public static MathNode Get(MathNode root, IReadOnlyList path) + { + var node = root; + foreach (var index in path) + node = node.Children[index]; + return node; + } + + public static bool TryGet(MathNode root, IReadOnlyList path, out MathNode node) + { + node = root; + foreach (var index in path) + { + if (index < 0 || index >= node.Children.Count) + return false; + node = node.Children[index]; + } + return true; + } + + public static MathNode Replace(MathNode root, IReadOnlyList path, MathNode node) => Replace(root, path, 0, node); + + private static MathNode Replace(MathNode current, IReadOnlyList path, int depth, MathNode node) => + depth == path.Count ? node : current.WithChild(path[depth], Replace(current.Children[path[depth]], path, depth + 1, node)); + + public static int[] Append(IReadOnlyList path, params int[] more) => path.Concat(more).ToArray(); + + public static int[] Parent(IReadOnlyList path) => path.Take(path.Count - 1).ToArray(); + + public static bool StartsWith(IReadOnlyList path, IReadOnlyList prefix) => + path.Count >= prefix.Count && prefix.Select((index, i) => path[i] == index).All(b => b); + + /// The path of the row of the math element. + public static readonly int[] RootRow = { 0 }; + + /// + /// The item of a row that a row is a slot of: its path (the row of the item is its parent), the paths of the + /// slots of the item, and the index of the row among them; null for the row of the math element. + /// + public static (int[] Item, int[][] Slots, int Index)? Owner(MathNode root, IReadOnlyList rowPath) + { + if (rowPath.Count == 0) + return null; + + var parentPath = Parent(rowPath); + if (Get(root, parentPath).IsRow) + { + var self = rowPath.ToArray(); + return (self, new[] { self }, 0); + } + + var item = parentPath; + while (item.Length > 0 && !Get(root, Parent(item)).IsRow) + item = Parent(item); + if (item.Length == 0) + return null; + + var slots = MathSchema.Slots(Get(root, item)).Select(s => Append(item, s)).ToArray(); + var index = Array.FindIndex(slots, s => s.SequenceEqual(rowPath)); + return index < 0 ? null : (item, slots, index); + } + } +} diff --git a/src/SharpEquations.Editing/SharpEquations.Editing.csproj b/src/SharpEquations.Editing/SharpEquations.Editing.csproj new file mode 100644 index 0000000..79a4cde --- /dev/null +++ b/src/SharpEquations.Editing/SharpEquations.Editing.csproj @@ -0,0 +1,33 @@ + + + + + netstandard2.0;net10.0 + SharpEquations.Editing + SharpEquations.Editing + SharpEquations.Editing + The editing model of an equation editor built on SharpEquations: the tree of the equation, the caret, the selection, undo, and the build up of UnicodeMath or LaTeX as it is typed. No dependency on a user interface. + equation math mathml editor unicodemath latex + enable + true + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpEquations.Editing/Text/CodePointText.cs b/src/SharpEquations.Editing/Text/CodePointText.cs new file mode 100644 index 0000000..3e95b45 --- /dev/null +++ b/src/SharpEquations.Editing/Text/CodePointText.cs @@ -0,0 +1,51 @@ +namespace SharpEquations.Editing.Text +{ + using System.Collections.Generic; + using System.Linq; + using System.Text; + + /// Text indexed by code points, never by UTF-16 s: the offsets of the caret in a run. + internal static class CodePointText + { + public static int Length(string s) => s.EnumerateRunes().Count(); + + public static List Runes(string s) => s.EnumerateRunes().ToList(); + + public static string Join(IEnumerable runes) + { + var sb = new StringBuilder(); + foreach (var rune in runes) + sb.Append(rune.ToString()); + return sb.ToString(); + } + + public static string Substring(string s, int start, int length) => Join(s.EnumerateRunes().Skip(start).Take(length)); + + public static string Substring(string s, int start) => Join(s.EnumerateRunes().Skip(start)); + + public static string Insert(string s, int index, string text) + { + var runes = Runes(s); + return Join(runes.Take(index)) + text + Join(runes.Skip(index)); + } + + public static string Remove(string s, int index, int count) + { + var runes = Runes(s); + return Join(runes.Take(index)) + Join(runes.Skip(index + count)); + } + + public static bool IsSingle(string s, out Rune rune) + { + rune = default; + var count = 0; + foreach (var r in s.EnumerateRunes()) + { + if (++count > 1) + return false; + rune = r; + } + return count == 1; + } + } +} diff --git a/src/SharpEquations.Rendering/IMathFont.cs b/src/SharpEquations.Rendering/IMathFont.cs new file mode 100644 index 0000000..ba153d1 --- /dev/null +++ b/src/SharpEquations.Rendering/IMathFont.cs @@ -0,0 +1,54 @@ +namespace SharpEquations.Rendering +{ + using SharpEquations.Rendering.OpenType; + + /// The ink box of a glyph, in design units, with y up from the baseline. + public readonly struct GlyphBounds + { + /// A box. + public GlyphBounds(double xMin, double yMin, double xMax, double yMax) + { + XMin = xMin; + YMin = yMin; + XMax = xMax; + YMax = yMax; + } + + /// The left of the ink. + public double XMin { get; } + + /// The bottom of the ink (negative below the baseline). + public double YMin { get; } + + /// The right of the ink. + public double XMax { get; } + + /// The top of the ink. + public double YMax { get; } + } + + /// + /// A font as the layout needs it, which a toolkit implements over its own fonts: glyphs, advances, ink boxes, and + /// the OpenType MATH table of a math font. + /// + public interface IMathFont + { + /// The units of the design of the font in an em. + double UnitsPerEm { get; } + + /// The MATH table of the font, or null for a font that has none (a fallback font). + MathTable? Math { get; } + + /// The glyph of a code point, if the font has one. + bool TryGetGlyph(int codePoint, out ushort glyph); + + /// The advance of a glyph, in design units. + double GetAdvance(ushort glyph); + + /// The ink box of a glyph, in design units. + GlyphBounds GetBounds(ushort glyph); + + /// A font for a code point that the font has no glyph for, or null. + IMathFont? GetFallback(int codePoint); + } +} diff --git a/src/SharpEquations.Rendering/Layout/Box.cs b/src/SharpEquations.Rendering/Layout/Box.cs new file mode 100644 index 0000000..36488b2 --- /dev/null +++ b/src/SharpEquations.Rendering/Layout/Box.cs @@ -0,0 +1,139 @@ +namespace SharpEquations.Rendering.Layout +{ + using System.Collections.Generic; + + /// + /// A laid out node: its width and its height and depth from the baseline (y up), with its children, glyphs and + /// shapes placed relative to its origin on the baseline. + /// + internal sealed class Box + { + public double Width { get; set; } + + public double Height { get; set; } + + public double Depth { get; set; } + + /// The italic correction of the last glyph, when the box is a token. + public double Italic { get; set; } + + /// Where an accent attaches above the box; null for its middle. + public double? TopAccent { get; set; } + + /// A token element, which the baseline drops of scripts do not apply to. + public bool IsToken { get; set; } + + /// A large operator, whose subscript moves left by its italic correction. + public bool IsLargeOp { get; set; } + + /// A single letter of mi, after which the italic correction is added. + public bool IsItalicLetter { get; set; } + + /// Nothing of the box is drawn (mphantom). + public bool Invisible { get; set; } + + public string? Color { get; set; } + + public List<(Box Box, double X, double Shift)> Children { get; } = new List<(Box, double, double)>(); + + public List Glyphs { get; } = new List(); + + public List Shapes { get; } = new List(); + + /// The path of the row or run of an editor the box is, and its caret positions (x from the origin). + public int[]? Path { get; set; } + + public bool IsRun { get; set; } + + public double[]? CaretX { get; set; } + + /// The height and depth the caret has at least in the row. + public double CaretHeight { get; set; } + + public double CaretDepth { get; set; } + + public void Add(Box box, double x, double shift) => Children.Add((box, x, shift)); + + /// A box of one child, shifted. + public static Box Wrap(Box child, double x = 0, double shift = 0) + { + var box = new Box + { + Width = child.Width + x, + Height = child.Height + shift, + Depth = child.Depth - shift, + Italic = child.Italic, + TopAccent = child.TopAccent + x, + IsToken = child.IsToken, + IsLargeOp = child.IsLargeOp, + IsItalicLetter = child.IsItalicLetter, + }; + box.Add(child, x, shift); + return box; + } + } + + internal readonly struct GlyphItem + { + public GlyphItem(IMathFont font, ushort glyph, double x, double shift, double size) + { + Font = font; + Glyph = glyph; + X = x; + Shift = shift; + Size = size; + } + + public IMathFont Font { get; } + + public ushort Glyph { get; } + + public double X { get; } + + public double Shift { get; } + + public double Size { get; } + } + + /// A shape in the coordinates of a box: x right, y up from the baseline; a rectangle from (X, Y) to (X + Width, Y + Height). + internal readonly struct ShapeItem + { + public ShapeItem(MathShapeKind kind, MathShapeRole role, double x, double y, double width, double height, double x2 = 0, double y2 = 0, double thickness = 0, string? color = null) + { + Kind = kind; + Role = role; + X = x; + Y = y; + Width = width; + Height = height; + X2 = x2; + Y2 = y2; + Thickness = thickness; + Color = color; + } + + public MathShapeKind Kind { get; } + + public MathShapeRole Role { get; } + + public double X { get; } + + public double Y { get; } + + public double Width { get; } + + public double Height { get; } + + public double X2 { get; } + + public double Y2 { get; } + + public double Thickness { get; } + + public string? Color { get; } + + public static ShapeItem Rule(double x, double y, double width, double height) => new ShapeItem(MathShapeKind.Fill, MathShapeRole.Ink, x, y, width, height); + + public static ShapeItem Line(double x, double y, double x2, double y2, double thickness) => new ShapeItem(MathShapeKind.Line, MathShapeRole.Ink, x, y, 0, 0, x2, y2, thickness); + } +} diff --git a/src/SharpEquations.Rendering/Layout/Engine.cs b/src/SharpEquations.Rendering/Layout/Engine.cs new file mode 100644 index 0000000..9d9b8b0 --- /dev/null +++ b/src/SharpEquations.Rendering/Layout/Engine.cs @@ -0,0 +1,1448 @@ +namespace SharpEquations.Rendering.Layout +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + using SharpEquations.Editing; + using SharpEquations.Rendering.OpenType; + + /// A request to stretch an operator: vertically to cover a height and a depth, or horizontally to a width. + internal readonly struct Stretch + { + public Stretch(double height, double depth, double width, bool accent, bool under = false) + { + Height = height; + Depth = depth; + Width = width; + Accent = accent; + Under = under; + } + + /// The operator is under its base (a bar drawn as a rule goes at the top of its box). + public bool Under { get; } + + public double Height { get; } + + public double Depth { get; } + + public double Width { get; } + + /// The operator is an accent: its box is its ink, so that a combining mark centres on its base. + public bool Accent { get; } + + public bool Vertical => Height > 0 || Depth > 0; + + public static Stretch None => default; + } + + /// + /// The layout of presentation MathML after MathML Core, with the constants of the OpenType MATH table: rows with + /// the spaces of the operator dictionary, stretchy operators, fractions, radicals, scripts and limits, tables. + /// + internal sealed class Engine + { + private static readonly char[] Spaces = { ' ' }; + + private readonly IMathFont font; + private readonly MathTable? math; + private readonly MathLayoutOptions options; + private readonly double upem; + + public Engine(IMathFont font, MathLayoutOptions options) + { + this.font = font; + math = font.Math; + this.options = options; + upem = font.UnitsPerEm; + } + + private double ScriptPercent => (math?[MathConstant.ScriptPercentScaleDown] is int p and > 0 ? p : 71) / 100.0; + + private double ScriptScriptPercent => (math?[MathConstant.ScriptScriptPercentScaleDown] is int p and > 0 ? p : 50) / 100.0; + + public Box LayoutRoot(MathNode root, int[]? path) + { + var display = options.Display || root.GetAttribute("display") == "block"; + var style = new Style(options.FontSize, 0, display, false, null); + return Layout(root, path, style, Stretch.None); + } + + private double C(MathConstant constant, Style s) + { + if (math != null) + return math[constant] * s.Size / upem; + return Fallback(constant) * s.Size / 1000; + } + + /// Constants of a font without MATH table, in thousandths of an em, close to those of Latin Modern Math. + private static double Fallback(MathConstant constant) => constant switch + { + MathConstant.AxisHeight => 250, + MathConstant.AccentBaseHeight => 450, + MathConstant.FractionRuleThickness or MathConstant.OverbarRuleThickness or MathConstant.UnderbarRuleThickness or MathConstant.RadicalRuleThickness => 40, + MathConstant.SubscriptShiftDown => 250, + MathConstant.SuperscriptShiftUp => 360, + MathConstant.SuperscriptShiftUpCramped => 290, + MathConstant.FractionNumeratorShiftUp => 390, + MathConstant.FractionDenominatorShiftDown => 340, + MathConstant.FractionNumeratorDisplayStyleShiftUp => 680, + MathConstant.FractionDenominatorDisplayStyleShiftDown => 690, + MathConstant.SpaceAfterScript => 40, + _ => 100, + }; + + private double Axis(Style s) => C(MathConstant.AxisHeight, s); + + private static double Em(Style s, double eighteenths) => s.Size * eighteenths / 18; + + private Style Script(Style s, bool cramped, int increment = 1) => + s.WithLevel(s.Level + increment, ScriptPercent, ScriptScriptPercent).WithDisplay(false).WithCramped(cramped || s.Cramped); + + private Box Layout(MathNode node, int[]? path, Style style, Stretch stretch) + { + if (node.GetAttribute("mathcolor") is { } color) + style = style.WithColor(color); + + var box = LayoutElement(node, path, style, stretch); + if (style.Color != null) + box.Color = style.Color; + + if (node.GetAttribute("mathbackground") is { } background) + { + var wrapped = new Box { Width = box.Width, Height = box.Height, Depth = box.Depth, Italic = box.Italic, TopAccent = box.TopAccent, IsToken = box.IsToken, IsLargeOp = box.IsLargeOp }; + wrapped.Shapes.Add(new ShapeItem(MathShapeKind.Fill, MathShapeRole.Background, 0, -box.Depth, box.Width, box.Height + box.Depth, color: background)); + wrapped.Add(box, 0, 0); + return wrapped; + } + return box; + } + + private Box LayoutElement(MathNode node, int[]? path, Style style, Stretch stretch) + { + if (node.IsRun) + return LayoutRun(node, path, style); + if (node.IsOpaque) + return LayoutOpaque(node, style); + + switch (node.Name) + { + case "math": + return node.Children.Count == 1 ? Layout(node.Children[0], Sub(path, 0), style, stretch) : LayoutRow(node, null, style, stretch); + case "mrow": + return LayoutRow(node, path, style, stretch); + case "mi" or "mn" or "mo" or "mtext" or "ms": + return LayoutToken(node, style, stretch); + case "mspace": + return LayoutSpace(node, style); + case "none" or "mprescripts" or "malignmark" or "maligngroup" or "mglyph": + return new Box(); + case "mstyle": + return LayoutInferred(node, path, ApplyStyle(node, style), stretch); + case "mphantom": + var phantom = LayoutInferred(node, path, style, stretch); + phantom.Invisible = true; + return phantom; + case "mpadded": + return LayoutPadded(node, path, style); + case "merror": + return LayoutError(node, path, style); + case "msqrt": + return LayoutRadical(Layout(node.Children[0], Sub(path, 0), style.WithCramped(true), Stretch.None), null, style); + case "mroot": + return LayoutRadical( + Layout(node.Children[0], Sub(path, 0), style.WithCramped(true), Stretch.None), + node.Children.Count > 1 ? Layout(node.Children[1], Sub(path, 1), Script(style, true, 2), Stretch.None) : null, + style); + case "mfrac": + return LayoutFraction(node, path, style); + case "msub" or "msup" or "msubsup": + return LayoutScripts(node, path, style); + case "munder" or "mover" or "munderover": + return LayoutUnderOver(node, path, style, stretch); + case "mmultiscripts": + return LayoutMultiscripts(node, path, style); + case "mtable": + return LayoutTable(node, path, style); + case "menclose": + return LayoutEnclose(node, path, style); + case "mtd" or "mtr" or "mlabeledtr": + return LayoutRow(MathNode.Row(node.Children), null, style, Stretch.None); + default: + return LayoutRow(MathNode.Row(node.Children), null, style, Stretch.None); + } + } + + private static int[]? Sub(int[]? path, params int[] more) => path == null ? null : MathTreePaths.Append(path, more); + + private Box LayoutInferred(MathNode node, int[]? path, Style style, Stretch stretch) => + node.Children.Count == 1 ? Layout(node.Children[0], Sub(path, 0), style, stretch) : LayoutRow(MathNode.Row(node.Children), null, style, stretch); + + private Style ApplyStyle(MathNode node, Style style) + { + if (node.GetAttribute("displaystyle") is { } display) + style = style.WithDisplay(display == "true"); + if (node.GetAttribute("scriptlevel") is { } level) + { + var value = level.Trim(); + if (int.TryParse(value.TrimStart('+'), out var n)) + style = style.WithLevel(value.Length > 0 && value[0] is '+' or '-' ? style.Level + n : n, ScriptPercent, ScriptScriptPercent); + } + if (node.GetAttribute("mathsize") is { } size && Lengths.TryParse(size, style.Size, Ex(style), style.Size, out var s) && s > 0) + style = style.WithSize(s); + return style; + } + + private double Ex(Style s) => math != null && math[MathConstant.AccentBaseHeight] > 0 ? C(MathConstant.AccentBaseHeight, s) : s.Size * 0.45; + + // ---- rows ----------------------------------------------------------------------------------------------- + + private Box LayoutRow(MathNode row, int[]? path, Style style, Stretch stretch) + { + var n = row.Children.Count; + var box = new Box { Path = path }; + if (n == 0) + { + if (options.ShowPlaceholders && path != null) + { + var w = style.Size * 0.55; + var h = style.Size * 0.68; + box.Width = w + style.Size * 0.1; + box.Height = h; + box.Shapes.Add(new ShapeItem(MathShapeKind.Outline, MathShapeRole.Placeholder, style.Size * 0.05, 0, w, h, thickness: Math.Max(0.75, style.Size / 24))); + } + box.CaretX = new[] { 0.0 }; + SetCaretExtent(box, style); + return box; + } + + // a single child gets the stretch of the row (a row in the slot of an accent, a fence of a table) + var boxes = new Box[n]; + var stretchy = new bool[n]; + for (var i = 0; i < n; i++) + { + var child = row.Children[i]; + stretchy[i] = n > 1 && IsVerticalStretchy(child); + if (!stretchy[i]) + boxes[i] = Layout(child, Sub(path, i), style, n == 1 ? stretch : Stretch.None); + } + + if (stretchy.Any(s => s)) + { + double height = 0, depth = 0; + var any = false; + for (var i = 0; i < n; i++) + { + if (stretchy[i]) + continue; + any = true; + height = Math.Max(height, boxes[i].Height); + depth = Math.Max(depth, boxes[i].Depth); + } + if (!any) + { + height = style.Size * 0.7; + depth = style.Size * 0.2; + } + for (var i = 0; i < n; i++) + { + if (stretchy[i]) + boxes[i] = Layout(row.Children[i], Sub(path, i), style, new Stretch(height, depth, 0, false)); + } + } + + var caret = new double[n + 1]; + var x = 0.0; + for (var i = 0; i < n; i++) + { + caret[i] = x; + var (lspace, rspace) = Spacing(row, i, style); + x += lspace; + box.Add(boxes[i], x, 0); + x += boxes[i].Width; + if (boxes[i].IsItalicLetter && i + 1 < n) + x += boxes[i].Italic; + x += rspace; + box.Height = Math.Max(box.Height, boxes[i].Height); + box.Depth = Math.Max(box.Depth, boxes[i].Depth); + } + caret[n] = x; + box.Width = x; + box.CaretX = caret; + SetCaretExtent(box, style); + if (n == 1) + { + box.Italic = boxes[0].Italic; + box.TopAccent = boxes[0].TopAccent + (box.Children[0].X); + box.IsToken = boxes[0].IsToken; + box.IsLargeOp = boxes[0].IsLargeOp; + box.IsItalicLetter = boxes[0].IsItalicLetter; + } + else if (n > 0) + { + box.Italic = boxes[n - 1].Italic; + } + return box; + } + + private static void SetCaretExtent(Box box, Style style) + { + box.CaretHeight = Math.Max(box.Height, style.Size * 0.75); + box.CaretDepth = Math.Max(box.Depth, style.Size * 0.22); + } + + /// The spaces before and after a child of a row: those of its operator, if it is an embellished operator. + private (double L, double R) Spacing(MathNode row, int i, Style style) + { + var child = row.Children[i]; + var op = CoreOperator(child); + if (op == null) + return (0, 0); + var entry = OperatorDictionary.Lookup(op.Text, FormOf(row, i, op)); + var l = Em(style, entry.LSpace); + var r = Em(style, entry.RSpace); + if (style.Level > 0) + l = r = 0; + if (op.GetAttribute("lspace") is { } ls && Lengths.TryParse(ls, style.Size, Ex(style), style.Size, out var lv)) + l = lv; + if (op.GetAttribute("rspace") is { } rs && Lengths.TryParse(rs, style.Size, Ex(style), style.Size, out var rv)) + r = rv; + return (l, r); + } + + private static OperatorForm FormOf(MathNode row, int i, MathNode op) + { + switch (op.GetAttribute("form")) + { + case "prefix": + return OperatorForm.Prefix; + case "postfix": + return OperatorForm.Postfix; + case "infix": + return OperatorForm.Infix; + } + var n = row.Children.Count; + if (n <= 1) + return OperatorForm.Infix; + if (i == 0) + return OperatorForm.Prefix; + return i == n - 1 ? OperatorForm.Postfix : OperatorForm.Infix; + } + + /// The operator an element is an embellishment of (an mo with scripts, in a row of its own...), or null. + private static MathNode? CoreOperator(MathNode node) + { + switch (node.Name) + { + case "mo": + return node; + case "mrow": + return node.Children.Count == 1 ? CoreOperator(node.Children[0]) : null; + case "msub" or "msup" or "msubsup" or "munder" or "mover" or "munderover" or "mmultiscripts" or "mfrac": + return node.Children.Count > 0 ? CoreOperator(node.Children[0]) : null; + case "mstyle" or "mphantom" or "mpadded": + return node.Children.Count == 1 ? CoreOperator(node.Children[0]) : null; + default: + return null; + } + } + + private static bool IsVerticalStretchy(MathNode node) + { + var op = node.Name == "mo" ? node : null; + if (op == null || op.GetAttribute("stretchy") == "false") + return false; + if (!OperatorDictionary.TryLookup(op.Text, out var entry)) + return op.GetAttribute("stretchy") == "true"; + return entry.Has(OperatorFlags.Stretchy) && !entry.Has(OperatorFlags.Horizontal) || op.GetAttribute("stretchy") == "true" && !entry.Has(OperatorFlags.Horizontal); + } + + private static bool IsHorizontalStretchy(MathNode node) + { + var op = CoreOperatorOfSlot(node); + if (op == null || op.GetAttribute("stretchy") == "false") + return false; + return OperatorDictionary.TryLookup(op.Text, out var entry) && entry.Has(OperatorFlags.Stretchy | OperatorFlags.Horizontal); + } + + private static MathNode? CoreOperatorOfSlot(MathNode node) => node.Name switch + { + "mo" => node, + "mrow" when node.Children.Count == 1 => CoreOperatorOfSlot(node.Children[0]), + _ => null, + }; + + // ---- tokens --------------------------------------------------------------------------------------------- + + private Box LayoutToken(MathNode node, Style style, Stretch stretch) + { + var text = node.Text; + if (node.Name == "ms") + text = (node.GetAttribute("lquote") ?? "\"") + text + (node.GetAttribute("rquote") ?? "\""); + + var italic = node.Name == "mi" && node.GetAttribute("mathvariant") != "normal" && IsSingle(text, out var single) && MathItalic(single) != single; + if (node.Name == "mo") + return LayoutOperator(node, text, style, stretch); + + var box = LayoutText(text, style, italic); + box.IsToken = true; + box.IsItalicLetter = italic; + return box; + } + + private Box LayoutOperator(MathNode node, string text, Style style, Stretch stretch) + { + OperatorDictionary.TryLookup(text, out var entry); + var isLarge = node.GetAttribute("largeop") is { } lo ? lo == "true" : entry.Has(OperatorFlags.LargeOp); + if (IsSingle(text, out var cp) && Glyph(cp, out var f, out var glyph)) + { + var fmath = f.Math; + if (stretch.Vertical && fmath?.Vertical(glyph) is { } vertical) + { + var symmetric = node.GetAttribute("symmetric") is { } sym ? sym == "true" : entry.Has(OperatorFlags.Symmetric); + var axis = Axis(style); + double target, center; + if (symmetric) + { + var half = Math.Max(stretch.Height - axis, stretch.Depth + axis); + target = 2 * half; + center = axis; + } + else + { + target = stretch.Height + stretch.Depth; + center = (stretch.Height - stretch.Depth) / 2; + } + target = ApplyMinMax(node, target, style); + var stretched = StretchGlyph(f, glyph, vertical, target, style, true); + return Box.Wrap(stretched, 0, center - (stretched.Height - stretched.Depth) / 2); + } + + if (stretch.Width > 0 && IsBar(cp) && (fmath?.Horizontal(glyph) is not { } bars || bars.Variants.Count == 0 && bars.Assembly == null)) + return BarBox(stretch, style); + + if (stretch.Width > 0 && fmath?.Horizontal(glyph) is { } horizontal) + { + var stretched = StretchGlyph(f, glyph, horizontal, stretch.Width, style, false); + return stretch.Accent ? InkBox(stretched) : stretched; + } + + if (isLarge && style.Display && fmath?.Vertical(glyph) is { } large) + { + var min = fmath[MathConstant.DisplayOperatorMinHeight]; + var variant = large.Variants.Count > 0 ? large.Variants[large.Variants.Count - 1].Glyph : glyph; + foreach (var (g, advance) in large.Variants) + { + if (advance >= min && g != glyph) + { + variant = g; + break; + } + } + var big = GlyphBox(f, variant, style); + big.IsLargeOp = true; + big.IsToken = true; + return Box.Wrap(big, 0, Axis(style) - (big.Height - big.Depth) / 2); + } + } + + var box = LayoutText(text, style, false); + box.IsToken = true; + box.IsLargeOp = isLarge; + if (isLarge) + return Box.Wrap(box, 0, Axis(style) - (box.Height - box.Depth) / 2); + return stretch.Accent ? InkBox(box) : box; + } + + private static bool IsBar(int cp) => cp is 0xAF or 0x203E or '_' or 0x0332 or 0x0305 or 0x2015 or 0x2500 or 0x23AF; + + /// A bar over or under its base, as a rule as wide as the base: the overbar and underbar of the MATH table. + private Box BarBox(Stretch stretch, Style style) + { + var box = new Box { Width = stretch.Width }; + if (stretch.Under) + { + var gap = C(MathConstant.UnderbarVerticalGap, style); + var t = C(MathConstant.UnderbarRuleThickness, style); + box.Height = gap + t; + box.Depth = C(MathConstant.UnderbarExtraDescender, style); + box.Shapes.Add(ShapeItem.Rule(0, 0, stretch.Width, t)); + } + else + { + var gap = C(MathConstant.OverbarVerticalGap, style); + var t = C(MathConstant.OverbarRuleThickness, style); + var bottom = stretch.Accent ? C(MathConstant.AccentBaseHeight, style) + gap : 0; + box.Height = bottom + t + C(MathConstant.OverbarExtraAscender, style); + box.Depth = stretch.Accent ? 0 : gap; + box.Shapes.Add(ShapeItem.Rule(0, bottom, stretch.Width, t)); + } + return box; + } + + private double ApplyMinMax(MathNode node, double target, Style style) + { + if (node.GetAttribute("minsize") is { } min && Lengths.TryParse(min, style.Size, Ex(style), style.Size, out var minValue)) + target = Math.Max(target, minValue); + if (node.GetAttribute("maxsize") is { } max && Lengths.TryParse(max, style.Size, Ex(style), style.Size, out var maxValue) && maxValue > 0) + target = Math.Min(target, maxValue); + return target; + } + + /// A copy of the box whose extent is its ink, starting at its left. + private static Box InkBox(Box box) + { + double xMin = double.MaxValue, xMax = double.MinValue; + foreach (var g in box.Glyphs) + { + var b = g.Font.GetBounds(g.Glyph); + var scale = g.Size / g.Font.UnitsPerEm; + xMin = Math.Min(xMin, g.X + b.XMin * scale); + xMax = Math.Max(xMax, g.X + b.XMax * scale); + } + foreach (var (child, x, _) in box.Children) + { + xMin = Math.Min(xMin, x); + xMax = Math.Max(xMax, x + child.Width); + } + if (xMin > xMax) + return box; + var wrapped = Box.Wrap(box, -xMin, 0); + wrapped.Width = xMax - xMin; + wrapped.TopAccent = box.TopAccent - xMin; + return wrapped; + } + + private Box LayoutText(string text, Style style, bool italic) + { + var box = new Box(); + var x = 0.0; + ushort last = 0; + IMathFont? lastFont = null; + var count = 0; + foreach (var rune in text.EnumerateRunes()) + { + var cp = italic ? MathItalic(rune.Value) : rune.Value; + if (IsInvisible(cp)) + continue; + if (!Glyph(cp, out var f, out var glyph) && !(italic && Glyph(rune.Value, out f, out glyph))) + { + if (cp is ' ' or 0xA0) + { + x += style.Size * 0.25; + continue; + } + f = font; + glyph = 0; + } + var scale = style.Size / f.UnitsPerEm; + var bounds = f.GetBounds(glyph); + box.Glyphs.Add(new GlyphItem(f, glyph, x, 0, style.Size)); + box.Height = Math.Max(box.Height, bounds.YMax * scale); + box.Depth = Math.Max(box.Depth, -bounds.YMin * scale); + x += f.GetAdvance(glyph) * scale; + last = glyph; + lastFont = f; + count++; + } + box.Width = x; + if (lastFont?.Math is { } m) + { + box.Italic = m.ItalicCorrection(last) * style.Size / lastFont.UnitsPerEm; + if (count == 1 && m.TopAccentAttachment(last) is { } attachment) + box.TopAccent = attachment * style.Size / lastFont.UnitsPerEm; + } + return box; + } + + private static Box GlyphBox(IMathFont f, ushort glyph, Style style, double x = 0) + { + var scale = style.Size / f.UnitsPerEm; + var bounds = f.GetBounds(glyph); + var box = new Box + { + Width = f.GetAdvance(glyph) * scale, + Height = bounds.YMax * scale, + Depth = -bounds.YMin * scale, + Italic = (f.Math?.ItalicCorrection(glyph) ?? 0) * scale, + }; + if (f.Math?.TopAccentAttachment(glyph) is { } attachment) + box.TopAccent = attachment * scale; + box.Glyphs.Add(new GlyphItem(f, glyph, x, 0, style.Size)); + return box; + } + + /// A glyph stretched to a size at least: the first variant big enough, or an assembly of parts. + private static Box StretchGlyph(IMathFont f, ushort glyph, GlyphConstruction construction, double target, Style style, bool vertical) + { + var scale = style.Size / f.UnitsPerEm; + var targetUnits = target / scale; + var natural = vertical ? GlyphBox(f, glyph, style) : null; + if (vertical && natural!.Height + natural.Depth >= target) + return natural; + if (!vertical && f.GetAdvance(glyph) >= targetUnits) + return GlyphBox(f, glyph, style); + + foreach (var (variant, advance) in construction.Variants) + { + if (advance >= targetUnits) + return GlyphBox(f, variant, style); + } + + if (construction.Assembly is { Count: > 0 } parts) + return Assembly(f, parts, f.Math!.MinConnectorOverlap, targetUnits, style, vertical, construction.AssemblyItalicCorrection); + + return construction.Variants.Count > 0 ? GlyphBox(f, construction.Variants[construction.Variants.Count - 1].Glyph, style) : GlyphBox(f, glyph, style); + } + + private static Box Assembly(IMathFont f, IReadOnlyList parts, int minOverlap, double target, Style style, bool vertical, int italic) + { + var scale = style.Size / f.UnitsPerEm; + List list = new List(); + for (var repeats = 0; repeats <= 64; repeats++) + { + list = new List(); + foreach (var part in parts) + { + if (part.IsExtender) + { + for (var k = 0; k < repeats; k++) + list.Add(part); + } + else + { + list.Add(part); + } + } + if (list.Count == 0) + continue; + var max = list.Sum(p => (double)p.FullAdvance) - minOverlap * (list.Count - 1); + if (max >= target) + break; + } + + var sum = list.Sum(p => (double)p.FullAdvance); + var overlap = 0.0; + if (list.Count > 1) + { + var maxOverlap = double.MaxValue; + for (var i = 1; i < list.Count; i++) + maxOverlap = Math.Min(maxOverlap, Math.Min(list[i - 1].EndConnector, list[i].StartConnector)); + overlap = Math.Max(minOverlap, Math.Min((sum - target) / (list.Count - 1), Math.Max(minOverlap, maxOverlap))); + } + + var box = new Box(); + var at = 0.0; + foreach (var part in list) + { + var bounds = f.GetBounds(part.Glyph); + if (vertical) + { + box.Glyphs.Add(new GlyphItem(f, part.Glyph, 0, (at - bounds.YMin) * scale, style.Size)); + box.Width = Math.Max(box.Width, f.GetAdvance(part.Glyph) * scale); + } + else + { + box.Glyphs.Add(new GlyphItem(f, part.Glyph, (at - bounds.XMin) * scale, 0, style.Size)); + box.Height = Math.Max(box.Height, bounds.YMax * scale); + box.Depth = Math.Max(box.Depth, -bounds.YMin * scale); + } + at += part.FullAdvance - overlap; + } + var size = (at + overlap) * scale; + if (vertical) + { + // the parts of a vertical assembly start at the baseline: the assembly goes from 0 up + box.Height = size; + box.Depth = 0; + box.Italic = italic * scale; + } + else + { + box.Width = size; + } + return box; + } + + private bool Glyph(int codePoint, out IMathFont f, out ushort glyph) + { + if (font.TryGetGlyph(codePoint, out glyph) && glyph != 0) + { + f = font; + return true; + } + if (font.GetFallback(codePoint) is { } fallback && fallback.TryGetGlyph(codePoint, out glyph) && glyph != 0) + { + f = fallback; + return true; + } + f = font; + glyph = 0; + return false; + } + + private static bool IsInvisible(int cp) => cp is >= 0x2061 and <= 0x2064 or 0x200B or 0x200C or 0x200D or 0x2060 or 0xFEFF; + + private static bool IsSingle(string text, out int codePoint) + { + codePoint = 0; + var count = 0; + foreach (var rune in text.EnumerateRunes()) + { + codePoint = rune.Value; + count++; + } + return count == 1; + } + + /// The mathematical italic of a letter, as MathML Core shows a single letter of mi. + internal static int MathItalic(int c) => c switch + { + 'h' => 0x210E, + >= 'A' and <= 'Z' => 0x1D434 + c - 'A', + >= 'a' and <= 'z' => 0x1D44E + c - 'a', + 0x131 => 0x1D6A4, + 0x237 => 0x1D6A5, + >= 0x391 and <= 0x3A9 => 0x1D6E2 + c - 0x391, + 0x3F4 => 0x1D6F3, + 0x2207 => 0x1D6FB, + >= 0x3B1 and <= 0x3C9 => 0x1D6FC + c - 0x3B1, + 0x2202 => 0x1D715, + 0x3F5 => 0x1D716, + 0x3D1 => 0x1D717, + 0x3F0 => 0x1D718, + 0x3D5 => 0x1D719, + 0x3F1 => 0x1D71A, + 0x3D6 => 0x1D71B, + _ => c, + }; + + // ---- runs and other leaves ------------------------------------------------------------------------------ + + private Box LayoutRun(MathNode run, int[]? path, Style style) + { + var box = new Box { Path = path, IsRun = true }; + var caret = new List { 0 }; + var x = 0.0; + foreach (var rune in run.Text.EnumerateRunes()) + { + var c = rune.Value; + var cp = c < 0x80 && char.IsLetter((char)c) || c is >= 0x3B1 and <= 0x3C9 ? MathItalic(c) : c; + if (cp is ' ' or 0xA0 || IsInvisible(cp) || !Glyph(cp, out var f, out var glyph) && !Glyph(c, out f, out glyph)) + { + x += cp is ' ' or 0xA0 ? style.Size * 0.28 : 0; + caret.Add(x); + continue; + } + var scale = style.Size / f.UnitsPerEm; + var bounds = f.GetBounds(glyph); + box.Glyphs.Add(new GlyphItem(f, glyph, x, 0, style.Size)); + box.Height = Math.Max(box.Height, bounds.YMax * scale); + box.Depth = Math.Max(box.Depth, -bounds.YMin * scale); + x += f.GetAdvance(glyph) * scale; + caret.Add(x); + } + box.Width = x; + box.CaretX = caret.ToArray(); + SetCaretExtent(box, style); + return box; + } + + private Box LayoutSpace(MathNode node, Style style) + { + var box = new Box(); + if (Lengths.TryParse(node.GetAttribute("width"), style.Size, Ex(style), style.Size, out var w)) + box.Width = w; + if (Lengths.TryParse(node.GetAttribute("height"), style.Size, Ex(style), style.Size, out var h)) + box.Height = h; + if (Lengths.TryParse(node.GetAttribute("depth"), style.Size, Ex(style), style.Size, out var d)) + box.Depth = d; + return box; + } + + private Box LayoutOpaque(MathNode node, Style style) + { + var source = node.Source!; + switch (node.Name) + { + case "semantics": + var first = source.Elements().FirstOrDefault(e => !e.Name.LocalName.StartsWith("annotation", StringComparison.Ordinal)); + return first == null ? new Box() : Layout(MathNode.FromMathML(first), null, style, Stretch.None); + case "mstack" or "mlongdiv": + return LayoutStack(source, style); + default: + var text = LayoutText(source.Value, style, false); + return text; + } + } + + /// The rows of an mstack or mlongdiv, one under the other, aligned on the right. + private Box LayoutStack(XElement stack, Style style) + { + var rows = new List(); + foreach (var child in stack.Elements()) + { + switch (child.Name.LocalName) + { + case "msline": + var line = new Box { Height = style.Size * 0.1 }; + line.Shapes.Add(ShapeItem.Rule(0, style.Size * 0.03, 0, C(MathConstant.FractionRuleThickness, style))); + rows.Add(line); + break; + case "mscarries": + rows.Add(LayoutRow(MathNode.Row(child.Elements().Select(MathNode.FromMathML)), null, Script(style, false), Stretch.None)); + break; + case "msrow" or "msgroup": + rows.Add(LayoutRow(MathNode.Row(child.Elements().Select(MathNode.FromMathML)), null, style, Stretch.None)); + break; + default: + rows.Add(Layout(MathNode.FromMathML(child), null, style, Stretch.None)); + break; + } + } + + var width = rows.Count == 0 ? 0 : rows.Max(r => r.Width); + var box = new Box { Width = width }; + var y = 0.0; + var gap = style.Size * 0.15; + var placed = new List<(Box Box, double Y)>(); + foreach (var row in rows) + { + y -= row.Height; + placed.Add((row, y)); + y -= row.Depth + gap; + } + var total = -y - gap; + var shift = total / 2 + Axis(style); + foreach (var (row, top) in placed) + { + if (row.Shapes.Count == 1 && row.Glyphs.Count == 0 && row.Children.Count == 0) + { + var rule = row.Shapes[0]; + var full = new Box { Width = width, Height = row.Height }; + full.Shapes.Add(ShapeItem.Rule(0, rule.Y, width, rule.Height)); + box.Add(full, 0, top + shift); + } + else + { + box.Add(row, width - row.Width, top + shift); + } + } + box.Height = shift; + box.Depth = total - shift; + return box; + } + + private Box LayoutPadded(MathNode node, int[]? path, Style style) + { + var content = LayoutInferred(node, path, style, Stretch.None); + double Dimension(string attribute, double current) + { + var value = node.GetAttribute(attribute); + if (value == null) + return current; + var v = value.Trim(); + var sign = v.Length == 0 ? 0 : v[0] == '+' ? 1 : v[0] == '-' ? -1 : 0; + var body = sign == 0 ? v : v.Substring(1); + if (!Lengths.TryParse(body, style.Size, Ex(style), current, out var length)) + return current; + return sign == 0 ? length : current + sign * length; + } + + var lspace = Dimension("lspace", 0); + var voffset = Dimension("voffset", 0); + var box = Box.Wrap(content, lspace, voffset); + box.Width = Dimension("width", content.Width); + box.Height = Dimension("height", content.Height); + box.Depth = Dimension("depth", content.Depth); + return box; + } + + private Box LayoutError(MathNode node, int[]? path, Style style) + { + var content = LayoutInferred(node, path, style, Stretch.None); + var pad = style.Size * 0.05; + var box = Box.Wrap(content, pad, 0); + box.Width = content.Width + 2 * pad; + box.Height = content.Height + pad; + box.Depth = content.Depth + pad; + box.Shapes.Add(new ShapeItem(MathShapeKind.Fill, MathShapeRole.Error, 0, -box.Depth, box.Width, box.Height + box.Depth)); + return box; + } + + // ---- fractions and radicals ----------------------------------------------------------------------------- + + private Box LayoutFraction(MathNode node, int[]? path, Style style) + { + var inner = style.Display ? style.WithDisplay(false) : Script(style, false); + var num = Layout(node.Children[0], Sub(path, 0), inner, Stretch.None); + var den = Layout(node.Children.Count > 1 ? node.Children[1] : MathNode.Row(Array.Empty()), Sub(path, 1), inner.WithCramped(true), Stretch.None); + + var defaultThickness = C(MathConstant.FractionRuleThickness, style); + var thickness = defaultThickness; + if (node.GetAttribute("linethickness") is { } lt) + { + thickness = lt switch + { + "thin" => defaultThickness / 2, + "medium" => defaultThickness, + "thick" => defaultThickness * 2, + _ => Lengths.TryParse(lt, style.Size, Ex(style), defaultThickness, out var t) ? t : defaultThickness, + }; + } + + var axis = Axis(style); + double numShift, denShift; + if (thickness > 0) + { + numShift = C(style.Display ? MathConstant.FractionNumeratorDisplayStyleShiftUp : MathConstant.FractionNumeratorShiftUp, style); + denShift = C(style.Display ? MathConstant.FractionDenominatorDisplayStyleShiftDown : MathConstant.FractionDenominatorShiftDown, style); + var numGap = C(style.Display ? MathConstant.FractionNumDisplayStyleGapMin : MathConstant.FractionNumeratorGapMin, style); + var denGap = C(style.Display ? MathConstant.FractionDenomDisplayStyleGapMin : MathConstant.FractionDenominatorGapMin, style); + numShift = Math.Max(numShift, axis + thickness / 2 + numGap + num.Depth); + denShift = Math.Max(denShift, den.Height + denGap - axis + thickness / 2); + } + else + { + numShift = C(style.Display ? MathConstant.StackTopDisplayStyleShiftUp : MathConstant.StackTopShiftUp, style); + denShift = C(style.Display ? MathConstant.StackBottomDisplayStyleShiftDown : MathConstant.StackBottomShiftDown, style); + var gapMin = C(style.Display ? MathConstant.StackDisplayStyleGapMin : MathConstant.StackGapMin, style); + var gap = numShift - num.Depth - (den.Height - denShift); + if (gap < gapMin) + { + numShift += (gapMin - gap) / 2; + denShift += (gapMin - gap) / 2; + } + } + + var pad = style.Size * 0.12; + var width = Math.Max(num.Width, den.Width) + 2 * pad; + var box = new Box { Width = width }; + box.Add(num, (width - num.Width) / 2, numShift); + box.Add(den, (width - den.Width) / 2, -denShift); + if (thickness > 0) + box.Shapes.Add(ShapeItem.Rule(pad / 2, axis - thickness / 2, width - pad, thickness)); + box.Height = Math.Max(numShift + num.Height, axis + thickness / 2); + box.Depth = Math.Max(denShift + den.Depth, thickness / 2 - axis); + return box; + } + + private Box LayoutRadical(Box radicand, Box? index, Style style) + { + var gap = C(style.Display ? MathConstant.RadicalDisplayStyleVerticalGap : MathConstant.RadicalVerticalGap, style); + var thickness = C(MathConstant.RadicalRuleThickness, style); + var extra = C(MathConstant.RadicalExtraAscender, style); + var target = radicand.Height + radicand.Depth + gap + thickness; + + Box sign; + if (Glyph(0x221A, out var f, out var glyph)) + sign = f.Math?.Vertical(glyph) is { } construction ? StretchGlyph(f, glyph, construction, target, style, true) : GlyphBox(f, glyph, style); + else + sign = LayoutText("√", style, false); + + var total = sign.Height + sign.Depth; + if (total > target) + gap += (total - target) / 2; + var ruleBottom = radicand.Height + gap; + var top = ruleBottom + thickness; + var signShift = top - sign.Height; + + var box = new Box(); + var x = 0.0; + if (index != null) + { + var before = C(MathConstant.RadicalKernBeforeDegree, style); + var after = C(MathConstant.RadicalKernAfterDegree, style); + var raise = (math?[MathConstant.RadicalDegreeBottomRaisePercent] ?? 60) / 100.0; + var indexShift = (signShift - sign.Depth) + raise * total + index.Depth; + box.Add(index, before, indexShift); + x = Math.Max(0, before + index.Width + after); + box.Height = Math.Max(box.Height, indexShift + index.Height); + } + + box.Add(sign, x, signShift); + x += sign.Width; + box.Add(radicand, x, 0); + box.Shapes.Add(ShapeItem.Rule(x, ruleBottom, radicand.Width, thickness)); + box.Width = x + radicand.Width; + box.Height = Math.Max(box.Height, top + extra); + box.Depth = Math.Max(radicand.Depth, sign.Depth - signShift); + return box; + } + + // ---- scripts ---------------------------------------------------------------------------------------------- + + private Box LayoutScripts(MathNode node, int[]? path, Style style) + { + var baseBox = Layout(node.Children[0], Sub(path, 0), style, Stretch.None); + Box? sub = null, sup = null; + switch (node.Name) + { + case "msub": + sub = Layout(node.Children[1], Sub(path, 1), Script(style, true), Stretch.None); + break; + case "msup": + sup = Layout(node.Children[1], Sub(path, 1), Script(style, false), Stretch.None); + break; + default: + sub = Layout(node.Children[1], Sub(path, 1), Script(style, true), Stretch.None); + sup = Layout(node.Children[2], Sub(path, 2), Script(style, false), Stretch.None); + break; + } + return AttachScripts(baseBox, new List<(Box?, Box?)> { (sub, sup) }, new List<(Box?, Box?)>(), style); + } + + private Box LayoutMultiscripts(MathNode node, int[]? path, Style style) + { + var baseBox = Layout(node.Children[0], Sub(path, 0), style, Stretch.None); + var post = new List<(Box?, Box?)>(); + var pre = new List<(Box?, Box?)>(); + var target = post; + var i = 1; + while (i < node.Children.Count) + { + if (node.Children[i].Name == "mprescripts") + { + target = pre; + i++; + continue; + } + var sub = Layout(node.Children[i], Sub(path, i), Script(style, true), Stretch.None); + Box? sup = null; + if (i + 1 < node.Children.Count && node.Children[i + 1].Name != "mprescripts") + sup = Layout(node.Children[i + 1], Sub(path, i + 1), Script(style, false), Stretch.None); + target.Add((sub, sup)); + i += sup == null ? 1 : 2; + } + return AttachScripts(baseBox, post, pre, style); + } + + private Box AttachScripts(Box baseBox, List<(Box? Sub, Box? Sup)> post, List<(Box? Sub, Box? Sup)> pre, Style style) + { + var hasSub = post.Concat(pre).Any(p => p.Sub != null && p.Sub.Width + p.Sub.Height > 0); + var hasSup = post.Concat(pre).Any(p => p.Sup != null && p.Sup.Width + p.Sup.Height > 0); + var subShift = 0.0; + var supShift = 0.0; + if (hasSub) + { + subShift = C(MathConstant.SubscriptShiftDown, style); + foreach (var (sub, _) in post.Concat(pre)) + { + if (sub != null) + subShift = Math.Max(subShift, sub.Height - C(MathConstant.SubscriptTopMax, style)); + } + if (!baseBox.IsToken) + subShift = Math.Max(subShift, baseBox.Depth + C(MathConstant.SubscriptBaselineDropMin, style)); + } + if (hasSup) + { + supShift = C(style.Cramped ? MathConstant.SuperscriptShiftUpCramped : MathConstant.SuperscriptShiftUp, style); + foreach (var (_, sup) in post.Concat(pre)) + { + if (sup != null) + supShift = Math.Max(supShift, sup.Depth + C(MathConstant.SuperscriptBottomMin, style)); + } + if (!baseBox.IsToken) + supShift = Math.Max(supShift, baseBox.Height - C(MathConstant.SuperscriptBaselineDropMax, style)); + } + if (hasSub && hasSup) + { + var supDepth = post.Concat(pre).Max(p => p.Sup?.Depth ?? 0); + var subHeight = post.Concat(pre).Max(p => p.Sub?.Height ?? 0); + var gap = supShift - supDepth - (subHeight - subShift); + var gapMin = C(MathConstant.SubSuperscriptGapMin, style); + if (gap < gapMin) + { + subShift += gapMin - gap; + var maxWithSub = C(MathConstant.SuperscriptBottomMaxWithSubscript, style); + var delta = maxWithSub - (supShift - supDepth); + if (delta > 0) + { + supShift += delta; + subShift -= delta; + } + } + } + + var box = new Box(); + var space = C(MathConstant.SpaceAfterScript, style); + var x = 0.0; + foreach (var (sub, sup) in pre) + { + var w = Math.Max(sub?.Width ?? 0, sup?.Width ?? 0); + if (sub != null) + box.Add(sub, x + w - sub.Width, -subShift); + if (sup != null) + box.Add(sup, x + w - sup.Width, supShift); + x += w + space; + } + + box.Add(baseBox, x, 0); + var italic = baseBox.Italic; + x += baseBox.Width; + var end = x; + var first = true; + foreach (var (sub, sup) in post) + { + var subX = x - (first && baseBox.IsLargeOp ? italic : 0); + var supX = x + (first && !baseBox.IsLargeOp ? italic : 0); + if (sub != null) + { + box.Add(sub, subX, -subShift); + end = Math.Max(end, subX + sub.Width); + } + if (sup != null) + { + box.Add(sup, supX, supShift); + end = Math.Max(end, supX + sup.Width); + } + x = end; + first = false; + } + box.Width = end + (post.Count > 0 ? space : 0); + + box.Height = baseBox.Height; + box.Depth = baseBox.Depth; + foreach (var (sub, sup) in post.Concat(pre)) + { + if (sub != null) + { + box.Height = Math.Max(box.Height, sub.Height - subShift); + box.Depth = Math.Max(box.Depth, sub.Depth + subShift); + } + if (sup != null) + { + box.Height = Math.Max(box.Height, sup.Height + supShift); + box.Depth = Math.Max(box.Depth, sup.Depth - supShift); + } + } + return box; + } + + private Box LayoutUnderOver(MathNode node, int[]? path, Style style, Stretch stretch) + { + var op = CoreOperator(node.Children[0]); + var movable = op != null && (op.GetAttribute("movablelimits") is { } ml + ? ml == "true" + : OperatorDictionary.TryLookup(op.Text, out var e) && e.Has(OperatorFlags.MovableLimits)); + if (movable && !style.Display) + { + var scripts = node.Name switch { "munder" => "msub", "mover" => "msup", _ => "msubsup" }; + return LayoutScripts(MathNode.Element(scripts, node.Children), path, style); + } + + var hasUnder = node.Name is "munder" or "munderover"; + var hasOver = node.Name is "mover" or "munderover"; + var underIndex = hasUnder ? 1 : -1; + var overIndex = node.Name == "mover" ? 1 : node.Name == "munderover" ? 2 : -1; + var accentUnder = hasUnder && IsAccent(node, node.Children[underIndex], "accentunder"); + var accentOver = hasOver && IsAccent(node, node.Children[overIndex], "accent"); + + var baseStyle = style; + var underStyle = accentUnder ? style.WithDisplay(false) : Script(style, false); + var overStyle = accentOver ? style.WithDisplay(false).WithCramped(true) : Script(style, false); + + // stretchy operators take the width of the widest of the others + var stretchyBase = IsHorizontalStretchy(node.Children[0]); + var stretchyUnder = hasUnder && IsHorizontalStretchy(node.Children[underIndex]); + var stretchyOver = hasOver && IsHorizontalStretchy(node.Children[overIndex]); + + Box? baseBox = stretchyBase ? null : Layout(node.Children[0], Sub(path, 0), baseStyle, Stretch.None); + Box? under = hasUnder && !stretchyUnder ? Layout(node.Children[underIndex], Sub(path, underIndex), underStyle, new Stretch(0, 0, 0, accentUnder)) : null; + Box? over = hasOver && !stretchyOver ? Layout(node.Children[overIndex], Sub(path, overIndex), overStyle, new Stretch(0, 0, 0, accentOver)) : null; + var width = new[] { baseBox, under, over }.Where(b => b != null).Select(b => b!.Width).DefaultIfEmpty(0).Max(); + if (stretch.Width > width) + width = stretch.Width; + if (width <= 0) + width = style.Size; + baseBox ??= Layout(node.Children[0], Sub(path, 0), baseStyle, new Stretch(0, 0, width, false)); + if (hasUnder && under == null) + under = Layout(node.Children[underIndex], Sub(path, underIndex), underStyle, new Stretch(0, 0, width, accentUnder, true)); + if (hasOver && over == null) + over = Layout(node.Children[overIndex], Sub(path, overIndex), overStyle, new Stretch(0, 0, width, accentOver)); + + var total = Math.Max(baseBox.Width, Math.Max(under?.Width ?? 0, over?.Width ?? 0)); + var baseX = (total - baseBox.Width) / 2; + var placed = new List<(Box Box, double X, double Shift)> { (baseBox, baseX, 0) }; + var height = baseBox.Height; + var depth = baseBox.Depth; + + if (over != null) + { + double shift; + double x; + if (accentOver) + { + shift = Math.Max(0, baseBox.Height - C(MathConstant.AccentBaseHeight, style)); + var attach = baseX + (baseBox.TopAccent ?? baseBox.Width / 2); + x = attach - (over.TopAccent ?? over.Width / 2); + } + else + { + double gap, rise, extra = 0; + if (baseBox.IsLargeOp) + { + gap = C(MathConstant.UpperLimitGapMin, style); + rise = C(MathConstant.UpperLimitBaselineRiseMin, style); + } + else if (stretchyOver) + { + gap = C(MathConstant.StretchStackGapBelowMin, style); + rise = C(MathConstant.StretchStackTopShiftUp, style); + } + else + { + gap = C(MathConstant.OverbarVerticalGap, style); + rise = 0; + extra = C(MathConstant.OverbarExtraAscender, style); + } + shift = baseBox.Height + Math.Max(gap + over.Depth, rise); + height = Math.Max(height, shift + over.Height + extra); + x = (total - over.Width) / 2; + if (baseBox.IsLargeOp) + x += baseBox.Italic / 2; + } + placed.Add((over, x, shift)); + height = Math.Max(height, shift + over.Height); + depth = Math.Max(depth, over.Depth - shift); + } + + if (under != null) + { + double shift; + if (accentUnder) + { + shift = baseBox.Depth + under.Height; + } + else + { + double gap, drop, extra = 0; + if (baseBox.IsLargeOp) + { + gap = C(MathConstant.LowerLimitGapMin, style); + drop = C(MathConstant.LowerLimitBaselineDropMin, style); + } + else if (stretchyUnder) + { + gap = C(MathConstant.StretchStackGapAboveMin, style); + drop = C(MathConstant.StretchStackBottomShiftDown, style); + } + else + { + gap = C(MathConstant.UnderbarVerticalGap, style); + drop = 0; + extra = C(MathConstant.UnderbarExtraDescender, style); + } + shift = baseBox.Depth + Math.Max(gap + under.Height, drop); + depth = Math.Max(depth, shift + under.Depth + extra); + } + var x = (total - under.Width) / 2 - (baseBox.IsLargeOp ? baseBox.Italic / 2 : 0); + placed.Add((under, x, -shift)); + depth = Math.Max(depth, shift + under.Depth); + height = Math.Max(height, under.Height - shift); + } + + var minX = placed.Min(p => p.X); + var maxX = placed.Max(p => p.X + p.Box.Width); + var box = new Box { Width = maxX - minX, Height = height, Depth = depth, IsLargeOp = baseBox.IsLargeOp }; + foreach (var (b, x, shift) in placed) + box.Add(b, x - minX, shift); + return box; + } + + private static bool IsAccent(MathNode node, MathNode script, string attribute) + { + if (node.GetAttribute(attribute) is { } value) + return value == "true"; + var op = CoreOperatorOfSlot(script); + if (op == null) + return false; + if (op.GetAttribute("accent") is { } accent) + return accent == "true"; + return OperatorDictionary.TryLookup(op.Text, out var entry) && entry.Has(OperatorFlags.Accent); + } + + // ---- tables and enclosures -------------------------------------------------------------------------------- + + private Box LayoutTable(MathNode table, int[]? path, Style style) + { + var cellStyle = style.WithDisplay(false); + var rows = new List<(List Cells, Box? Label, MathNode Row)>(); + var columns = 0; + for (var r = 0; r < table.Children.Count; r++) + { + var row = table.Children[r]; + var cells = new List(); + Box? label = null; + for (var c = 0; c < row.Children.Count; c++) + { + var cell = row.Children[c]; + var cellBox = Layout(cell.Name == "mtd" && cell.Children.Count == 1 ? cell.Children[0] : cell, cell.Name == "mtd" && cell.Children.Count == 1 ? Sub(path, r, c, 0) : null, cellStyle, Stretch.None); + if (row.Name == "mlabeledtr" && c == 0) + label = cellBox; + else + cells.Add(cellBox); + } + columns = Math.Max(columns, cells.Count); + rows.Add((cells, label, row)); + } + + var widths = new double[columns]; + foreach (var (cells, _, _) in rows) + { + for (var c = 0; c < cells.Count; c++) + widths[c] = Math.Max(widths[c], cells[c].Width); + } + + var columnSpacing = Lists(table.GetAttribute("columnspacing"), "0.8em", style, columns - 1); + var rowSpacing = Lists(table.GetAttribute("rowspacing"), "1.0ex", style, rows.Count - 1); + var aligns = (table.GetAttribute("columnalign") ?? "center").Split(Spaces, StringSplitOptions.RemoveEmptyEntries); + + var lefts = new double[columns]; + var x = 0.0; + for (var c = 0; c < columns; c++) + { + lefts[c] = x; + x += widths[c] + (c < columns - 1 ? columnSpacing[c] : 0); + } + var tableWidth = x; + + var box = new Box(); + var y = 0.0; + var placedRows = new List<(double Baseline, (List Cells, Box? Label, MathNode Row) Row)>(); + for (var r = 0; r < rows.Count; r++) + { + var (cells, label, _) = rows[r]; + var h = cells.Select(c => c.Height).DefaultIfEmpty(0).Max(); + var d = cells.Select(c => c.Depth).DefaultIfEmpty(0).Max(); + if (label != null) + { + h = Math.Max(h, label.Height); + d = Math.Max(d, label.Depth); + } + h = Math.Max(h, style.Size * 0.6); + d = Math.Max(d, style.Size * 0.2); + y -= h; + placedRows.Add((y, rows[r])); + y -= d + (r < rows.Count - 1 ? rowSpacing[r] : 0); + } + var total = -y; + var shift = total / 2 + Axis(style); + var labelX = tableWidth + style.Size; + var hasLabels = false; + foreach (var (baseline, (cells, label, row)) in placedRows) + { + var rowAligns = row.GetAttribute("columnalign")?.Split(Spaces, StringSplitOptions.RemoveEmptyEntries); + var cellOffset = row.Name == "mlabeledtr" ? 1 : 0; + for (var c = 0; c < cells.Count; c++) + { + var cellNode = row.Children[c + cellOffset]; + var align = cellNode.GetAttribute("columnalign") ?? Pick(rowAligns, c) ?? Pick(aligns, c) ?? "center"; + var cx = align switch + { + "left" => lefts[c], + "right" => lefts[c] + widths[c] - cells[c].Width, + _ => lefts[c] + (widths[c] - cells[c].Width) / 2, + }; + box.Add(cells[c], cx, baseline + shift); + } + if (label != null) + { + box.Add(label, labelX, baseline + shift); + hasLabels = true; + } + } + box.Width = hasLabels ? labelX + placedRows.Select(p => p.Row.Label?.Width ?? 0).Max() : tableWidth; + box.Height = shift; + box.Depth = total - shift; + return box; + } + + private static string? Pick(string[]? values, int index) => + values == null || values.Length == 0 ? null : values[Math.Min(index, values.Length - 1)]; + + private double[] Lists(string? value, string fallback, Style style, int count) + { + var parts = (value ?? fallback).Split(Spaces, StringSplitOptions.RemoveEmptyEntries); + var result = new double[Math.Max(0, count)]; + for (var i = 0; i < result.Length; i++) + { + var part = parts.Length == 0 ? fallback : parts[Math.Min(i, parts.Length - 1)]; + result[i] = Lengths.TryParse(part, style.Size, Ex(style), style.Size, out var length) ? length : style.Size * 0.8; + } + return result; + } + + private Box LayoutEnclose(MathNode node, int[]? path, Style style) + { + var notations = new HashSet((node.GetAttribute("notation") ?? "longdiv").Split(Spaces, StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); + if (notations.Contains("radical")) + return LayoutRadical(LayoutInferred(node, path, style.WithCramped(true), Stretch.None), null, style); + + var content = LayoutInferred(node, path, style, Stretch.None); + var t = C(MathConstant.OverbarRuleThickness, style); + var pad = style.Size * 0.15; + bool Has(params string[] names) => names.Any(notations.Contains); + + var left = Has("box", "roundedbox", "left", "longdiv", "circle") ? pad + t : 0; + var right = Has("box", "roundedbox", "right", "actuarial", "madruwb", "circle") ? pad + t : 0; + var top = Has("box", "roundedbox", "top", "actuarial", "longdiv", "circle") ? pad + t : 0; + var bottom = Has("box", "roundedbox", "madruwb", "circle", "phasorangle") ? pad + t : 0; + if (notations.Contains("bottom")) + bottom = Math.Max(bottom, C(MathConstant.UnderbarVerticalGap, style) + C(MathConstant.UnderbarRuleThickness, style)); + if (notations.Contains("top") && !Has("box", "roundedbox")) + top = Math.Max(top, C(MathConstant.OverbarVerticalGap, style) + t); + if (notations.Contains("circle")) + { + left += pad; + right += pad; + } + + var box = Box.Wrap(content, left, 0); + box.Width = left + content.Width + right; + box.Height = content.Height + top; + box.Depth = content.Depth + bottom; + var x0 = t / 2; + var x1 = box.Width - t / 2; + var y0 = -box.Depth + t / 2; + var y1 = box.Height - t / 2; + void Line(double ax, double ay, double bx, double by) => box.Shapes.Add(ShapeItem.Line(ax, ay, bx, by, t)); + + if (Has("box", "roundedbox")) + box.Shapes.Add(new ShapeItem(MathShapeKind.Outline, MathShapeRole.Ink, x0, y0, x1 - x0, y1 - y0, thickness: t)); + if (notations.Contains("circle")) + box.Shapes.Add(new ShapeItem(MathShapeKind.Ellipse, MathShapeRole.Ink, x0, y0, x1 - x0, y1 - y0, thickness: t)); + if (Has("left", "longdiv")) + Line(x0, y0, x0, y1); + if (Has("right", "actuarial", "madruwb")) + Line(x1, y0, x1, y1); + if (Has("top", "actuarial", "longdiv")) + Line(x0, y1, x1, y1); + if (Has("bottom", "madruwb", "phasorangle")) + Line(x0, y0, x1, y0); + if (Has("updiagonalstrike", "updiagonalarrow")) + Line(left, -content.Depth, left + content.Width, content.Height); + if (notations.Contains("downdiagonalstrike")) + Line(left, content.Height, left + content.Width, -content.Depth); + if (notations.Contains("verticalstrike")) + Line(left + content.Width / 2, -content.Depth, left + content.Width / 2, content.Height); + if (notations.Contains("horizontalstrike")) + Line(left, Axis(style), left + content.Width, Axis(style)); + if (notations.Contains("phasorangle")) + Line(x0, y0, x0 + (y1 - y0) / 2, y1); + return box; + } + } + + internal static class MathTreePaths + { + public static int[] Append(int[] path, int[] more) + { + var result = new int[path.Length + more.Length]; + path.CopyTo(result, 0); + more.CopyTo(result, path.Length); + return result; + } + } +} diff --git a/src/SharpEquations.Rendering/Layout/OperatorDictionary.cs b/src/SharpEquations.Rendering/Layout/OperatorDictionary.cs new file mode 100644 index 0000000..33255e2 --- /dev/null +++ b/src/SharpEquations.Rendering/Layout/OperatorDictionary.cs @@ -0,0 +1,120 @@ +namespace SharpEquations.Rendering.Layout +{ + using System; + using System.Collections.Generic; + using System.Text; + + internal enum OperatorForm + { + Prefix, + Infix, + Postfix, + } + + [Flags] + internal enum OperatorFlags + { + None = 0, + Stretchy = 1, + Symmetric = 2, + LargeOp = 4, + MovableLimits = 8, + Fence = 16, + Accent = 32, + Horizontal = 64, + } + + /// The properties of an operator: its spaces, in eighteenths of an em, and its flags. + internal readonly struct OperatorEntry + { + public OperatorEntry(int lspace, int rspace, OperatorFlags flags) + { + LSpace = lspace; + RSpace = rspace; + Flags = flags; + } + + public int LSpace { get; } + + public int RSpace { get; } + + public OperatorFlags Flags { get; } + + public bool Has(OperatorFlags flag) => (Flags & flag) == flag; + } + + /// + /// The operator dictionary of MathML Core, for the operators equations use: the spaces around each form of an + /// operator, and whether it stretches, is a large operator, takes limits that move in inline style, is an accent. + /// + internal static class OperatorDictionary + { + private const OperatorFlags FenceV = OperatorFlags.Stretchy | OperatorFlags.Symmetric | OperatorFlags.Fence; + private const OperatorFlags ArrowH = OperatorFlags.Stretchy | OperatorFlags.Horizontal; + private const OperatorFlags AccentH = OperatorFlags.Accent | OperatorFlags.Stretchy | OperatorFlags.Horizontal; + + private static readonly Dictionary<(string, OperatorForm), OperatorEntry> Entries = Build(); + + public static OperatorEntry Lookup(string text, OperatorForm form) + { + if (Entries.TryGetValue((text, form), out var entry)) + return entry; + foreach (var other in new[] { OperatorForm.Infix, OperatorForm.Postfix, OperatorForm.Prefix }) + { + if (Entries.TryGetValue((text, other), out entry)) + return entry; + } + return form == OperatorForm.Infix ? new OperatorEntry(5, 5, OperatorFlags.None) : new OperatorEntry(0, 0, OperatorFlags.None); + } + + public static bool TryLookup(string text, out OperatorEntry entry) + { + foreach (var form in new[] { OperatorForm.Infix, OperatorForm.Postfix, OperatorForm.Prefix }) + { + if (Entries.TryGetValue((text, form), out entry)) + return true; + } + entry = default; + return false; + } + + private static Dictionary<(string, OperatorForm), OperatorEntry> Build() + { + var d = new Dictionary<(string, OperatorForm), OperatorEntry>(); + + void Add(string chars, OperatorForm form, int l, int r, OperatorFlags flags = OperatorFlags.None) + { + foreach (var rune in chars.EnumerateRunes()) + d[(rune.ToString(), form)] = new OperatorEntry(l, r, flags); + } + + Add("([{|‖⌈⌊⟨⟦⟪〈⟮⦃⦅⦇⦉❲〖", OperatorForm.Prefix, 0, 0, FenceV); + Add(")]}|‖⌉⌋⟩⟧⟫〉⟯⦄⦆⦈⦊❳〗", OperatorForm.Postfix, 0, 0, FenceV); + Add("|‖∣∥│", OperatorForm.Infix, 5, 5, OperatorFlags.Stretchy | OperatorFlags.Symmetric); + Add("/∕⁄", OperatorForm.Infix, 1, 1, OperatorFlags.Stretchy | OperatorFlags.Symmetric); + Add("\\∖", OperatorForm.Infix, 4, 4); + + Add("=≠<>≤≥≦≧≪≫≈≉≡≢≅≃≄∼≍∝≺≻⪯⪰∈∉∋∌⊂⊃⊆⊇⊊⊋⊄⊅⊏⊐⊑⊒⊢⊣⊤⊨⊩⊥∥∦≔≕≝≜≐≑≗≙≟≲≳⋘⋙⩽⩾∷∶:", OperatorForm.Infix, 5, 5); + Add("→←↔⇒⇐⇔↦⟶⟵⟷⟹⟸⟺⟼↪↩⇀↼⇁↽⇌⇋↠↞⇄⇆⤳↣↢⇉⇇⇶", OperatorForm.Infix, 5, 5, ArrowH); + Add("↑↓↕⇑⇓⇕↗↘↙↖", OperatorForm.Infix, 5, 5, OperatorFlags.Stretchy); + + Add("+-−±∓∪∩⊎∨∧⊕⊖⊔⊓∔⊻⊼⊽⋎⋏", OperatorForm.Infix, 4, 4); + Add("+-−±∓¬", OperatorForm.Prefix, 0, 0); + Add("×·⋅∗∘∙⊗⊙⊘÷⋆⨯⋄⋇⊛⊚⊡⊠⊟⊞⋉⋊⋋⋌≀", OperatorForm.Infix, 4, 4); + + Add(",;", OperatorForm.Infix, 0, 3); + Add("…⋯⋮⋰⋱", OperatorForm.Infix, 0, 0); + Add("!′″‴⁗°%", OperatorForm.Postfix, 0, 0); + Add("∂∇∀∃∄∠∡∢√∛∜", OperatorForm.Prefix, 0, 0); + + Add("∑∏∐⋀⋁⋂⋃⨀⨁⨂⨄⨆⨉", OperatorForm.Prefix, 1, 2, OperatorFlags.LargeOp | OperatorFlags.MovableLimits | OperatorFlags.Symmetric); + Add("∫∬∭⨌∮∯∰∱∲∳⨍⨎⨏⨐⨑⨒⨓⨔⨕⨖⨗⨘⨙⨚⨛⨜", OperatorForm.Prefix, 1, 2, OperatorFlags.LargeOp | OperatorFlags.Symmetric); + + Add("⁡⁢⁣⁤", OperatorForm.Infix, 0, 0); + + Add("‾¯_^ˆˇ˜~̲̂̃̌̄̅⏞⏟⏜⏝⎴⎵⏠⏡⃗⃖⃡⃐⃑=", OperatorForm.Postfix, 0, 0, AccentH); + Add("˙¨´`˘˚̇̈́̀̆̊⃛⃜", OperatorForm.Postfix, 0, 0, OperatorFlags.Accent); + return d; + } + } +} diff --git a/src/SharpEquations.Rendering/Layout/Style.cs b/src/SharpEquations.Rendering/Layout/Style.cs new file mode 100644 index 0000000..f4bcf2e --- /dev/null +++ b/src/SharpEquations.Rendering/Layout/Style.cs @@ -0,0 +1,135 @@ +namespace SharpEquations.Rendering.Layout +{ + using System; + using System.Globalization; + + /// The style an element is laid out in: its size, script level, display style and whether it is cramped. + internal readonly struct Style + { + public Style(double size, int level, bool display, bool cramped, string? color) + { + Size = size; + Level = level; + Display = display; + Cramped = cramped; + Color = color; + } + + /// The size of the em, in the units of the layout. + public double Size { get; } + + public int Level { get; } + + public bool Display { get; } + + public bool Cramped { get; } + + public string? Color { get; } + + public Style WithLevel(int level, double scriptPercent, double scriptScriptPercent) => + new Style(Size * Scale(level, scriptPercent, scriptScriptPercent) / Scale(Level, scriptPercent, scriptScriptPercent), level, Display, Cramped, Color); + + public Style WithDisplay(bool display) => new Style(Size, Level, display, Cramped, Color); + + public Style WithCramped(bool cramped) => new Style(Size, Level, Display, cramped, Color); + + public Style WithColor(string? color) => new Style(Size, Level, Display, Cramped, color); + + public Style WithSize(double size) => new Style(size, Level, Display, Cramped, Color); + + private static double Scale(int level, double script, double scriptScript) + { + if (level <= 0) + return Math.Pow(1 / 0.71, -level); + if (level == 1) + return script; + return scriptScript * Math.Pow(0.71, level - 2); + } + } + + /// The lengths of MathML: numbers with units, percentages and the named spaces. + internal static class Lengths + { + /// + /// Parses a length; a percentage, or a number without unit, is of . Returns false + /// if the value is not a length. + /// + public static bool TryParse(string? value, double em, double ex, double reference, out double length) + { + length = 0; + if (value == null) + return false; + var v = value.Trim(); + var named = NamedSpace(v); + if (named != null) + { + length = named.Value * em / 18; + return true; + } + + var i = 0; + while (i < v.Length && (char.IsDigit(v[i]) || v[i] is '.' or '-' or '+')) + i++; + if (!double.TryParse(v.Substring(0, i), NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + return false; + var unit = v.Substring(i).Trim(); + switch (unit) + { + case "": + length = number * reference; + return true; + case "%": + length = number / 100 * reference; + return true; + case "em": + length = number * em; + return true; + case "ex": + length = number * ex; + return true; + case "px": + length = number; + return true; + case "pt": + length = number * 96 / 72; + return true; + case "pc": + length = number * 16; + return true; + case "in": + length = number * 96; + return true; + case "cm": + length = number * 96 / 2.54; + return true; + case "mm": + length = number * 9.6 / 2.54; + return true; + case "mu": + length = number * em / 18; + return true; + default: + return false; + } + } + + private static int? NamedSpace(string v) => v switch + { + "veryverythinmathspace" => 1, + "verythinmathspace" => 2, + "thinmathspace" => 3, + "mediummathspace" => 4, + "thickmathspace" => 5, + "verythickmathspace" => 6, + "veryverythickmathspace" => 7, + "negativeveryverythinmathspace" => -1, + "negativeverythinmathspace" => -2, + "negativethinmathspace" => -3, + "negativemediummathspace" => -4, + "negativethickmathspace" => -5, + "negativeverythickmathspace" => -6, + "negativeveryverythickmathspace" => -7, + _ => null, + }; + } +} diff --git a/src/SharpEquations.Rendering/MathLayout.cs b/src/SharpEquations.Rendering/MathLayout.cs new file mode 100644 index 0000000..c35121d --- /dev/null +++ b/src/SharpEquations.Rendering/MathLayout.cs @@ -0,0 +1,310 @@ +namespace SharpEquations.Rendering +{ + using System; + using System.Collections.Generic; + using System.Linq; + using SharpEquations.Editing; + + /// A rectangle, with y down from the top of the equation. + public readonly struct MathRect + { + /// A rectangle. + public MathRect(double x, double y, double width, double height) + { + X = x; + Y = y; + Width = width; + Height = height; + } + + /// The left. + public double X { get; } + + /// The top. + public double Y { get; } + + /// The width. + public double Width { get; } + + /// The height. + public double Height { get; } + + /// The right. + public double Right => X + Width; + + /// The bottom. + public double Bottom => Y + Height; + + /// + public override string ToString() => $"{X:0.##},{Y:0.##} {Width:0.##}×{Height:0.##}"; + } + + /// A glyph to draw: its font, its origin on the baseline (y down from the top) and its size. + public readonly struct MathGlyph + { + internal MathGlyph(IMathFont font, ushort glyph, double x, double y, double size, string? color) + { + Font = font; + Glyph = glyph; + X = x; + Y = y; + Size = size; + Color = color; + } + + /// The font of the glyph. + public IMathFont Font { get; } + + /// The glyph. + public ushort Glyph { get; } + + /// The x of the origin of the glyph. + public double X { get; } + + /// The y of the baseline of the glyph. + public double Y { get; } + + /// The size of the em, in the units of the layout. + public double Size { get; } + + /// The colour of mathcolor, or null for the foreground. + public string? Color { get; } + } + + /// The kind of a . + public enum MathShapeKind + { + /// A filled rectangle: a fraction bar, the bar of a radical, an underbar. + Fill, + + /// The outline of a rectangle. + Outline, + + /// A line from (X, Y) to (X2, Y2). + Line, + + /// The outline of the ellipse in the rectangle. + Ellipse, + } + + /// What a is, so that a toolkit draws it with the right brush. + public enum MathShapeRole + { + /// Part of the equation, drawn with the foreground. + Ink, + + /// The background of mathbackground, with its . + Background, + + /// The box of an empty slot in an editor. + Placeholder, + + /// The background of merror. + Error, + } + + /// A shape to draw. + public readonly struct MathShape + { + internal MathShape(MathShapeKind kind, MathShapeRole role, double x, double y, double width, double height, double x2, double y2, double thickness, string? color) + { + Kind = kind; + Role = role; + X = x; + Y = y; + Width = width; + Height = height; + X2 = x2; + Y2 = y2; + Thickness = thickness; + Color = color; + } + + /// The kind of the shape. + public MathShapeKind Kind { get; } + + /// What the shape is. + public MathShapeRole Role { get; } + + /// The left of the rectangle, or the x of the start of the line. + public double X { get; } + + /// The top of the rectangle, or the y of the start of the line. + public double Y { get; } + + /// The width of the rectangle. + public double Width { get; } + + /// The height of the rectangle. + public double Height { get; } + + /// The x of the end of the line. + public double X2 { get; } + + /// The y of the end of the line. + public double Y2 { get; } + + /// The thickness of an outline or a line. + public double Thickness { get; } + + /// The colour of mathcolor or mathbackground, or null. + public string? Color { get; } + } + + /// + /// A laid out equation: what to draw, its size, and the geometry of the caret and the selection for an editor. + /// + public sealed class MathLayout : IMathGeometry + { + private readonly List rows; + private readonly Dictionary byPath; + + internal MathLayout(double width, double height, double baseline, IReadOnlyList glyphs, IReadOnlyList shapes, List rows) + { + Width = width; + Height = height; + Baseline = baseline; + Glyphs = glyphs; + Shapes = shapes; + this.rows = rows; + byPath = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + byPath[Key(row.Path)] = row; + } + + /// The width of the equation. + public double Width { get; } + + /// The height of the equation, from the top of its ink to the bottom. + public double Height { get; } + + /// The y of the baseline. + public double Baseline { get; } + + /// The glyphs to draw. + public IReadOnlyList Glyphs { get; } + + /// The shapes to draw, after the backgrounds and before the glyphs. + public IReadOnlyList Shapes { get; } + + /// The rectangle of the caret at a position, or null if the position is not laid out. + public MathRect? GetCaretRect(MathPosition position) + { + if (!byPath.TryGetValue(Key(position.Path), out var row) || position.Offset >= row.CaretX.Length) + return null; + return new MathRect(row.CaretX[position.Offset], row.Top, 0, row.Bottom - row.Top); + } + + /// The rectangles to highlight for a selection. + public IReadOnlyList GetSelectionRects(MathSelection selection) + { + if (!byPath.TryGetValue(Key(selection.Path), out var row) || selection.End >= row.CaretX.Length) + return Array.Empty(); + var x0 = row.CaretX[selection.Start]; + var x1 = row.CaretX[selection.End]; + if (!row.IsRun) + { + // the extent of the children selected, which may stand out of the row's own box + var top = row.Top; + var bottom = row.Bottom; + foreach (var other in rows) + { + if (other.Path.Length > row.Path.Length && other.Path.Take(row.Path.Length).SequenceEqual(row.Path) + && other.Path[row.Path.Length] >= selection.Start && other.Path[row.Path.Length] < selection.End) + { + top = Math.Min(top, other.Top); + bottom = Math.Max(bottom, other.Bottom); + } + } + return new[] { new MathRect(Math.Min(x0, x1), top, Math.Abs(x1 - x0), bottom - top) }; + } + return new[] { new MathRect(Math.Min(x0, x1), row.Top, Math.Abs(x1 - x0), row.Bottom - row.Top) }; + } + + /// The position of the caret nearest to a point. + public MathPosition? HitTest(double x, double y) + { + RowGeometry? best = null; + var bestDistance = double.MaxValue; + foreach (var row in rows) + { + var dx = x < row.Left ? row.Left - x : x > row.Right ? x - row.Right : 0; + var dy = y < row.Top ? row.Top - y : y > row.Bottom ? y - row.Bottom : 0; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (best == null || distance < bestDistance - 0.01 + || Math.Abs(distance - bestDistance) <= 0.01 && (row.Path.Length > best.Path.Length || row.Path.Length == best.Path.Length && row.Area < best.Area)) + { + best = row; + bestDistance = distance; + } + } + if (best == null) + return null; + return best.IsRun ? new MathPosition(best.Path, Nearest(best.CaretX, x)) : GetNearestPosition(best.Path, x); + } + + /// + public double? GetCaretX(MathPosition position) => + byPath.TryGetValue(Key(position.Path), out var row) && position.Offset < row.CaretX.Length ? row.CaretX[position.Offset] : null; + + /// + public MathPosition? GetNearestPosition(IReadOnlyList rowPath, double x) + { + if (!byPath.TryGetValue(Key(rowPath), out var row)) + return null; + foreach (var run in rows) + { + if (run.IsRun && run.Path.Length == rowPath.Count + 1 && run.Path.Take(rowPath.Count).SequenceEqual(rowPath) + && x > run.Left && x < run.Right) + { + return new MathPosition(run.Path, Nearest(run.CaretX, x)); + } + } + return new MathPosition(rowPath, Nearest(row.CaretX, x)); + } + + private static int Nearest(double[] xs, double x) + { + var best = 0; + for (var i = 1; i < xs.Length; i++) + { + if (Math.Abs(xs[i] - x) < Math.Abs(xs[best] - x)) + best = i; + } + return best; + } + + private static string Key(IEnumerable path) => string.Join("/", path); + } + + /// The geometry of a row or a run, in the coordinates of the layout. + internal sealed class RowGeometry + { + public RowGeometry(int[] path, bool isRun, double[] caretX, double top, double bottom, double left, double right) + { + Path = path; + IsRun = isRun; + CaretX = caretX; + Top = top; + Bottom = bottom; + Left = left; + Right = right; + } + + public int[] Path { get; } + + public bool IsRun { get; } + + public double[] CaretX { get; } + + public double Top { get; } + + public double Bottom { get; } + + public double Left { get; } + + public double Right { get; } + + public double Area => (Right - Left) * (Bottom - Top); + } +} diff --git a/src/SharpEquations.Rendering/MathLayoutEngine.cs b/src/SharpEquations.Rendering/MathLayoutEngine.cs new file mode 100644 index 0000000..12d4acf --- /dev/null +++ b/src/SharpEquations.Rendering/MathLayoutEngine.cs @@ -0,0 +1,92 @@ +namespace SharpEquations.Rendering +{ + using System; + using System.Collections.Generic; + using System.Xml.Linq; + using SharpEquations.Editing; + using SharpEquations.Rendering.Layout; + + /// The options of a layout. + public sealed class MathLayoutOptions + { + /// The size of the em, in the units of the layout (device independent pixels for a toolkit); 16 by default. + public double FontSize { get; set; } = 16; + + /// Lays out in display style even if the math element is inline. + public bool Display { get; set; } + + /// Draws a box in the empty slots, for an editor. + public bool ShowPlaceholders { get; set; } + } + + /// + /// Lays out presentation MathML after MathML Core, with the OpenType MATH table of a math font. + /// + public static class MathLayoutEngine + { + /// Lays out an equation. + public static MathLayout Layout(Equation equation, IMathFont font, MathLayoutOptions? options = null) + { + if (equation == null) + throw new ArgumentNullException(nameof(equation)); + return Layout(MathNode.FromMathML(equation.ToMathML()), font, options); + } + + /// Lays out a math element of MathML. + public static MathLayout Layout(XElement math, IMathFont font, MathLayoutOptions? options = null) + { + if (math == null) + throw new ArgumentNullException(nameof(math)); + return Layout(MathNode.FromMathML(math), font, options); + } + + /// + /// Lays out the math node of an editor (), with the geometry of the caret + /// in every row and run. + /// + public static MathLayout Layout(MathNode root, IMathFont font, MathLayoutOptions? options = null) + { + if (root == null) + throw new ArgumentNullException(nameof(root)); + if (font == null) + throw new ArgumentNullException(nameof(font)); + + var engine = new Engine(font, options ?? new MathLayoutOptions()); + var box = engine.LayoutRoot(root, Array.Empty()); + + var glyphs = new List(); + var shapes = new List(); + var rows = new List(); + Flatten(box, 0, box.Height, null, false, glyphs, shapes, rows); + return new MathLayout(box.Width, box.Height + box.Depth, box.Height, glyphs, shapes, rows); + } + + private static void Flatten(Box box, double x, double baseline, string? color, bool invisible, List glyphs, List shapes, List rows) + { + color = box.Color ?? color; + invisible |= box.Invisible; + if (!invisible) + { + foreach (var shape in box.Shapes) + { + var top = baseline - shape.Y - shape.Height; + shapes.Add(new MathShape(shape.Kind, shape.Role, x + shape.X, shape.Kind == MathShapeKind.Line ? baseline - shape.Y : top, + shape.Width, shape.Height, x + shape.X2, baseline - shape.Y2, shape.Thickness, shape.Color ?? color)); + } + foreach (var glyph in box.Glyphs) + glyphs.Add(new MathGlyph(glyph.Font, glyph.Glyph, x + glyph.X, baseline - glyph.Shift, glyph.Size, color)); + } + + if (box.Path != null && box.CaretX != null) + { + var caret = new double[box.CaretX.Length]; + for (var i = 0; i < caret.Length; i++) + caret[i] = x + box.CaretX[i]; + rows.Add(new RowGeometry(box.Path, box.IsRun, caret, baseline - box.CaretHeight, baseline + box.CaretDepth, x, x + Math.Max(box.Width, 1))); + } + + foreach (var (child, cx, shift) in box.Children) + Flatten(child, x + cx, baseline - shift, color, invisible, glyphs, shapes, rows); + } + } +} diff --git a/src/SharpEquations.Rendering/OpenType/FontFile.cs b/src/SharpEquations.Rendering/OpenType/FontFile.cs new file mode 100644 index 0000000..9b807a6 --- /dev/null +++ b/src/SharpEquations.Rendering/OpenType/FontFile.cs @@ -0,0 +1,82 @@ +namespace SharpEquations.Rendering.OpenType +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// The tables of an OpenType font file, or of one font of a collection (.ttc). + public sealed class FontFile + { + private readonly byte[] data; + private readonly Dictionary tables; + + private FontFile(byte[] data, Dictionary tables) + { + this.data = data; + this.tables = tables; + } + + /// + /// Reads the table directory of a font file. In a collection, the font is the one with that number of glyphs + /// (when it is not negative) or else the first with a MATH table, or the first. + /// + /// The bytes are not an OpenType font. + public static FontFile Parse(byte[] data, int glyphCount = -1) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + try + { + if (data.Length >= 12 && Encoding.ASCII.GetString(data, 0, 4) == "ttcf") + { + var count = (int)U32(data, 8); + var faces = new List>(); + for (var i = 0; i < count; i++) + faces.Add(Directory(data, (int)U32(data, 12 + i * 4))); + foreach (var face in faces) + { + if (glyphCount >= 0 && face.TryGetValue("maxp", out var maxp) && U16(data, maxp.Item1 + 4) == glyphCount) + return new FontFile(data, face); + } + foreach (var face in faces) + { + if (face.ContainsKey("MATH")) + return new FontFile(data, face); + } + return new FontFile(data, faces[0]); + } + return new FontFile(data, Directory(data, 0)); + } + catch (Exception e) when (e is IndexOutOfRangeException or ArgumentOutOfRangeException) + { + throw new FormatException("Not an OpenType font.", e); + } + } + + /// A copy of a table, or null if the font has none. + public byte[]? Table(string tag) + { + if (!tables.TryGetValue(tag, out var table) || table.Offset + table.Length > data.Length) + return null; + var copy = new byte[table.Length]; + Array.Copy(data, table.Offset, copy, 0, table.Length); + return copy; + } + + private static Dictionary Directory(byte[] data, int at) + { + var count = U16(data, at + 4); + var tables = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < count; i++) + { + var record = at + 12 + i * 16; + tables[Encoding.ASCII.GetString(data, record, 4)] = ((int)U32(data, record + 8), (int)U32(data, record + 12)); + } + return tables; + } + + private static int U16(byte[] d, int at) => d[at] << 8 | d[at + 1]; + + private static uint U32(byte[] d, int at) => (uint)(d[at] << 24 | d[at + 1] << 16 | d[at + 2] << 8 | d[at + 3]); + } +} diff --git a/src/SharpEquations.Rendering/OpenType/GlyphOutlines.cs b/src/SharpEquations.Rendering/OpenType/GlyphOutlines.cs new file mode 100644 index 0000000..a77258d --- /dev/null +++ b/src/SharpEquations.Rendering/OpenType/GlyphOutlines.cs @@ -0,0 +1,595 @@ +namespace SharpEquations.Rendering.OpenType +{ + using System; + using System.Collections.Generic; + + /// + /// The ink boxes of the glyphs of a font, read from its outlines: the boxes of the glyf table of a + /// TrueType font, or the outlines of the charstrings of the CFF table of an OpenType font, so that the + /// layout depends on no rasterizer. + /// + public sealed class GlyphOutlines + { + private readonly Func reader; + private readonly Dictionary cache = new Dictionary(); + + private GlyphOutlines(Func reader) => this.reader = reader; + + /// + /// The outlines of a font from its tables, given by their tag (head, loca, glyf, + /// CFF ); null if the font has neither TrueType nor CFF outlines. + /// + public static GlyphOutlines? Create(Func table) + { + if (table == null) + throw new ArgumentNullException(nameof(table)); + try + { + if (table("CFF ") is { Length: > 0 } cff) + return new GlyphOutlines(new Cff(cff).Bounds); + if (table("glyf") is { Length: > 0 } glyf && table("loca") is { } loca && table("head") is { Length: >= 54 } head) + { + var longOffsets = head[50] << 8 | head[51]; + return new GlyphOutlines(glyph => TrueType(glyf, loca, longOffsets != 0, glyph)); + } + } + catch (IndexOutOfRangeException) + { + } + return null; + } + + /// The ink box of a glyph in design units; empty for a glyph without outline (a space). + public GlyphBounds Bounds(ushort glyph) + { + lock (cache) + { + if (!cache.TryGetValue(glyph, out var bounds)) + { + try + { + bounds = reader(glyph) ?? default; + } + catch (Exception e) when (e is IndexOutOfRangeException or InvalidOperationException or ArgumentOutOfRangeException) + { + bounds = default; + } + cache[glyph] = bounds; + } + return bounds; + } + } + + private static GlyphBounds? TrueType(byte[] glyf, byte[] loca, bool longOffsets, ushort glyph) + { + int start, end; + if (longOffsets) + { + start = (int)U32(loca, glyph * 4); + end = (int)U32(loca, glyph * 4 + 4); + } + else + { + start = U16(loca, glyph * 2) * 2; + end = U16(loca, glyph * 2 + 2) * 2; + } + if (end <= start) + return null; + return new GlyphBounds(S16(glyf, start + 2), S16(glyf, start + 4), S16(glyf, start + 6), S16(glyf, start + 8)); + } + + private static int U16(byte[] d, int at) => d[at] << 8 | d[at + 1]; + + private static short S16(byte[] d, int at) => (short)(d[at] << 8 | d[at + 1]); + + private static uint U32(byte[] d, int at) => (uint)(d[at] << 24 | d[at + 1] << 16 | d[at + 2] << 8 | d[at + 3]); + + /// A CFF table: its charstrings and subroutines, and an interpreter of Type 2 charstrings that follows the outline. + private sealed class Cff + { + private readonly byte[] data; + private readonly List<(int Start, int End)> charStrings; + private readonly List<(int Start, int End)> globalSubrs; + private readonly List> localSubrs = new List>(); + private readonly byte[]? fdSelect; + private readonly int fdSelectAt; + private readonly int glyphCount; + + public Cff(byte[] data) + { + this.data = data; + int at = data[2]; + var names = Index(ref at); + var topDicts = Index(ref at); + Index(ref at); + globalSubrs = Index(ref at); + if (names.Count == 0 || topDicts.Count == 0) + throw new InvalidOperationException("No font in the CFF table."); + + var top = Dict(topDicts[0].Start, topDicts[0].End); + if (!top.TryGetValue(17, out var cs)) + throw new InvalidOperationException("No charstrings."); + var csAt = (int)cs[0]; + charStrings = Index(ref csAt); + glyphCount = charStrings.Count; + + if (top.TryGetValue(1236, out var fdArray) && top.TryGetValue(1237, out var select)) + { + var fdAt = (int)fdArray[0]; + foreach (var (start, end) in Index(ref fdAt)) + localSubrs.Add(PrivateSubrs(Dict(start, end))); + fdSelect = data; + fdSelectAt = (int)select[0]; + } + else + { + localSubrs.Add(PrivateSubrs(top)); + } + } + + private List<(int, int)> PrivateSubrs(Dictionary dict) + { + if (!dict.TryGetValue(18, out var priv) || priv.Length < 2) + return new List<(int, int)>(); + var size = (int)priv[0]; + var offset = (int)priv[1]; + var privateDict = Dict(offset, offset + size); + if (!privateDict.TryGetValue(19, out var subrs)) + return new List<(int, int)>(); + var at = offset + (int)subrs[0]; + return Index(ref at); + } + + private int FontIndex(ushort glyph) + { + if (fdSelect == null) + return 0; + var format = data[fdSelectAt]; + if (format == 0) + return data[fdSelectAt + 1 + glyph]; + if (format == 3) + { + var ranges = U16(data, fdSelectAt + 1); + for (var i = 0; i < ranges; i++) + { + var r = fdSelectAt + 3 + i * 3; + var first = U16(data, r); + var next = U16(data, r + 3); + if (glyph >= first && glyph < next) + return data[r + 2]; + } + } + return 0; + } + + public GlyphBounds? Bounds(ushort glyph) + { + if (glyph >= glyphCount) + return null; + var outline = new Outline(this, localSubrs.Count == 0 ? new List<(int, int)>() : localSubrs[Math.Min(FontIndex(glyph), localSubrs.Count - 1)]); + outline.Run(charStrings[glyph], 0); + return outline.HasPoints ? new GlyphBounds(outline.XMin, outline.YMin, outline.XMax, outline.YMax) : null; + } + + private List<(int Start, int End)> Index(ref int at) + { + var count = U16(data, at); + var list = new List<(int, int)>(count); + if (count == 0) + { + at += 2; + return list; + } + var offSize = data[at + 2]; + var offsets = at + 3; + var dataStart = offsets + (count + 1) * offSize - 1; + int Offset(int i) + { + var value = 0; + for (var b = 0; b < offSize; b++) + value = value << 8 | data[offsets + i * offSize + b]; + return value; + } + for (var i = 0; i < count; i++) + list.Add((dataStart + Offset(i), dataStart + Offset(i + 1))); + at = dataStart + Offset(count); + return list; + } + + private Dictionary Dict(int start, int end) + { + var dict = new Dictionary(); + var operands = new List(); + var at = start; + while (at < end) + { + int b0 = data[at]; + if (b0 <= 21) + { + var op = b0; + at++; + if (b0 == 12) + op = 1200 + data[at++]; + dict[op] = operands.ToArray(); + operands.Clear(); + } + else if (b0 == 28) + { + operands.Add((short)(data[at + 1] << 8 | data[at + 2])); + at += 3; + } + else if (b0 == 29) + { + operands.Add((int)U32(data, at + 1)); + at += 5; + } + else if (b0 == 30) + { + // a real number: its nibbles until 0xf; the operators read here are integers + at++; + while (at < end && (data[at] & 0x0F) != 0x0F && (data[at] & 0xF0) != 0xF0) + at++; + at++; + operands.Add(0); + } + else if (b0 >= 32 && b0 <= 246) + { + operands.Add(b0 - 139); + at++; + } + else if (b0 >= 247 && b0 <= 250) + { + operands.Add((b0 - 247) * 256 + data[at + 1] + 108); + at += 2; + } + else if (b0 >= 251 && b0 <= 254) + { + operands.Add(-(b0 - 251) * 256 - data[at + 1] - 108); + at += 2; + } + else + { + at++; + } + } + return dict; + } + + private static int Bias(int count) => count < 1240 ? 107 : count < 33900 ? 1131 : 32768; + + /// The state of the interpreter of a charstring: the pen, the stack, the stems, and the box of the outline so far. + private sealed class Outline + { + private readonly Cff cff; + private readonly List<(int Start, int End)> local; + private readonly List stack = new List(); + private double x; + private double y; + private int stems; + private bool widthDone; + private bool done; + + public Outline(Cff cff, List<(int, int)> local) + { + this.cff = cff; + this.local = local; + } + + public double XMin { get; private set; } = double.MaxValue; + + public double YMin { get; private set; } = double.MaxValue; + + public double XMax { get; private set; } = double.MinValue; + + public double YMax { get; private set; } = double.MinValue; + + public bool HasPoints => XMin <= XMax; + + public void Run((int Start, int End) charString, int depth) + { + if (depth > 10) + return; + var d = cff.data; + var at = charString.Start; + while (at < charString.End && !done) + { + int b0 = d[at]; + if (b0 >= 32 || b0 == 28) + { + at = Number(d, at); + continue; + } + + at++; + switch (b0) + { + case 1 or 3 or 18 or 23: // hstem vstem hstemhm vstemhm + Width(stack.Count % 2 == 1); + stems += stack.Count / 2; + stack.Clear(); + break; + case 19 or 20: // hintmask cntrmask + Width(stack.Count % 2 == 1); + stems += stack.Count / 2; + stack.Clear(); + at += (stems + 7) / 8; + break; + case 21: // rmoveto + Width(stack.Count > 2); + MoveTo(x + Arg(-2), y + Arg(-1)); + break; + case 22: // hmoveto + Width(stack.Count > 1); + MoveTo(x + Arg(-1), y); + break; + case 4: // vmoveto + Width(stack.Count > 1); + MoveTo(x, y + Arg(-1)); + break; + case 5: // rlineto + for (var i = 0; i + 1 < stack.Count; i += 2) + LineTo(x + stack[i], y + stack[i + 1]); + stack.Clear(); + break; + case 6 or 7: // hlineto vlineto + var horizontal = b0 == 6; + foreach (var v in stack) + { + if (horizontal) + LineTo(x + v, y); + else + LineTo(x, y + v); + horizontal = !horizontal; + } + stack.Clear(); + break; + case 8: // rrcurveto + for (var i = 0; i + 5 < stack.Count; i += 6) + CurveBy(stack[i], stack[i + 1], stack[i + 2], stack[i + 3], stack[i + 4], stack[i + 5]); + stack.Clear(); + break; + case 24: // rcurveline + { + var i = 0; + for (; i + 5 < stack.Count - 2; i += 6) + CurveBy(stack[i], stack[i + 1], stack[i + 2], stack[i + 3], stack[i + 4], stack[i + 5]); + if (i + 1 < stack.Count) + LineTo(x + stack[i], y + stack[i + 1]); + stack.Clear(); + break; + } + case 25: // rlinecurve + { + var i = 0; + for (; i + 1 < stack.Count - 6; i += 2) + LineTo(x + stack[i], y + stack[i + 1]); + if (i + 5 < stack.Count) + CurveBy(stack[i], stack[i + 1], stack[i + 2], stack[i + 3], stack[i + 4], stack[i + 5]); + stack.Clear(); + break; + } + case 26: // vvcurveto + { + var i = 0; + double dx1 = 0; + if (stack.Count % 4 == 1) + dx1 = stack[i++]; + for (; i + 3 < stack.Count; i += 4) + { + CurveBy(dx1, stack[i], stack[i + 1], stack[i + 2], 0, stack[i + 3]); + dx1 = 0; + } + stack.Clear(); + break; + } + case 27: // hhcurveto + { + var i = 0; + double dy1 = 0; + if (stack.Count % 4 == 1) + dy1 = stack[i++]; + for (; i + 3 < stack.Count; i += 4) + { + CurveBy(stack[i], dy1, stack[i + 1], stack[i + 2], stack[i + 3], 0); + dy1 = 0; + } + stack.Clear(); + break; + } + case 30 or 31: // vhcurveto hvcurveto + { + var horizontalFirst = b0 == 31; + var i = 0; + while (i + 3 < stack.Count) + { + var last = i + 4 == stack.Count - 1; + var extra = last ? stack[i + 4] : 0; + if (horizontalFirst) + CurveBy(stack[i], 0, stack[i + 1], stack[i + 2], extra, stack[i + 3]); + else + CurveBy(0, stack[i], stack[i + 1], stack[i + 2], stack[i + 3], extra); + horizontalFirst = !horizontalFirst; + i += last ? 5 : 4; + } + stack.Clear(); + break; + } + case 10 or 29: // callsubr callgsubr + { + if (stack.Count == 0) + return; + var subrs = b0 == 10 ? local : cff.globalSubrs; + var index = (int)stack[stack.Count - 1] + Bias(subrs.Count); + stack.RemoveAt(stack.Count - 1); + if (index >= 0 && index < subrs.Count) + Run(subrs[index], depth + 1); + break; + } + case 11: // return + return; + case 14: // endchar + Width(stack.Count == 1 || stack.Count == 5); + done = true; + return; + case 12: + Escape(d[at++]); + break; + default: + stack.Clear(); + break; + } + } + } + + private void Escape(int op) + { + var s = stack; + switch (op) + { + case 35 when s.Count >= 12: // flex + CurveBy(s[0], s[1], s[2], s[3], s[4], s[5]); + CurveBy(s[6], s[7], s[8], s[9], s[10], s[11]); + break; + case 34 when s.Count >= 7: // hflex + { + var y0 = y; + CurveBy(s[0], 0, s[1], s[2], s[3], 0); + CurveBy(s[4], 0, s[5], y0 - y, s[6], 0); + break; + } + case 36 when s.Count >= 9: // hflex1 + { + var y0 = y; + CurveBy(s[0], s[1], s[2], s[3], s[4], 0); + CurveBy(s[5], 0, s[6], s[7], s[8], y0 - y - s[7]); + break; + } + case 37 when s.Count >= 11: // flex1 + { + var x0 = x; + var y0 = y; + var dx = s[0] + s[2] + s[4] + s[6] + s[8]; + var dy = s[1] + s[3] + s[5] + s[7] + s[9]; + CurveBy(s[0], s[1], s[2], s[3], s[4], s[5]); + if (Math.Abs(dx) > Math.Abs(dy)) + CurveBy(s[6], s[7], s[8], s[9], s[10], y0 - (y + s[7] + s[9])); + else + CurveBy(s[6], s[7], s[8], s[9], x0 - (x + s[6] + s[8]), s[10]); + break; + } + } + stack.Clear(); + } + + private int Number(byte[] d, int at) + { + int b0 = d[at]; + if (b0 == 28) + { + stack.Add((short)(d[at + 1] << 8 | d[at + 2])); + return at + 3; + } + if (b0 <= 246) + { + stack.Add(b0 - 139); + return at + 1; + } + if (b0 <= 250) + { + stack.Add((b0 - 247) * 256 + d[at + 1] + 108); + return at + 2; + } + if (b0 <= 254) + { + stack.Add(-(b0 - 251) * 256 - d[at + 1] - 108); + return at + 2; + } + stack.Add((int)U32(d, at + 1) / 65536.0); + return at + 5; + } + + private double Arg(int fromEnd) => stack.Count + fromEnd >= 0 ? stack[stack.Count + fromEnd] : 0; + + /// The first operator that clears the stack may have the width of the glyph before its arguments. + private void Width(bool hasWidth) + { + if (!widthDone && hasWidth && stack.Count > 0) + stack.RemoveAt(0); + widthDone = true; + } + + private void MoveTo(double nx, double ny) + { + x = nx; + y = ny; + stack.Clear(); + } + + private void LineTo(double nx, double ny) + { + Add(x, y); + x = nx; + y = ny; + Add(x, y); + } + + private void CurveBy(double dx1, double dy1, double dx2, double dy2, double dx3, double dy3) + { + var x0 = x; + var y0 = y; + var x1 = x0 + dx1; + var y1 = y0 + dy1; + var x2 = x1 + dx2; + var y2 = y1 + dy2; + var x3 = x2 + dx3; + var y3 = y2 + dy3; + Add(x0, y0); + Add(x3, y3); + foreach (var t in Extrema(x0, x1, x2, x3)) + Add(Bezier(x0, x1, x2, x3, t), Bezier(y0, y1, y2, y3, t)); + foreach (var t in Extrema(y0, y1, y2, y3)) + Add(Bezier(x0, x1, x2, x3, t), Bezier(y0, y1, y2, y3, t)); + x = x3; + y = y3; + } + + private static double Bezier(double p0, double p1, double p2, double p3, double t) + { + var u = 1 - t; + return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; + } + + /// The parameters in (0, 1) where a cubic Bézier is at an extreme in one coordinate. + private static IEnumerable Extrema(double p0, double p1, double p2, double p3) + { + var a = -p0 + 3 * p1 - 3 * p2 + p3; + var b = 2 * (p0 - 2 * p1 + p2); + var c = p1 - p0; + if (Math.Abs(a) < 1e-9) + { + if (Math.Abs(b) > 1e-9 && -c / b is var t0 and > 0 and < 1) + yield return t0; + yield break; + } + var disc = b * b - 4 * a * c; + if (disc < 0) + yield break; + var sq = Math.Sqrt(disc); + foreach (var t in new[] { (-b + sq) / (2 * a), (-b - sq) / (2 * a) }) + { + if (t > 0 && t < 1) + yield return t; + } + } + + private void Add(double px, double py) + { + XMin = Math.Min(XMin, px); + XMax = Math.Max(XMax, px); + YMin = Math.Min(YMin, py); + YMax = Math.Max(YMax, py); + } + } + } + } +} diff --git a/src/SharpEquations.Rendering/OpenType/MathTable.cs b/src/SharpEquations.Rendering/OpenType/MathTable.cs new file mode 100644 index 0000000..866467c --- /dev/null +++ b/src/SharpEquations.Rendering/OpenType/MathTable.cs @@ -0,0 +1,307 @@ +namespace SharpEquations.Rendering.OpenType +{ + using System; + using System.Collections.Generic; + + /// The constants of the OpenType MATH table, in the order of the table. + public enum MathConstant + { +#pragma warning disable CS1591 // the names of the OpenType specification + ScriptPercentScaleDown, + ScriptScriptPercentScaleDown, + DelimitedSubFormulaMinHeight, + DisplayOperatorMinHeight, + MathLeading, + AxisHeight, + AccentBaseHeight, + FlattenedAccentBaseHeight, + SubscriptShiftDown, + SubscriptTopMax, + SubscriptBaselineDropMin, + SuperscriptShiftUp, + SuperscriptShiftUpCramped, + SuperscriptBottomMin, + SuperscriptBaselineDropMax, + SubSuperscriptGapMin, + SuperscriptBottomMaxWithSubscript, + SpaceAfterScript, + UpperLimitGapMin, + UpperLimitBaselineRiseMin, + LowerLimitGapMin, + LowerLimitBaselineDropMin, + StackTopShiftUp, + StackTopDisplayStyleShiftUp, + StackBottomShiftDown, + StackBottomDisplayStyleShiftDown, + StackGapMin, + StackDisplayStyleGapMin, + StretchStackTopShiftUp, + StretchStackBottomShiftDown, + StretchStackGapAboveMin, + StretchStackGapBelowMin, + FractionNumeratorShiftUp, + FractionNumeratorDisplayStyleShiftUp, + FractionDenominatorShiftDown, + FractionDenominatorDisplayStyleShiftDown, + FractionNumeratorGapMin, + FractionNumDisplayStyleGapMin, + FractionRuleThickness, + FractionDenominatorGapMin, + FractionDenomDisplayStyleGapMin, + SkewedFractionHorizontalGap, + SkewedFractionVerticalGap, + OverbarVerticalGap, + OverbarRuleThickness, + OverbarExtraAscender, + UnderbarVerticalGap, + UnderbarRuleThickness, + UnderbarExtraDescender, + RadicalVerticalGap, + RadicalDisplayStyleVerticalGap, + RadicalRuleThickness, + RadicalExtraAscender, + RadicalKernBeforeDegree, + RadicalKernAfterDegree, + RadicalDegreeBottomRaisePercent, +#pragma warning restore CS1591 + } + + /// A part of a glyph assembly: a stretchy glyph built of pieces, some of which (the extenders) repeat. + public readonly struct GlyphPart + { + internal GlyphPart(ushort glyph, int startConnector, int endConnector, int fullAdvance, bool isExtender) + { + Glyph = glyph; + StartConnector = startConnector; + EndConnector = endConnector; + FullAdvance = fullAdvance; + IsExtender = isExtender; + } + + /// The glyph of the part. + public ushort Glyph { get; } + + /// The length of the connector at the start (bottom or left), in design units. + public int StartConnector { get; } + + /// The length of the connector at the end (top or right), in design units. + public int EndConnector { get; } + + /// The advance of the part in the direction of the stretch, in design units. + public int FullAdvance { get; } + + /// The part repeats as many times as needed. + public bool IsExtender { get; } + } + + /// The variants of a glyph in one direction: bigger glyphs, and an assembly of parts for any size. + public sealed class GlyphConstruction + { + internal GlyphConstruction(IReadOnlyList<(ushort Glyph, int Advance)> variants, IReadOnlyList? assembly, int assemblyItalicCorrection) + { + Variants = variants; + Assembly = assembly; + AssemblyItalicCorrection = assemblyItalicCorrection; + } + + /// The variants, from the smallest, with their size in the direction of the stretch, in design units. + public IReadOnlyList<(ushort Glyph, int Advance)> Variants { get; } + + /// The parts of the assembly, from the bottom or the left; null without one. + public IReadOnlyList? Assembly { get; } + + /// The italic correction of the assembly, in design units. + public int AssemblyItalicCorrection { get; } + } + + /// The OpenType MATH table of a font: its constants, the italic corrections and accent attachments of its glyphs, and their variants. + public sealed class MathTable + { + private readonly int[] constants; + private readonly Dictionary italics; + private readonly Dictionary topAccents; + private readonly HashSet extendedShapes; + private readonly Dictionary vertical; + private readonly Dictionary horizontal; + + private MathTable(int[] constants, Dictionary italics, Dictionary topAccents, HashSet extendedShapes, + int minConnectorOverlap, Dictionary vertical, Dictionary horizontal) + { + this.constants = constants; + this.italics = italics; + this.topAccents = topAccents; + this.extendedShapes = extendedShapes; + MinConnectorOverlap = minConnectorOverlap; + this.vertical = vertical; + this.horizontal = horizontal; + } + + /// The overlap of the parts of an assembly at least, in design units. + public int MinConnectorOverlap { get; } + + /// A constant: a percentage for the first two and the last, a length in design units for the others. + public int this[MathConstant constant] => constants[(int)constant]; + + /// The italic correction of a glyph, in design units; 0 without one. + public int ItalicCorrection(ushort glyph) => italics.TryGetValue(glyph, out var value) ? value : 0; + + /// The horizontal position where an accent attaches above the glyph, in design units; null without one. + public int? TopAccentAttachment(ushort glyph) => topAccents.TryGetValue(glyph, out var value) ? value : null; + + /// Whether the glyph is an extended shape (a big operator, a delimiter variant), which scripts attach to differently. + public bool IsExtendedShape(ushort glyph) => extendedShapes.Contains(glyph); + + /// The vertical variants of a glyph, or null. + public GlyphConstruction? Vertical(ushort glyph) => vertical.TryGetValue(glyph, out var value) ? value : null; + + /// The horizontal variants of a glyph, or null. + public GlyphConstruction? Horizontal(ushort glyph) => horizontal.TryGetValue(glyph, out var value) ? value : null; + + /// Reads the table from its bytes (the MATH table of the font, not the font). + /// The bytes are not a MATH table. + public static MathTable Parse(ReadOnlySpan data) => Parse(data.ToArray()); + + /// Reads the table from its bytes (the MATH table of the font, not the font). + /// The bytes are not a MATH table. + public static MathTable Parse(byte[] data) + { + try + { + var r = new Reader(data); + if (r.U16(0) != 1) + throw new FormatException("Not a MATH table of version 1."); + var constantsAt = r.U16(4); + var glyphInfoAt = r.U16(6); + var variantsAt = r.U16(8); + + var constants = new int[(int)MathConstant.RadicalDegreeBottomRaisePercent + 1]; + constants[0] = r.S16(constantsAt); + constants[1] = r.S16(constantsAt + 2); + constants[2] = r.U16(constantsAt + 4); + constants[3] = r.U16(constantsAt + 6); + for (var i = 4; i < constants.Length - 1; i++) + constants[i] = r.S16(constantsAt + 8 + (i - 4) * 4); + constants[constants.Length - 1] = r.S16(constantsAt + 8 + (constants.Length - 5) * 4); + + var italics = new Dictionary(); + var topAccents = new Dictionary(); + var extended = new HashSet(); + if (glyphInfoAt != 0) + { + ReadValues(r, glyphInfoAt, r.U16(glyphInfoAt), italics); + ReadValues(r, glyphInfoAt, r.U16(glyphInfoAt + 2), topAccents); + var extendedAt = r.U16(glyphInfoAt + 4); + if (extendedAt != 0) + { + foreach (var glyph in Coverage(r, glyphInfoAt + extendedAt)) + extended.Add(glyph); + } + } + + var vertical = new Dictionary(); + var horizontal = new Dictionary(); + var overlap = 0; + if (variantsAt != 0) + { + overlap = r.U16(variantsAt); + var vertCoverage = r.U16(variantsAt + 2); + var horizCoverage = r.U16(variantsAt + 4); + var vertCount = r.U16(variantsAt + 6); + var horizCount = r.U16(variantsAt + 8); + ReadConstructions(r, variantsAt, vertCoverage, vertCount, variantsAt + 10, vertical); + ReadConstructions(r, variantsAt, horizCoverage, horizCount, variantsAt + 10 + vertCount * 2, horizontal); + } + return new MathTable(constants, italics, topAccents, extended, overlap, vertical, horizontal); + } + catch (IndexOutOfRangeException e) + { + throw new FormatException("The MATH table is truncated.", e); + } + } + + private static void ReadValues(Reader r, int parent, int offset, Dictionary values) + { + if (offset == 0) + return; + var at = parent + offset; + var glyphs = Coverage(r, at + r.U16(at)); + var count = r.U16(at + 2); + for (var i = 0; i < count && i < glyphs.Count; i++) + values[glyphs[i]] = r.S16(at + 4 + i * 4); + } + + private static void ReadConstructions(Reader r, int variantsAt, int coverageOffset, int count, int offsetsAt, Dictionary result) + { + if (coverageOffset == 0 || count == 0) + return; + var glyphs = Coverage(r, variantsAt + coverageOffset); + for (var i = 0; i < count && i < glyphs.Count; i++) + { + var at = variantsAt + r.U16(offsetsAt + i * 2); + var assemblyOffset = r.U16(at); + var variantCount = r.U16(at + 2); + var variants = new List<(ushort, int)>(variantCount); + for (var v = 0; v < variantCount; v++) + variants.Add((r.U16(at + 4 + v * 4), r.U16(at + 6 + v * 4))); + + List? parts = null; + var italic = 0; + if (assemblyOffset != 0) + { + var a = at + assemblyOffset; + italic = r.S16(a); + var partCount = r.U16(a + 4); + parts = new List(partCount); + for (var p = 0; p < partCount; p++) + { + var pa = a + 6 + p * 10; + parts.Add(new GlyphPart(r.U16(pa), r.U16(pa + 2), r.U16(pa + 4), r.U16(pa + 6), (r.U16(pa + 8) & 1) != 0)); + } + } + result[glyphs[i]] = new GlyphConstruction(variants, parts, italic); + } + } + + /// The glyphs of a coverage table, in the order of their coverage index. + private static List Coverage(Reader r, int at) + { + var glyphs = new List(); + switch (r.U16(at)) + { + case 1: + var count = r.U16(at + 2); + for (var i = 0; i < count; i++) + glyphs.Add(r.U16(at + 4 + i * 2)); + break; + case 2: + var ranges = r.U16(at + 2); + var byIndex = new SortedDictionary(); + for (var i = 0; i < ranges; i++) + { + var ra = at + 4 + i * 6; + var start = r.U16(ra); + var end = r.U16(ra + 2); + var index = r.U16(ra + 4); + for (var g = start; g <= end; g++) + byIndex[index + g - start] = (ushort)g; + } + glyphs.AddRange(byIndex.Values); + break; + default: + throw new FormatException("Unknown coverage format."); + } + return glyphs; + } + + private readonly struct Reader + { + private readonly byte[] data; + + public Reader(byte[] data) => this.data = data; + + public ushort U16(int at) => (ushort)(data[at] << 8 | data[at + 1]); + + public short S16(int at) => (short)(data[at] << 8 | data[at + 1]); + } + } +} diff --git a/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj b/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj new file mode 100644 index 0000000..eacb05e --- /dev/null +++ b/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj @@ -0,0 +1,31 @@ + + + + + netstandard2.0;net10.0 + SharpEquations.Rendering + SharpEquations.Rendering + SharpEquations.Rendering + The layout of presentation MathML (MathML Core) over the OpenType MATH table of a math font, with the geometry of the caret and the selection of an equation editor. No dependency on a user interface. + equation math mathml layout opentype math-table renderer + enable + true + + + + + + + + + + + + + + + + + + diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs new file mode 100644 index 0000000..bc34acc --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs @@ -0,0 +1,129 @@ +namespace SharpEquations.Avalonia.Tests.Controls +{ + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Headless; + using global::Avalonia.Input; + using SharpEquations; + + /// The control in a window of the headless session: typing, keys, MathML in and out. + [NotInParallel("headless")] + public class MathEditBoxTests + { + // the editor edits display equations by default, as the equations of Word on a line of their own + private static Equation Display(string unicodeMath) => Equation.Parse(unicodeMath, EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + + private static T WithEditor(System.Func test) + { + var editor = new MathEditBox { FontSize = 20 }; + var window = new Window { Content = editor, Width = 600, Height = 200 }; + window.Show(); + editor.Focus(); + try + { + return test(window, editor); + } + finally + { + window.Close(); + } + } + + [Test] + public async Task TypingBuildsUp() + { + var (equation, events) = await Headless.Run(() => WithEditor((window, editor) => + { + var count = 0; + editor.EquationChanged += (_, _) => count++; + window.KeyTextInput("a/b "); + return (editor.Equation, count); + })); + await Assert.That(equation).IsEqualTo(Display("a/b")); + await Assert.That(events).IsGreaterThan(0); + } + + [Test] + public async Task LaTeXInput() + { + var equation = await Headless.Run(() => WithEditor((window, editor) => + { + editor.InputFormat = EquationFormat.LaTeX; + window.KeyTextInput("\\frac{1}{2} "); + return editor.Equation; + })); + await Assert.That(equation).IsEqualTo(Display("1/2")); + } + + [Test] + public async Task KeysEditTheEquation() + { + var equation = await Headless.Run(() => WithEditor((window, editor) => + { + window.KeyTextInput("x+y"); + window.KeyPress(Key.Back, RawInputModifiers.None, PhysicalKey.Backspace, null); + window.KeyTextInput("z "); + return editor.Equation; + })); + await Assert.That(equation).IsEqualTo(Display("x+z")); + } + + [Test] + public async Task SetMathAndGetMath() + { + var (mathML, text) = await Headless.Run(() => WithEditor((_, editor) => + { + editor.SetMath("x"); + return (editor.GetMath(), editor.GetText(EquationFormat.UnicodeMath)); + })); + await Assert.That(mathML).Contains("msqrt"); + await Assert.That(Equation.Parse(text, EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("√x", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task BindingTheEquationLoadsIt() + { + var text = await Headless.Run(() => WithEditor((_, editor) => + { + editor.Equation = Equation.Parse("a^2", EquationFormat.UnicodeMath); + return editor.Editor.GetText(EquationFormat.UnicodeMath); + })); + await Assert.That(Equation.Parse(text, EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("a^2", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task LinearViewMode() + { + var (linear, back) = await Headless.Run(() => WithEditor((window, editor) => + { + window.KeyTextInput("a/b "); + editor.ViewMode = MathViewMode.Linear; + var isRun = editor.Editor.Root.Children[0].Children[0].IsRun; + editor.ViewMode = MathViewMode.Professional; + return (isRun, editor.Equation); + })); + await Assert.That(linear).IsTrue(); + await Assert.That(back).IsEqualTo(Display("a/b")); + } + + [Test] + public async Task ClickPlacesTheCaret() + { + var caret = await Headless.Run(() => WithEditor((window, editor) => + { + editor.Equation = Equation.Parse("a/b", EquationFormat.UnicodeMath); + window.UpdateLayout(); + var layout = editor.Layout!; + // a click below the middle of the equation, in the denominator + var point = new global::Avalonia.Point(editor.Padding.Left + editor.BorderThickness + layout.Width / 2, 0); + var origin = editor.TranslatePoint(point, window)!.Value; + var y = editor.TranslatePoint(new global::Avalonia.Point(0, editor.Bounds.Height / 2 + layout.Height / 4), window)!.Value.Y; + window.MouseDown(new global::Avalonia.Point(origin.X, y), MouseButton.Left); + window.MouseUp(new global::Avalonia.Point(origin.X, y), MouseButton.Left); + return editor.Editor.Caret; + })); + await Assert.That(caret.Path).IsEquivalentTo(new[] { 0, 0, 1 }); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs new file mode 100644 index 0000000..2ccd5d3 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs @@ -0,0 +1,73 @@ +namespace SharpEquations.Avalonia.Tests.Controls +{ + using System; + using System.IO; + using System.Threading.Tasks; + using global::Avalonia.Controls; + using global::Avalonia.Headless; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations; + + /// + /// Pictures of equations, written to the directory of SHARPEQUATIONS_SCREENSHOTS when it is set, to look at the + /// rendering; without it the test only renders them. + /// + [NotInParallel("headless")] + public class ScreenshotTests + { + private static readonly string[] Samples = + { + "x=(-b±√(b^2-4ac))/2a", + "∑_(i=1)^n i^2=(n(n+1)(2n+1))/6", + "∫_0^∞ e^(-x^2) ⅆx=√π/2", + "(■(a&b@c&d))^(-1)=1/(ad-bc) (■(d&-b@-c&a))", + "lim▒_(x→0) sinx/x=1", + "f(x)={■(x&x≥0@-x&x<0)┤", + "L:\\hat{a}+\\tilde{b}+\\overline{a+b}+\\vec{x}+\\underline{y}", + "L:\\sqrt[3]{x^3+y^3}+\\overbrace{a+b+c}^{n}", + "L:\\binom{n}{k}=\\frac{n!}{k!(n-k)!}", + "|x|+‖y‖+⌊z⌋+⟨ψ|φ⟩", + }; + + [Test] + public async Task RenderSamples() + { + var directory = Environment.GetEnvironmentVariable("SHARPEQUATIONS_SCREENSHOTS"); + var failures = await Headless.Run(() => + { + var panel = new StackPanel { Spacing = 12, Margin = new global::Avalonia.Thickness(12) }; + foreach (var sample in Samples) + panel.Children.Add(new MathView { Equation = Equation.Parse(sample.StartsWith("L:", StringComparison.Ordinal) ? sample.Substring(2) : sample, sample.StartsWith("L:", StringComparison.Ordinal) ? EquationFormat.LaTeX : EquationFormat.UnicodeMath, new EquationOptions { Display = true }), FontSize = 28, Foreground = Brushes.Black }); + var editor = new MathEditBox { FontSize = 28, Foreground = Brushes.Black, BorderBrush = Brushes.SteelBlue, HorizontalAlignment = HorizontalAlignment.Left }; + panel.Children.Add(editor); + var window = new Window { Content = panel, Width = 900, Height = 1100, Background = Brushes.White }; + window.Show(); + editor.Focus(); + window.KeyTextInput("a/b+\\sqrt(x^2+1) +("); + + // empty slots, and a selection + var slots = new MathEditBox { FontSize = 28, Foreground = Brushes.Black, HorizontalAlignment = HorizontalAlignment.Left }; + panel.Children.Add(slots); + var empty = SharpEquations.Editing.MathNode.Row(System.Array.Empty()); + slots.Editor.InsertNodes(new[] { SharpEquations.Editing.MathNode.Element("mfrac", new[] { empty, empty }), SharpEquations.Editing.MathNode.Token("mo", "+"), SharpEquations.Editing.MathNode.Element("msup", new[] { empty, empty }) }); + var selected = new MathEditBox { FontSize = 28, Foreground = Brushes.Black, HorizontalAlignment = HorizontalAlignment.Left, Equation = Equation.Parse("x+(a+b)/c", EquationFormat.UnicodeMath) }; + panel.Children.Add(selected); + selected.Editor.SetCaret(new SharpEquations.Editing.MathPosition(new[] { 0 }, 2)); + selected.Editor.SetCaret(new SharpEquations.Editing.MathPosition(new[] { 0 }, 3), extend: true); + var linear = new MathEditBox { FontSize = 28, Foreground = Brushes.Black, HorizontalAlignment = HorizontalAlignment.Left, Equation = Equation.Parse("x+(a+b)/c", EquationFormat.UnicodeMath), ViewMode = MathViewMode.Linear }; + panel.Children.Add(linear); + editor.Focus(); + var frame = window.CaptureRenderedFrame(); + if (directory != null && frame != null) + { + Directory.CreateDirectory(directory); + frame.Save(Path.Combine(directory, "samples.png"), global::Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); + } + window.Close(); + return frame == null ? 1 : 0; + }); + await Assert.That(failures).IsEqualTo(0); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Corpus.cs b/tests/SharpEquations.Avalonia.Tests/Corpus.cs new file mode 100644 index 0000000..c97bc71 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Corpus.cs @@ -0,0 +1,27 @@ +namespace SharpEquations.Avalonia.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json; + using SharpEquations.Tests; + + /// The equations of the UnicodeMathML corpus (tests/data/unicodemath) that are valid UnicodeMath and not empty (a comment alone). + internal static class Corpus + { + private static List<(string Input, bool Display, Equation Equation)>? examples; + + public static List<(string Input, bool Display, Equation Equation)> Examples() => examples ??= Load(); + + private static List<(string, bool, Equation)> Load() + { + using var json = JsonDocument.Parse(File.ReadAllText(TestData.Path("unicodemath", "unicodemathml-examples.json"))); + return json.RootElement.EnumerateArray() + .Select(e => (Input: e.GetProperty("input").GetString()!, Display: e.GetProperty("display").GetBoolean())) + .Select(e => (e.Input, e.Display, Equation: Equation.TryParse(e.Input, EquationFormat.UnicodeMath, new EquationOptions { Display = e.Display }, out var equation) ? equation : null)) + .Where(e => e.Equation != null && e.Equation.ToMathML().HasElements) + .Select(e => (e.Input, e.Display, e.Equation!)) + .ToList(); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/BuildUpTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/BuildUpTests.cs new file mode 100644 index 0000000..71dd8b7 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Editing/BuildUpTests.cs @@ -0,0 +1,112 @@ +namespace SharpEquations.Avalonia.Tests.Editing +{ + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + + /// What is typed builds up when a space or an operator ends an argument, as in Word. + public class BuildUpTests + { + private static MathEditor Type(string text, EquationFormat format = EquationFormat.UnicodeMath) + { + var editor = new MathEditor { InputFormat = format }; + editor.Insert(text); + return editor; + } + + private static string Built(MathEditor editor) => string.Join(" ", editor.Root.Children[0].Children); + + [Test] + [Arguments("a/b ", "a/b")] + [Arguments("x^2+", "x^2+")] + [Arguments("\\alpha ", "α")] + [Arguments("(a+b)/c ", "(a+b)/c")] + [Arguments("\\sqrt(x) ", "√x")] + [Arguments("e^-x ", "e^(-x)")] + [Arguments("x^2_3 ", "x_3^2")] + [Arguments("a/b+c ", "a/b+c")] + [Arguments("1/2+1/3 ", "1/2+1/3")] + public async Task UnicodeMath(string typed, string expected) + { + var editor = Type(typed); + await Assert.That(editor.Root.Children[0].Children.Any(c => c.IsRun)).IsFalse().Because(Built(editor)); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse(expected, EquationFormat.UnicodeMath)); + } + + [Test] + [Arguments("\\frac{a}{b} ", "a/b")] + [Arguments("x^{2}+", "x^2+")] + [Arguments("\\alpha+", "α+")] + [Arguments("\\sqrt{x} ", "√x")] + public async Task LaTeX(string typed, string expected) + { + var editor = Type(typed, EquationFormat.LaTeX); + await Assert.That(editor.Root.Children[0].Children.Any(c => c.IsRun)).IsFalse().Because(Built(editor)); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse(expected, EquationFormat.UnicodeMath)); + } + + [Test] + public async Task ArgumentStillToComeDoesNotBuildUp() + { + // the minus after ^ is the start of the exponent, not an operator that ends it + var editor = Type("e^-"); + await Assert.That(editor.Root.Children[0].Children.Count).IsEqualTo(1); + await Assert.That(editor.Root.Children[0].Children[0].IsRun).IsTrue(); + } + + [Test] + public async Task OpenBracketDoesNotBuildUp() + { + var editor = Type("\\sqrt(a+"); + await Assert.That(editor.Root.Children[0].Children[0].Text).IsEqualTo("\\sqrt(a+"); + } + + [Test] + public async Task StructureBeforeIsAnOperand() + { + // the fraction built up first is the base of the superscript typed after it + var editor = Type("(a/b "); + editor.Insert(")^2 "); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("(a/b)^2", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task UndoGivesBackTheTextTyped() + { + var editor = Type("a/b "); + editor.Undo(); + await Assert.That(editor.Root.Children[0].Children.Count).IsEqualTo(1); + await Assert.That(editor.Root.Children[0].Children[0].Text).IsEqualTo("a/b "); + editor.Redo(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("a/b", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task LinearAndBack() + { + var editor = new MathEditor(Equation.Parse("√(x^2+1)/2", EquationFormat.UnicodeMath)); + editor.SetLinear(true); + await Assert.That(Equation.Parse(editor.GetText(EquationFormat.UnicodeMath), EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("√(x^2+1)/2", EquationFormat.UnicodeMath)); + await Assert.That(editor.Root.Children[0].Children[0].IsRun).IsTrue(); + editor.SetLinear(false); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("√(x^2+1)/2", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task LinearInLaTeX() + { + var editor = new MathEditor(Equation.Parse("a/b", EquationFormat.UnicodeMath)) { InputFormat = EquationFormat.LaTeX }; + editor.SetLinear(true); + await Assert.That(editor.GetText(EquationFormat.LaTeX)).StartsWith("\\frac{"); + await Assert.That(Equation.Parse(editor.GetText(EquationFormat.LaTeX), EquationFormat.LaTeX)).IsEqualTo(Equation.Parse("a/b", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task TextThatDoesNotParseIsAnError() + { + var editor = Type("\\frac{a", EquationFormat.LaTeX); + await Assert.That(editor.ToMathML().ToString()).Contains("merror"); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/CorpusTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/CorpusTests.cs new file mode 100644 index 0000000..b31c5f2 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Editing/CorpusTests.cs @@ -0,0 +1,74 @@ +namespace SharpEquations.Avalonia.Tests.Editing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + + /// The editor on the 700 equations of the UnicodeMathML corpus. + public class CorpusTests + { + [Test] + public async Task CorpusIsLarge() => await Assert.That(Corpus.Examples()).Count().IsGreaterThan(700); + + /// An equation loaded in the editor (its MathML to the tree of nodes and back) is the same equation. + [Test] + public async Task LoadedEquationIsUnchanged() + { + var failures = new List(); + foreach (var (input, _, equation) in Corpus.Examples()) + { + if (new MathEditor(equation).ToEquation() != equation) + failures.Add(input); + } + await Assert.That(failures).IsEmpty(); + } + + /// Linear and back: an equation built down to UnicodeMath and built up again is the same equation. + [Test] + public async Task BuiltDownAndUpIsUnchanged() + { + var failures = new List(); + foreach (var (input, _, equation) in Corpus.Examples()) + { + var editor = new MathEditor(equation); + editor.SetLinear(true); + editor.SetLinear(false); + if (editor.ToEquation() != equation) + failures.Add(input); + } + await Assert.That(failures).IsEmpty(); + } + + /// + /// Every equation typed one character at a time, building up as it is typed, ends in an equation; and for most + /// of them it is the equation of the whole text read at once. The others are text that UnicodeMath reads in + /// another way when it is read a piece at a time: what builds up at an operator is final, as in Word (the + /// prescripts of _4 F_1, +- after a superscript, the spaces in quoted text). + /// + [Test] + public async Task TypedEquationIsMostlyTheEquationOfTheText() + { + var same = 0; + var errors = new List(); + foreach (var (input, display, equation) in Corpus.Examples()) + { + var editor = new MathEditor { IsDisplay = display }; + try + { + editor.Insert(input); + if (editor.ToEquation() == equation) + same++; + } + catch (Exception e) + { + errors.Add(input + ": " + e.Message); + } + } + await Assert.That(errors).IsEmpty(); + await Assert.That(same).IsGreaterThanOrEqualTo(660); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs new file mode 100644 index 0000000..3a5eb06 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs @@ -0,0 +1,129 @@ +namespace SharpEquations.Avalonia.Tests.Editing +{ + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + + /// The caret, the selection and the deletion in the tree of an equation. + public class EditorTests + { + private static MathEditor Load(string unicodeMath) => new MathEditor(Equation.Parse(unicodeMath, EquationFormat.UnicodeMath)); + + [Test] + public async Task CaretGoesThroughTheSlots() + { + // a/b: the caret enters the numerator, goes to the denominator, then out of the fraction + var editor = Load("a/b"); + editor.MoveHome(); + var positions = new System.Collections.Generic.List { editor.Caret.ToString() }; + for (var i = 0; i < 6; i++) + { + editor.MoveRight(); + positions.Add(editor.Caret.ToString()); + } + await Assert.That(positions).IsEquivalentTo(new[] { "/0:0", "/0/0/0:0", "/0/0/0:1", "/0/0/1:0", "/0/0/1:1", "/0:1", "/0:1" }); + + for (var i = 0; i < 6; i++) + editor.MoveLeft(); + await Assert.That(editor.Caret.ToString()).IsEqualTo("/0:0"); + } + + [Test] + public async Task UpAndDownInAFraction() + { + var editor = Load("a/b"); + editor.SetCaret(new MathPosition(new[] { 0, 0, 0 }, 1)); + editor.MoveDown(); + await Assert.That(editor.Caret.Path).IsEquivalentTo(new[] { 0, 0, 1 }); + editor.MoveUp(); + await Assert.That(editor.Caret.Path).IsEquivalentTo(new[] { 0, 0, 0 }); + } + + [Test] + public async Task TypingInASlot() + { + var editor = Load("a/b"); + editor.SetCaret(new MathPosition(new[] { 0, 0, 1 }, 1)); + editor.Insert("+c "); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("a/(b+c)", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task BackspaceSelectsAStructureThenDeletesIt() + { + var editor = Load("x+a/b"); + editor.MoveEnd(); + editor.Backspace(); + await Assert.That(editor.Selection).IsNotNull(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("x+a/b", EquationFormat.UnicodeMath)); + editor.Backspace(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("x+", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task BackspaceInATokenMakesItText() + { + var editor = Load("123"); + editor.MoveEnd(); + editor.Backspace(); + await Assert.That(editor.Root.Children[0].Children.Single().Text).IsEqualTo("12"); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("12", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task SelectionOutOfASlotCoversTheStructure() + { + var editor = Load("x+a/b"); + editor.SetCaret(new MathPosition(new[] { 0, 2, 0 }, 1)); + editor.SetCaret(new MathPosition(new[] { 0 }, 0), extend: true); + var selection = editor.Selection!; + await Assert.That(selection.Path).IsEquivalentTo(new[] { 0 }); + await Assert.That(selection.Start).IsEqualTo(0); + await Assert.That(selection.End).IsEqualTo(3); + await Assert.That(editor.GetSelectedEquation()).IsEqualTo(Equation.Parse("x+a/b", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task SelectAllAndType() + { + var editor = Load("x+y"); + editor.SelectAll(); + editor.Insert("z"); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("z", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task PasteInsertsTheEquation() + { + var editor = Load("x+"); + editor.MoveEnd(); + await Assert.That(editor.Paste("\\frac{1}{2}", EquationFormat.LaTeX)).IsTrue(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("x+1/2", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task BuildDownTheSelection() + { + var editor = Load("x+a/b"); + editor.SetCaret(new MathPosition(new[] { 0 }, 2)); + editor.SetCaret(new MathPosition(new[] { 0 }, 3), extend: true); + editor.BuildDown(); + await Assert.That(editor.Root.Children[0].Children[2].IsRun).IsTrue(); + editor.BuildUpAll(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("x+a/b", EquationFormat.UnicodeMath)); + } + + [Test] + public async Task EmptyEquation() + { + var editor = new MathEditor(); + await Assert.That(editor.IsEmpty).IsTrue(); + await Assert.That(editor.ToEquation()).IsNull(); + editor.Backspace(); + editor.MoveLeft(); + editor.MoveUp(); + await Assert.That(editor.Caret.ToString()).IsEqualTo("/0:0"); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Headless.cs b/tests/SharpEquations.Avalonia.Tests/Headless.cs new file mode 100644 index 0000000..579f410 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Headless.cs @@ -0,0 +1,34 @@ +namespace SharpEquations.Avalonia.Tests +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Headless; + using global::Avalonia.Themes.Fluent; + + /// The application of the headless session, which renders with Skia so that the fonts are real. + public class TestApp : Application + { + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); + + public override void Initialize() => Styles.Add(new FluentTheme()); + } + + /// + /// Runs code on the dispatcher of the one headless session of the tests. The tests that use it do not run in + /// parallel ([NotInParallel("headless")]): showing a window or typing runs the jobs of the dispatcher, which + /// would run another test in the middle of this one. + /// + internal static class Headless + { + private static readonly Lazy Session = new Lazy( + () => HeadlessUnitTestSession.StartNew(typeof(TestApp)), LazyThreadSafetyMode.ExecutionAndPublication); + + public static Task Run(Action action) => Session.Value.Dispatch(action, CancellationToken.None); + + public static Task Run(Func function) => Session.Value.Dispatch(function, CancellationToken.None); + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Rendering/LayoutTests.cs b/tests/SharpEquations.Avalonia.Tests/Rendering/LayoutTests.cs new file mode 100644 index 0000000..c346320 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Rendering/LayoutTests.cs @@ -0,0 +1,104 @@ +namespace SharpEquations.Avalonia.Tests.Rendering +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + using SharpEquations.Rendering; + + /// The layout of the corpus with the default math font, in the headless session. + [NotInParallel("headless")] + public class LayoutTests + { + private static readonly MathLayoutOptions Options = new MathLayoutOptions { FontSize = 20, ShowPlaceholders = true }; + + [Test] + public async Task FontHasMathTable() + { + var hasMath = await Headless.Run(() => MathFonts.Default.Math != null); + await Assert.That(hasMath).IsTrue(); + } + + [Test] + public async Task CorpusLaysOut() + { + var failures = await Headless.Run(() => + { + var failed = new List(); + foreach (var (input, _, equation) in Corpus.Examples()) + { + try + { + var layout = MathLayoutEngine.Layout(equation, MathFonts.Default, Options); + if (!(layout.Width > 0) || !(layout.Height > 0) || double.IsInfinity(layout.Width) || double.IsInfinity(layout.Height) + || layout.Glyphs.Any(g => double.IsNaN(g.X) || double.IsNaN(g.Y))) + { + failed.Add(input + ": " + layout.Width + "×" + layout.Height); + } + } + catch (Exception e) + { + failed.Add(input + ": " + e.GetType().Name + " " + e.Message); + } + } + return failed; + }); + await Assert.That(failures).IsEmpty(); + } + + [Test] + public async Task FractionIsTallerThanItsParts() + { + var (fraction, letter) = await Headless.Run(() => + (MathLayoutEngine.Layout(Equation.Parse("a/b", EquationFormat.UnicodeMath), MathFonts.Default, Options).Height, + MathLayoutEngine.Layout(Equation.Parse("a", EquationFormat.UnicodeMath), MathFonts.Default, Options).Height)); + await Assert.That(fraction).IsGreaterThan(letter * 1.8); + } + + [Test] + public async Task DelimitersStretch() + { + var (tall, plain) = await Headless.Run(() => + (MathLayoutEngine.Layout(Equation.Parse("(a/b)", EquationFormat.UnicodeMath, new EquationOptions { Display = true }), MathFonts.Default, Options).Height, + MathLayoutEngine.Layout(Equation.Parse("a/b", EquationFormat.UnicodeMath, new EquationOptions { Display = true }), MathFonts.Default, Options).Height)); + await Assert.That(tall).IsGreaterThanOrEqualTo(plain); + } + + /// A click on the caret of every position of an equation gives back that position (or one at the same place). + [Test] + public async Task HitTestFindsEveryCaret() + { + var failures = await Headless.Run(() => + { + var failed = new List(); + foreach (var input in new[] { "x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n i^2", "(■(a&b@c&d))", "e^(iπ)+1=0" }) + { + var editor = new MathEditor(Equation.Parse(input, EquationFormat.UnicodeMath)); + var layout = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, Options); + editor.MoveHome(); + editor.MoveHome(); + for (var step = 0; step < 200; step++) + { + var caret = editor.Caret; + var rect = layout.GetCaretRect(caret); + if (rect is not { } r) + { + failed.Add(input + " " + caret + ": no caret"); + break; + } + var hit = layout.HitTest(r.X, r.Y + r.Height / 2); + if (hit == null || layout.GetCaretRect(hit) is not { } h || Math.Abs(h.X - r.X) > 0.5) + failed.Add(input + " " + caret + " → " + hit); + editor.MoveRight(); + if (editor.Caret == caret) + break; + } + } + return failed; + }); + await Assert.That(failures).IsEmpty(); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs b/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs new file mode 100644 index 0000000..05ebd4a --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs @@ -0,0 +1,47 @@ +namespace SharpEquations.Avalonia.Tests.Rendering +{ + using System; + using System.IO; + using System.Text; + using System.Threading.Tasks; + using SharpEquations.Rendering.OpenType; + + /// The MATH table of the embedded Libertinus Math, read from the font file without a toolkit. + public class MathTableTests + { + private static byte[] Table(string tag) + { + var path = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "SharpEquations.Avalonia", "Assets", "Fonts", "LibertinusMath-Regular.otf"); + var font = File.ReadAllBytes(path); + int U16(int at) => font[at] << 8 | font[at + 1]; + long U32(int at) => (long)U16(at) << 16 | (uint)U16(at + 2); + var tables = U16(4); + for (var i = 0; i < tables; i++) + { + var record = 12 + i * 16; + if (Encoding.ASCII.GetString(font, record, 4) == tag) + return font.AsSpan((int)U32(record + 8), (int)U32(record + 12)).ToArray(); + } + throw new InvalidDataException("No " + tag + " table."); + } + + [Test] + public async Task Constants() + { + var math = MathTable.Parse(Table("MATH")); + await Assert.That(math[MathConstant.ScriptPercentScaleDown]).IsBetween(50, 90); + await Assert.That(math[MathConstant.ScriptScriptPercentScaleDown]).IsBetween(30, 70); + await Assert.That(math[MathConstant.AxisHeight]).IsBetween(200, 300); + await Assert.That(math[MathConstant.FractionRuleThickness]).IsBetween(20, 100); + await Assert.That(math[MathConstant.RadicalDegreeBottomRaisePercent]).IsBetween(30, 80); + await Assert.That(math.MinConnectorOverlap).IsGreaterThan(0); + } + + [Test] + public async Task NotAMathTable() + { + await Assert.That(() => MathTable.Parse(new byte[] { 0, 2, 0, 0 })).Throws(); + await Assert.That(() => MathTable.Parse(new byte[] { 0, 1, 0, 0, 0, 100 })).Throws(); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj new file mode 100644 index 0000000..f238980 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj @@ -0,0 +1,28 @@ + + + + + net10.0;net8.0 + Exe + false + SharpEquations.Avalonia.Tests + enable + + + + + + + + + + + + + + + + + + From 743e60763f1f9044e9e44798e28c43f1160b7c19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 07:54:25 +0000 Subject: [PATCH 26/45] [no-new-version] Editor: copy API, equation numbers, tools, demo, packages - Copy: GetSelectedText in any format (UnicodeMath, LaTeX, MathML, OMML), CopyTextFormat (MathML pastes as an equation into Word), the Copying event. - Equation numbers: #(1) or \tag{1} typed, or EquationNumber; the number at the right edge of the line (MathLayoutOptions.LineWidth, side="left"). - Commands: InsertStructure, InsertFences, InsertMatrix, InsertTemplate, Tab through the cells of a table, UndoLimit (the history forgets the oldest). - Accessibility: MathEditBoxAutomationPeer, the equation as UnicodeMath. - LaTeX typing: a control word followed by a space waits for its argument, and words of LaTeX are written back as \operatorname (86% to 90% of the latex2mathml corpus typed a character at a time). - Tests on the other corpora of the repository (latex2mathml, the OMML of Word documents, mml2tex, MTEF), of undo and delete, of the demo window. - A demo with the tools of the equations of Word. - CI packs and publishes the three packages of the editor with the core; their public API is tracked with PublicApiAnalyzers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- .github/workflows/ci.yml | 19 +- AGENTS.md | 7 +- README.md | 8 +- docs/EDITOR.md | 43 ++- .../MainWindow.cs | 296 ++++++++++++++++++ .../SharpEquations.Avalonia.Demo/Program.cs | 62 ---- src/SharpEquations.Avalonia/MathEditBox.cs | 64 +++- .../MathEditBoxAutomationPeer.cs | 48 +++ src/SharpEquations.Avalonia/MathView.cs | 2 +- src/SharpEquations.Avalonia/MathViewMode.cs | 20 ++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 138 ++++++++ .../SharpEquations.Avalonia.csproj | 4 + src/SharpEquations.Editing/BuildUp.cs | 25 ++ src/SharpEquations.Editing/MathEditor.cs | 226 ++++++++++++- src/SharpEquations.Editing/MathNode.cs | 4 +- .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 95 ++++++ .../SharpEquations.Editing.csproj | 5 + src/SharpEquations.Rendering/Layout/Engine.cs | 28 +- .../MathLayoutEngine.cs | 12 +- .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 164 ++++++++++ .../SharpEquations.Rendering.csproj | 5 + .../Controls/DemoTests.cs | 51 +++ .../Controls/MathEditBoxTests.cs | 57 ++++ .../Editing/CommandTests.cs | 170 ++++++++++ .../Editing/RepositoryCorporaTests.cs | 178 +++++++++++ .../SharpEquations.Avalonia.Tests/Headless.cs | 2 + .../SharpEquations.Avalonia.Tests.csproj | 2 + 30 files changed, 1631 insertions(+), 107 deletions(-) create mode 100644 samples/SharpEquations.Avalonia.Demo/MainWindow.cs create mode 100644 src/SharpEquations.Avalonia/MathEditBoxAutomationPeer.cs create mode 100644 src/SharpEquations.Avalonia/PublicAPI.Shipped.txt create mode 100644 src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt create mode 100644 src/SharpEquations.Editing/PublicAPI.Shipped.txt create mode 100644 src/SharpEquations.Editing/PublicAPI.Unshipped.txt create mode 100644 src/SharpEquations.Rendering/PublicAPI.Shipped.txt create mode 100644 src/SharpEquations.Rendering/PublicAPI.Unshipped.txt create mode 100644 tests/SharpEquations.Avalonia.Tests/Controls/DemoTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Editing/RepositoryCorporaTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f12539a..9d7cf80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,12 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages SOLUTION: SharpEquations.slnx - LIBRARY_PROJECT: src/SharpEquations/SharpEquations.csproj + # the packages, all with the version of BuildVersion.props: the converter, and the equation editor on top of it + LIBRARY_PROJECTS: >- + src/SharpEquations/SharpEquations.csproj + src/SharpEquations.Editing/SharpEquations.Editing.csproj + src/SharpEquations.Rendering/SharpEquations.Rendering.csproj + src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj TEST_PROJECT: tests/SharpEquations.Tests/SharpEquations.Tests.csproj EDITOR_TEST_PROJECT: tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json @@ -216,11 +221,13 @@ jobs: # The .snupkg symbol package is written next to the .nupkg. - name: Pack run: | - dotnet pack "$LIBRARY_PROJECT" \ - -c Release \ - -o artifacts \ - -p:VersionSuffix="$VERSION_SUFFIX" \ - -p:ContinuousIntegrationBuild=true + for PROJECT in $LIBRARY_PROJECTS; do + dotnet pack "$PROJECT" \ + -c Release \ + -o artifacts \ + -p:VersionSuffix="$VERSION_SUFFIX" \ + -p:ContinuousIntegrationBuild=true + done ls -lh artifacts # nuget.org shows the README of the package: fail if it is missing or not declared. diff --git a/AGENTS.md b/AGENTS.md index 1c7ce4e..26ad7ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,12 +110,15 @@ for the equations of Word documents (`tests/data/omml/`), that an equation writt OMML) reads back to the same equation, and that one written in another format (standard LaTeX, Word's LaTeX, OMML) loses only the first time what that format cannot hold. Keep these at 100%. For the editor, an equation of the corpus loaded in the editor, or built down to linear text and up again, is the same equation (100%); typed one character at a -time it is the equation of the whole text for 93% of the corpus (`Editing/CorpusTests`), which a change must not lower. +time it is the equation of the whole text for 93% of the corpus (`Editing/CorpusTests`) and for 90% of the LaTeX of +latex2mathml (`Editing/RepositoryCorporaTests`), which a change must not lower. The public API of the editor's +packages is tracked in their `PublicAPI.*.txt`, as that of the core. ## Releases `.github/workflows/ci.yml`, the same pipeline as SharpMediaInfo and Letra200bSharp: it tests on Linux (net10.0, net8.0), -Windows (net10.0, net481) and macOS (net10.0), packs, publishes a `pr--…` pre-release for each pull request (deleted on close by +Windows (net10.0, net481) and macOS (net10.0), packs the four packages (`SharpEquations` and the editor's `SharpEquations.Editing`, `.Rendering`, `.Avalonia`, with +one version), publishes a `pr--…` pre-release for each pull request (deleted on close by `prerelease-cleanup.yml`) and, on `main`, tags `v` of `BuildVersion.props`, creates the GitHub release and pushes the package to GitHub Packages and nuget.org (Trusted Publishing, repository variable `NUGET_USER`). A change that should release bumps `VersionPrefix`; one that should not says `[no-new-version]` in its commit message. diff --git a/README.md b/README.md index 6b57ff5..299ac6e 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,14 @@ editor.EquationChanged += (_, e) => Console.WriteLine(e.Equation?.ToString(Equat string mathML = editor.GetMath(); // as RichEditTextDocument.GetMath editor.SetMath(mathML); // as RichEditTextDocument.SetMath editor.ViewMode = MathViewMode.Linear; // the equation as one line of UnicodeMath (Ctrl+Shift+L) +editor.EquationNumber = "(1)"; // at the right of the line, as #(1) or \tag{1} +string latex = editor.GetSelectedText(EquationFormat.LaTeX)!; +editor.CopyTextFormat = EquationFormat.MathML; // Ctrl+C pastes as an equation into Word ``` +The packages are `SharpEquations.Avalonia` (the controls), `SharpEquations.Rendering` and `SharpEquations.Editing`, with +the version of `SharpEquations`. + `MathView` shows an equation without editing it. Both lay out MathML after MathML Core with the OpenType MATH table of a math font: Cambria Math on Windows, and Libertinus Math (SIL Open Font License), embedded, elsewhere. The editor is three projects, so that the core keeps no dependency: `SharpEquations.Editing` (the editing model, no user interface), @@ -93,7 +99,7 @@ its README). ## Releases CI (`.github/workflows/ci.yml`) tests every push and pull request on Linux (.NET 10 and .NET 8), Windows (.NET 10 and -.NET Framework 4.8.1) and macOS (.NET 10), and packs the NuGet package. The release version is `VersionPrefix` in `BuildVersion.props`: bump it, and merging to +.NET Framework 4.8.1) and macOS (.NET 10), and packs the NuGet packages (the converter and the three of the editor, with one version). The release version is `VersionPrefix` in `BuildVersion.props`: bump it, and merging to `main` tags `vX.Y.Z`, creates the GitHub release with the package attached, and publishes it to GitHub Packages and to nuget.org (with [Trusted Publishing](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing): the repository variable `NUGET_USER` names the nuget.org account; without it nuget.org is skipped). A push that keeps the version on diff --git a/docs/EDITOR.md b/docs/EDITOR.md index 15387fb..a7f5f89 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -6,7 +6,8 @@ as it is typed, and the content is read and written as MathML (`GetMath`/`SetMat SharpEquations is the core under it: it reads what is typed, gives the MathML, and writes the linear form back when a part of the equation is built down. The editor edits one equation (math only), not math zones in a text document. -Phases 1 to 3 of the plan below are done; phase 4 is what remains. +Phases 1 to 3 of the plan below are done, and phase 4 in part; the three projects are published as NuGet packages with +the version of the core. ## What the user sees @@ -27,13 +28,26 @@ The behaviour is the one of the equations of Word and of `RichEditBox` in math m denominator, superscript and subscript, limits, the rows of a table), to the position nearest to the caret; Home and End go to the ends of the row, then of the equation; Shift extends the selection, which covers a whole structure when it leaves a slot. Backspace and Delete remove a code point of a run or of a token (a token becomes a run then), and - select a structure first and delete it the second time, as in Word. Ctrl+Z and Ctrl+Y undo and redo (an undo after a - build up gives back the text as it was typed); Ctrl+A selects all; Enter builds up all the runs. The shortcuts of copy, + select a structure first and delete it the second time, as in Word. Ctrl+Z and Ctrl+Y undo and redo: the history is a + stack of the immutable trees (1000 at most, `UndoLimit`), which share what an edit did not change; the characters + typed in a row are one step, and a build up another, so that an undo after a build up gives back the text as it was + typed; Ctrl+A selects all; Enter builds up all the runs. The shortcuts of copy, paste, undo... are those of the platform (`PlatformHotkeyConfiguration`). - **Mouse**: a click places the caret at the nearest position of the deepest row under the pointer; a drag selects. -- **Clipboard**: copy puts the selection as linear text in the input format and as MathML (`MathML` on Windows, - `application/mathml+xml` elsewhere); paste reads MathML, then the text in the input format or as MathML, and inserts - plain text as a run when it does not parse. +- **Clipboard**: copy puts the selection as text in `CopyTextFormat` (the input format by default; MathML pastes as an + equation into Word) and as MathML (`MathML` on Windows, `application/mathml+xml` elsewhere); a handler of `Copying` + can add formats or cancel; `GetSelectedText(format)` gives the selection in any format (UnicodeMath, LaTeX, MathML, + OMML) without the clipboard. Paste reads MathML, then the text in the input format or as MathML, and inserts plain + text as a run when it does not parse. +- **Input**: one format at a time, UnicodeMath or LaTeX, switched at any moment (what is built up stays). UnicodeMath + already reads the TeX control words of Word (`\alpha`, `\sqrt`, `\sum`); a LaTeX control word followed by a space + waits for its argument when it has one (`\mathring a`), and is built up when it is complete (`\alpha␣`). +- **Equation numbers**: `#(1)` at the end in UnicodeMath, `\tag{1}` in LaTeX, or `EquationNumber = "(1)"`: the number + goes to the right edge of the line (or the left, with `side="left"`), with the equation in the middle of what is + left, as in Word; the layout knows the line from `MathLayoutOptions.LineWidth`, which the controls set to their width. +- **Structures from code**: `InsertStructure("mfrac")`, `InsertFences`, `InsertMatrix(2, 2, "(", ")")`, + `InsertTemplate(node)`, with the selection in the first slot and the caret in the first empty one, as the buttons of + the equation tools of Word; Tab and Shift+Tab move through the cells of a table. - **Empty slots** are drawn as a dashed box, as the placeholder `⬚` of Word. ## Layers @@ -70,7 +84,10 @@ flowchart TB F --> M ``` -The core is unchanged and the editor is built on its public API only. Editing and Rendering have no dependency on a +The core is unchanged and the editor is built on its public API only. The demo (`samples/SharpEquations.Avalonia.Demo`) +has the tools of the equations of Word: the structures and symbols to insert, the input format, the views, the font, +the number of the equation, undo, copy in every format, examples, and the equation as MathML, UnicodeMath, LaTeX and +OMML as it is typed; a test opens it. Editing and Rendering have no dependency on a user interface (they target netstandard2.0 and net10.0, with no package, as the core), so that they are tested without a toolkit and could serve another one; only `SharpEquations.Avalonia` depends on Avalonia (12.1). Each is a project of its own in this repository; `samples/SharpEquations.Avalonia.Demo` is a window with the editor and the equation in every @@ -116,7 +133,10 @@ the editor, or built down to linear text and up again, is the same equation, for (`Editing/CorpusTests`). Typed one character at a time, building up as it goes, an equation of the corpus is the equation of its whole text for 93% of the corpus: the others are text that UnicodeMath reads otherwise when it is read a piece at a time (prescripts typed before their base, `^+-` that becomes `^±`, spaces that end an operand), where what -builds up at an operator stays built up, as in Word. +builds up at an operator stays built up, as in Word. The other corpora of the repository test the editor too +(`Editing/RepositoryCorporaTests`): the LaTeX of latex2mathml and the OMML of Word documents load unchanged, and round +trip through linear UnicodeMath and the LaTeX of Word; typed one character at a time, 90% of the LaTeX gives the +equation of its text; all of them, with the MathML of mml2tex and the MTEF of the fixtures, lay out. `MathEditor.ToEquation()` builds up a copy of the tree, with the runs that do not parse as `merror`, and reads it as MathML, so that the equation an editor gives is normalized like any other and compares with `==`. @@ -188,13 +208,14 @@ public class MathView : Control // read-only: Equati | 1 | `SharpEquations.Rendering` and `MathView`: MATH table, outlines, layout of MathML Core | done: the corpus lays out (`Rendering/LayoutTests`) | | 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, selection, build up and down, undo | done: 100% of the corpus loads and round-trips through linear text, 93% typed a character at a time | | 3 | `MathEditBox`: input, caret, hit testing, selection, linear view, clipboard, demo | done: headless tests of typing, keys, mouse, MathML in and out | -| 4 | Autocomplete of control words, accessibility (an `AutomationPeer` that reads the equation as UnicodeMath), keyboard navigation in tables (Tab, new rows), the `ssty` variants of scripts, cut lines of long equations, NuGet packages of the three projects | to do | +| 4 | Accessibility (`MathEditBoxAutomationPeer`: an edit control whose value is the equation in UnicodeMath), Tab in tables, equation numbers, copy in any format, structures from code, NuGet packages | done | +| 5 | Autocomplete of control words (with the public list of the core), new rows and columns of tables, the `ssty` variants of scripts, line breaks of long equations | to do | ## Open points - **Public API of the core** (for its next major version): the list of the control words of UnicodeMath and of the LaTeX commands with their symbols (`ControlWords`, `LaTeXSymbols`, internal today), for the autocomplete of phase 4. -- **Packages**: the three projects are packable, but the CI packs and publishes only `SharpEquations`; publishing the - editor (with its own version or that of the core) is to be decided. +- **Packages**: the CI packs the three projects with the core, with its version, and publishes them with it; their + public API is tracked in `PublicAPI.Shipped.txt`/`PublicAPI.Unshipped.txt`, as that of the core. - **Reference images**: the rendering is tested by the geometry of the layout, not by pictures; the tests write pictures of a set of equations when `SHARPEQUATIONS_SCREENSHOTS` names a directory, for a look. diff --git a/samples/SharpEquations.Avalonia.Demo/MainWindow.cs b/samples/SharpEquations.Avalonia.Demo/MainWindow.cs new file mode 100644 index 0000000..f8c11fc --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/MainWindow.cs @@ -0,0 +1,296 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System; + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Primitives; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations; + using SharpEquations.Editing; + + /// + /// The equation editor with the tools of the equations of Word: structures and symbols to insert, the input format + /// (UnicodeMath or LaTeX), the linear and built up views, the number of the equation, copy in every format, and the + /// equation as MathML, UnicodeMath, LaTeX and OMML as it is typed. + /// + public sealed class MainWindow : Window + { + private static readonly (string Name, string UnicodeMath)[] Examples = + { + ("Quadratic formula", "x=(-b±√(b^2-4ac))/2a"), + ("Euler", "e^(iπ)+1=0"), + ("Gauss integral", "∫_-∞^∞ e^(-x^2) ⅆx=√π"), + ("Sum of squares", "∑_(k=1)^n k^2=(n(n+1)(2n+1))/6"), + ("Binomial theorem", "(a+b)^n=∑_(k=0)^n (n¦k) a^k b^(n-k)"), + ("Matrix inverse", "(■(a&b@c&d))^(-1)=1/(ad-bc) (■(d&-b@-c&a))"), + ("Piecewise", "|x|={■(x&x≥0@-x&x<0)┤"), + ("Limit", "lim┬(x→0) sin⁡x/x=1"), + ("Maxwell", "∇×𝐁=μ_0 (𝐉+ε_0 ∂𝐄/∂t)"), + ("Numbered", "E=mc^2#(1)"), + }; + + private readonly MathEditBox editor; + private readonly MathView preview; + private readonly TextBox mathML = Output(); + private readonly TextBox unicodeMath = Output(); + private readonly TextBox wordLaTeX = Output(); + private readonly TextBox standardLaTeX = Output(); + private readonly TextBox omml = Output(); + private readonly TextBlock status = new TextBlock { Opacity = 0.7, VerticalAlignment = VerticalAlignment.Center }; + private readonly CheckBox linear = new CheckBox { Content = "Linear (Ctrl+Shift+L)" }; + private readonly TextBox number = new TextBox { PlaceholderText = "(1)", Width = 70 }; + + public MainWindow() + { + Title = "SharpEquations — equation editor"; + Width = 1100; + Height = 820; + + editor = new MathEditBox + { + FontSize = 32, + MinHeight = 100, + Padding = new Thickness(16, 12), + BorderBrush = Brushes.SteelBlue, + BorderThickness = 1.5, + }; + preview = new MathView { FontSize = 20 }; + editor.EquationChanged += (_, _) => Refresh(); + editor.Editor.Changed += (_, _) => status.Text = Status(); + + var rows = new StackPanel + { + Margin = new Thickness(16), + Spacing = 10, + Children = + { + Settings(), + Structures(), + Symbols(), + editor, + Actions(), + new TextBlock + { + Text = "Type UnicodeMath (a/b, x^2, \\sqrt(x), \\alpha, \\sum_(i=1)^n, (■(a&b@c&d))) or LaTeX (\\frac{a}{b}, \\sqrt{x}) and a space or an operator " + + "builds it up. Arrows move through the slots, Tab through the cells of a matrix; Backspace selects a structure, then deletes it; " + + "Ctrl+Z undoes. #(1) at the end, or \\tag{1}, numbers the equation.", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + }, + Outputs(), + }, + }; + Content = new DockPanel + { + Children = + { + new Border { [DockPanel.DockProperty] = Dock.Bottom, Padding = new Thickness(16, 4), Child = status }, + new ScrollViewer { Content = rows }, + }, + }; + Opened += (_, _) => + { + editor.Equation = Equation.Parse(Examples[0].UnicodeMath, EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + editor.Focus(); + }; + } + + private static TextBox Output() => new TextBox + { + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace"), + FontSize = 13, + MinHeight = 60, + MaxHeight = 220, + }; + + private StackPanel Settings() + { + var format = new ComboBox { ItemsSource = new[] { "UnicodeMath", "LaTeX" }, SelectedIndex = 0 }; + format.SelectionChanged += (_, _) => editor.InputFormat = format.SelectedIndex == 1 ? EquationFormat.LaTeX : EquationFormat.UnicodeMath; + linear.IsCheckedChanged += (_, _) => editor.ViewMode = linear.IsChecked == true ? MathViewMode.Linear : MathViewMode.Professional; + var display = new CheckBox { Content = "Display", IsChecked = true }; + display.IsCheckedChanged += (_, _) => editor.IsDisplay = display.IsChecked == true; + + var font = new ComboBox { ItemsSource = new[] { "Default font", "Cambria Math", "Libertinus Math" }, SelectedIndex = 0 }; + font.SelectionChanged += (_, _) => editor.MathFontFamily = preview.MathFontFamily = font.SelectedIndex switch + { + 1 => new FontFamily("Cambria Math"), + 2 => MathFonts.LibertinusMath, + _ => null, + }; + var size = new Slider { Minimum = 14, Maximum = 72, Value = 32, Width = 140 }; + size.PropertyChanged += (_, e) => + { + if (e.Property == RangeBase.ValueProperty) + editor.FontSize = size.Value; + }; + + var examples = new ComboBox { PlaceholderText = "Examples", ItemsSource = Examples.Select(e => e.Name).ToList() }; + examples.SelectionChanged += (_, _) => + { + if (examples.SelectedIndex >= 0) + { + editor.Equation = Equation.Parse(Examples[examples.SelectedIndex].UnicodeMath, EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + editor.Focus(); + } + }; + + return Row(Label("Input"), format, linear, display, Label("Font"), font, size, Label("Load"), examples); + } + + private StackPanel Structures() + { + var empty = MathNode.Row(Array.Empty()); + MathNode Op(string text) => MathNode.Row(new[] { MathNode.Token("mo", text) }); + MathNode Accent(string mark, bool under = false) => MathNode.Element( + under ? "munder" : "mover", + new[] { empty, Op(mark) }, + new[] { new KeyValuePair(under ? "accentunder" : "accent", "true") }); + + return Row( + Tool("a/b", "Fraction", () => editor.Editor.InsertStructure("mfrac")), + Tool("xⁿ", "Superscript", () => editor.Editor.InsertStructure("msup")), + Tool("xₙ", "Subscript", () => editor.Editor.InsertStructure("msub")), + Tool("xₙⁿ", "Subscript and superscript", () => editor.Editor.InsertStructure("msubsup")), + Tool("√x", "Square root", () => editor.Editor.InsertStructure("msqrt")), + Tool("ⁿ√x", "Root", () => editor.Editor.InsertStructure("mroot")), + Tool("(x)", "Parentheses", () => editor.Editor.InsertFences()), + Tool("[x]", "Brackets", () => editor.Editor.InsertFences("[", "]")), + Tool("|x|", "Absolute value", () => editor.Editor.InsertFences("|", "|")), + Tool("∑", "Sum with limits", () => editor.Editor.InsertTemplate(MathNode.Element("munderover", new[] { Op("∑"), empty, empty }))), + Tool("∫", "Integral with limits", () => editor.Editor.InsertTemplate(MathNode.Element("msubsup", new[] { Op("∫"), empty, empty }))), + Tool("lim", "Limit", () => editor.Editor.InsertTemplate(MathNode.Element("munder", new[] { MathNode.Row(new[] { MathNode.Token("mi", "lim") }), empty }))), + Tool("x̂", "Hat", () => editor.Editor.InsertTemplate(Accent("^"))), + Tool("x̄", "Overline", () => editor.Editor.InsertTemplate(Accent("¯"))), + Tool("x⃗", "Vector", () => editor.Editor.InsertTemplate(Accent("→"))), + Tool("(⋮)", "2×2 matrix", () => editor.Editor.InsertMatrix(2, 2, "(", ")")), + Tool("[⋮]", "3×3 matrix", () => editor.Editor.InsertMatrix(3, 3, "[", "]")), + Tool("{", "Cases", () => editor.Editor.InsertTemplate(MathNode.Row(new[] + { + MathNode.Token("mo", "{"), + MathNode.Element("mtable", new[] + { + MathNode.Element("mtr", new[] { MathNode.Element("mtd", new[] { empty }), MathNode.Element("mtd", new[] { empty }) }), + MathNode.Element("mtr", new[] { MathNode.Element("mtd", new[] { empty }), MathNode.Element("mtd", new[] { empty }) }), + }, new[] { new KeyValuePair("columnalign", "left") }), + MathNode.Token("mo", string.Empty), + }))), + Tool("▭", "Box", () => editor.Editor.InsertStructure("menclose", new[] { new KeyValuePair("notation", "box") }))); + } + + private StackPanel Symbols() + { + var symbols = "α β γ δ θ λ μ π σ φ ω Γ Δ Σ Ω ∞ ± ∓ × · ÷ ≤ ≥ ≠ ≈ ≡ ∝ → ⇒ ⇔ ∈ ∉ ⊂ ⊆ ∪ ∩ ∀ ∃ ∂ ∇ ℝ ℕ ℤ ℂ".Split(' '); + return Row(symbols.Select(s => (Control)Tool(s, s, () => editor.Editor.Insert(s), 34)).ToArray()); + } + + private StackPanel Actions() + { + var copyFormat = new ComboBox { ItemsSource = new[] { "Copy as input format", "Copy as MathML (Word)", "Copy as UnicodeMath", "Copy as LaTeX" }, SelectedIndex = 0 }; + copyFormat.SelectionChanged += (_, _) => editor.CopyTextFormat = copyFormat.SelectedIndex switch + { + 1 => EquationFormat.MathML, + 2 => EquationFormat.UnicodeMath, + 3 => EquationFormat.LaTeX, + _ => null, + }; + + var apply = Tool("Number", "Number the equation (empty: remove the number)", () => + editor.EquationNumber = string.IsNullOrWhiteSpace(number.Text) ? null : number.Text!.Trim(), 70); + + return Row( + Tool("↶ Undo", "Undo (Ctrl+Z)", () => editor.Editor.Undo(), 70), + Tool("↷ Redo", "Redo (Ctrl+Y)", () => editor.Editor.Redo(), 70), + Tool("Build up", "Build up what is typed (Enter)", () => editor.BuildUp(), 80), + Tool("Build down", "The selection, or the equation, as linear text", () => editor.BuildDown(), 90), + Tool("Select all", "Ctrl+A", () => editor.Editor.SelectAll(), 80), + Tool("Copy", "Copy the selection (Ctrl+C)", () => _ = editor.CopyAsync(), 60), + Tool("Paste", "Paste MathML, UnicodeMath or LaTeX (Ctrl+V)", () => _ = editor.PasteAsync(), 60), + copyFormat, + Label("Number"), + number, + apply, + Tool("Clear", "An empty equation", () => editor.Equation = null, 60)); + } + + private StackPanel Outputs() + { + var tabs = new TabControl + { + ItemsSource = new[] + { + new TabItem { Header = "MathML", Content = mathML }, + new TabItem { Header = "UnicodeMath", Content = unicodeMath }, + new TabItem { Header = "LaTeX (Word)", Content = wordLaTeX }, + new TabItem { Header = "LaTeX", Content = standardLaTeX }, + new TabItem { Header = "OMML", Content = omml }, + }, + }; + return new StackPanel + { + Spacing = 8, + Children = + { + Label("MathView (read only)"), + new Border { BorderBrush = Brushes.LightGray, BorderThickness = new Thickness(1), Padding = new Thickness(12), Child = preview }, + tabs, + }, + }; + } + + private void Refresh() + { + var equation = editor.Equation; + linear.IsChecked = editor.ViewMode == MathViewMode.Linear; + if (editor.EquationNumber is { } n && number.Text != n) + number.Text = n; + preview.Equation = equation; + mathML.Text = equation?.ToString(EquationFormat.MathML); + unicodeMath.Text = equation?.ToString(EquationFormat.UnicodeMath); + wordLaTeX.Text = equation?.ToString(EquationFormat.LaTeX, new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }); + standardLaTeX.Text = equation?.ToString(EquationFormat.LaTeX); + omml.Text = equation?.ToOmml().ToString(); + status.Text = Status(); + } + + private string Status() + { + var e = editor.Editor; + var selection = e.Selection is { } s ? $" · selection {s.Start}–{s.End} in /{string.Join("/", s.Path)}" : string.Empty; + return $"{e.InputFormat} · {(e.IsLinear ? "linear" : "built up")} · caret {e.Caret}{selection} · undo {(e.CanUndo ? "yes" : "no")}"; + } + + private static StackPanel Row(params Control[] children) + { + var row = new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 6, LineSpacing = 6 }; + foreach (var child in children) + row.Children.Add(child); + return new StackPanel { Children = { row } }; + } + + private static TextBlock Label(string text) => new TextBlock { Text = text, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + + private Button Tool(string content, string tip, Action action, double minWidth = 44) + { + var button = new Button + { + Content = content, + MinWidth = minWidth, + Focusable = false, + HorizontalContentAlignment = HorizontalAlignment.Center, + }; + ToolTip.SetTip(button, tip); + button.Click += (_, _) => + { + action(); + editor.Focus(); + }; + return button; + } + } +} diff --git a/samples/SharpEquations.Avalonia.Demo/Program.cs b/samples/SharpEquations.Avalonia.Demo/Program.cs index c3360f5..c11eab2 100644 --- a/samples/SharpEquations.Avalonia.Demo/Program.cs +++ b/samples/SharpEquations.Avalonia.Demo/Program.cs @@ -4,10 +4,7 @@ namespace SharpEquations.Avalonia.Demo using global::Avalonia; using global::Avalonia.Controls; using global::Avalonia.Controls.ApplicationLifetimes; - using global::Avalonia.Layout; - using global::Avalonia.Media; using global::Avalonia.Themes.Fluent; - using SharpEquations; public static class Program { @@ -28,63 +25,4 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } } - - /// - /// The editor, with what it holds in every format: type UnicodeMath (or LaTeX) and it builds up as in Word; - /// Ctrl+Shift+L shows it as linear text and back. - /// - public sealed class MainWindow : Window - { - public MainWindow() - { - Title = "SharpEquations — MathEditBox"; - Width = 900; - Height = 640; - - var editor = new MathEditBox { FontSize = 30, MinHeight = 80, BorderBrush = Brushes.SteelBlue, Padding = new Thickness(12) }; - var format = new ComboBox { ItemsSource = new[] { "UnicodeMath", "LaTeX" }, SelectedIndex = 0 }; - format.SelectionChanged += (_, _) => editor.InputFormat = format.SelectedIndex == 1 ? EquationFormat.LaTeX : EquationFormat.UnicodeMath; - var linear = new CheckBox { Content = "Linear (Ctrl+Shift+L)" }; - linear.IsCheckedChanged += (_, _) => editor.ViewMode = linear.IsChecked == true ? MathViewMode.Linear : MathViewMode.Professional; - var display = new CheckBox { Content = "Display", IsChecked = true }; - display.IsCheckedChanged += (_, _) => editor.IsDisplay = display.IsChecked == true; - - TextBox Output() => new TextBox { IsReadOnly = true, TextWrapping = TextWrapping.Wrap, FontFamily = new FontFamily("monospace"), MinHeight = 32 }; - var mathML = Output(); - var unicodeMath = Output(); - var latex = Output(); - var view = new MathView { FontSize = 22 }; - - editor.EquationChanged += (_, e) => - { - linear.IsChecked = editor.ViewMode == MathViewMode.Linear; - mathML.Text = e.Equation?.ToString(EquationFormat.MathML); - unicodeMath.Text = e.Equation?.ToString(EquationFormat.UnicodeMath); - latex.Text = e.Equation == null ? null : $"{e.Equation:L}"; - view.Equation = e.Equation; - }; - - Control Labelled(string label, Control control) => new StackPanel { Spacing = 4, Children = { new TextBlock { Text = label, FontWeight = FontWeight.SemiBold }, control } }; - - Content = new ScrollViewer - { - Content = new StackPanel - { - Margin = new Thickness(16), - Spacing = 12, - Children = - { - new StackPanel { Orientation = Orientation.Horizontal, Spacing = 16, Children = { format, linear, display } }, - editor, - new TextBlock { Text = "Type a/b, x^2, \\sqrt(x), \\alpha, (a+b)/c, \\sum_(i=1)^n i, then a space or an operator to build up. Backspace selects a structure, then deletes it.", TextWrapping = TextWrapping.Wrap, Opacity = 0.7 }, - Labelled("MathView", view), - Labelled("MathML", mathML), - Labelled("UnicodeMath", unicodeMath), - Labelled("LaTeX", latex), - }, - }, - }; - Opened += (_, _) => editor.Focus(); - } - } } diff --git a/src/SharpEquations.Avalonia/MathEditBox.cs b/src/SharpEquations.Avalonia/MathEditBox.cs index 9de4f35..13be271 100644 --- a/src/SharpEquations.Avalonia/MathEditBox.cs +++ b/src/SharpEquations.Avalonia/MathEditBox.cs @@ -5,6 +5,7 @@ namespace SharpEquations.Avalonia using System.Runtime.InteropServices; using System.Threading.Tasks; using global::Avalonia; + using global::Avalonia.Automation.Peers; using global::Avalonia.Controls; using global::Avalonia.Controls.Documents; using global::Avalonia.Data; @@ -86,6 +87,13 @@ public class MathEditBox : Control public static readonly StyledProperty MathFontFamilyProperty = AvaloniaProperty.Register(nameof(MathFontFamily)); + /// + /// The format of the text that puts on the clipboard; null (the default) for the input + /// format. MathML pastes as an equation into Word. + /// + public static readonly StyledProperty CopyTextFormatProperty = + AvaloniaProperty.Register(nameof(CopyTextFormat), validate: f => f != EquationFormat.Mtef); + /// The gesture that switches between the built up and the linear view. public static readonly StyledProperty ToggleViewModeGestureProperty = AvaloniaProperty.Register(nameof(ToggleViewModeGesture), new KeyGesture(Key.L, KeyModifiers.Control | KeyModifiers.Shift)); @@ -121,6 +129,12 @@ public MathEditBox() }; } + /// + /// Raised when the selection is copied, before it goes to the clipboard: a handler can add formats to the item + /// (OMML, a picture...) or cancel the copy. + /// + public event EventHandler? Copying; + /// Raised when the equation changes, by an edit or by . public event EventHandler? EquationChanged; @@ -239,6 +253,13 @@ public KeyGesture? ToggleViewModeGesture set => SetValue(ToggleViewModeGestureProperty, value); } + /// The format of the text copied to the clipboard; null for the input format. + public EquationFormat? CopyTextFormat + { + get => GetValue(CopyTextFormatProperty); + set => SetValue(CopyTextFormatProperty, value); + } + /// The layout of the equation, after a measure. public MathLayout? Layout => drawing?.Layout; @@ -261,22 +282,41 @@ private Point Origin /// The equation in a format; for LaTeX, the LaTeX of Word. public string GetText(EquationFormat format) => Editor.GetText(format); + /// The number of the equation, shown at the right of its line; null without one (). + public string? EquationNumber + { + get => Editor.EquationNumber; + set => Editor.SetEquationNumber(value); + } + /// Builds up all that is typed and not built up yet. public void BuildUp() => Editor.BuildUpAll(); /// Builds down the selection, or the whole equation, into linear text. public void BuildDown() => Editor.BuildDown(); - /// Copies the selection to the clipboard: as linear text in the input format, and as MathML. + /// + /// The selection in a format, or null when nothing is selected: UnicodeMath, LaTeX (as Word writes it unless the + /// options say otherwise), MathML or OMML. + /// + public string? GetSelectedText(EquationFormat format, EquationOptions? options = null) => Editor.GetSelectedText(format, options); + + /// + /// Copies the selection to the clipboard: as text in (the input format by default) + /// and as MathML (), with the formats a handler of adds. + /// public async Task CopyAsync() { if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard || Editor.GetSelectedEquation() is not { } equation) return; var item = new DataTransferItem(); - item.SetText(Editor.InputFormat == EquationFormat.LaTeX - ? equation.ToString(EquationFormat.LaTeX, new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }) - : equation.ToString(EquationFormat.UnicodeMath)); - item.Set(MathMLFormat, equation.ToString(EquationFormat.MathML)); + var format = CopyTextFormat ?? Editor.InputFormat; + item.SetText(format is EquationFormat.UnicodeMath or EquationFormat.LaTeX ? Editor.GetSelectedText(format)! : equation.ToString(format)); + item.Set(MathMLClipboardFormat, equation.ToString(EquationFormat.MathML)); + var args = new MathCopyingEventArgs(equation, item); + Copying?.Invoke(this, args); + if (args.Cancel) + return; var data = new DataTransfer(); data.Add(item); await clipboard.SetDataAsync(data).ConfigureAwait(true); @@ -297,7 +337,7 @@ public async Task PasteAsync() if (IsReadOnly || TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; var formats = await clipboard.GetDataFormatsAsync().ConfigureAwait(true); - if (formats.Contains(MathMLFormat) && await clipboard.TryGetValueAsync(MathMLFormat).ConfigureAwait(true) is { Length: > 0 } mathML + if (formats.Contains(MathMLClipboardFormat) && await clipboard.TryGetValueAsync(MathMLClipboardFormat).ConfigureAwait(true) is { Length: > 0 } mathML && Editor.Paste(mathML, EquationFormat.MathML)) { return; @@ -311,7 +351,8 @@ public async Task PasteAsync() Editor.Paste(text, Editor.InputFormat); } - private static DataFormat MathMLFormat { get; } = + /// The format of MathML on the clipboard: MathML on Windows (which Word reads), application/mathml+xml elsewhere. + public static DataFormat MathMLClipboardFormat { get; } = DataFormat.CreateStringPlatformFormat(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MathML" : "application/mathml+xml"); /// @@ -356,7 +397,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang protected override Size MeasureOverride(Size availableSize) { drawing?.Dispose(); - var layout = MathLayoutEngine.Layout(Editor.Root, MathFonts.Get(MathFontFamily), new MathLayoutOptions { FontSize = FontSize, ShowPlaceholders = true }); + var line = availableSize.Width - Padding.Left - Padding.Right - BorderThickness * 2; + var options = new MathLayoutOptions { FontSize = FontSize, ShowPlaceholders = true, LineWidth = double.IsInfinity(line) ? null : line }; + var layout = MathLayoutEngine.Layout(Editor.Root, MathFonts.Get(MathFontFamily), options); drawing = new MathDrawing(layout); var b = BorderThickness * 2; var width = Math.Max(layout.Width, FontSize * 0.6); @@ -393,6 +436,9 @@ public override void Render(DrawingContext context) } } + /// + protected override AutomationPeer OnCreateAutomationPeer() => new MathEditBoxAutomationPeer(this); + /// protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { @@ -467,6 +513,8 @@ bool Matches(System.Collections.Generic.List? gestures, Key key) => case Key.Delete when !IsReadOnly: Editor.Delete(); break; + case Key.Tab when (e.KeyModifiers & ~KeyModifiers.Shift) == 0 && Editor.MoveToNextCell(shift): + break; case Key.Enter when !IsReadOnly: Editor.BuildUpAll(); break; diff --git a/src/SharpEquations.Avalonia/MathEditBoxAutomationPeer.cs b/src/SharpEquations.Avalonia/MathEditBoxAutomationPeer.cs new file mode 100644 index 0000000..8c87741 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathEditBoxAutomationPeer.cs @@ -0,0 +1,48 @@ +namespace SharpEquations.Avalonia +{ + using global::Avalonia.Automation.Peers; + using global::Avalonia.Automation.Provider; + + /// + /// The automation peer of : an edit control whose value is the equation in UnicodeMath, as + /// screen readers read the equations of Word; setting the value reads it in the input format. + /// + public class MathEditBoxAutomationPeer : ControlAutomationPeer, IValueProvider + { + /// The peer of an editor. + public MathEditBoxAutomationPeer(MathEditBox owner) + : base(owner) + { + } + + /// The editor. + public new MathEditBox Owner => (MathEditBox)base.Owner; + + /// + public bool IsReadOnly => Owner.IsReadOnly; + + /// + public string Value => Owner.GetText(EquationFormat.UnicodeMath); + + /// + public void SetValue(string? value) + { + if (IsReadOnly) + return; + Owner.Editor.SelectAll(); + if (string.IsNullOrEmpty(value)) + Owner.Editor.DeleteSelection(); + else + Owner.Editor.Paste(value!, Owner.InputFormat); + } + + /// + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Edit; + + /// + protected override string GetClassNameCore() => nameof(MathEditBox); + + /// + protected override string? GetNameCore() => base.GetNameCore() is { Length: > 0 } name ? name : "Equation"; + } +} diff --git a/src/SharpEquations.Avalonia/MathView.cs b/src/SharpEquations.Avalonia/MathView.cs index 41d37e6..702d1d0 100644 --- a/src/SharpEquations.Avalonia/MathView.cs +++ b/src/SharpEquations.Avalonia/MathView.cs @@ -92,7 +92,7 @@ protected override Size MeasureOverride(Size availableSize) drawing = null; if (Equation is not { } equation) return default; - var layout = MathLayoutEngine.Layout(equation, MathFonts.Get(MathFontFamily), new MathLayoutOptions { FontSize = FontSize, Display = IsDisplay }); + var layout = MathLayoutEngine.Layout(equation, MathFonts.Get(MathFontFamily), new MathLayoutOptions { FontSize = FontSize, Display = IsDisplay, LineWidth = double.IsInfinity(availableSize.Width) ? null : availableSize.Width }); drawing = new MathDrawing(layout); return new Size(layout.Width, layout.Height); } diff --git a/src/SharpEquations.Avalonia/MathViewMode.cs b/src/SharpEquations.Avalonia/MathViewMode.cs index 030ea1b..ea5a88c 100644 --- a/src/SharpEquations.Avalonia/MathViewMode.cs +++ b/src/SharpEquations.Avalonia/MathViewMode.cs @@ -21,4 +21,24 @@ public sealed class EquationChangedEventArgs : EventArgs /// The equation, or null when the editor is empty. public Equation? Equation { get; } } + + /// The arguments of . + public sealed class MathCopyingEventArgs : EventArgs + { + /// Arguments with the equation copied and the item of the clipboard. + public MathCopyingEventArgs(Equation equation, global::Avalonia.Input.DataTransferItem item) + { + Equation = equation; + Item = item; + } + + /// The selection copied. + public Equation Equation { get; } + + /// The item that goes to the clipboard, with the text and the MathML; a handler can add formats. + public global::Avalonia.Input.DataTransferItem Item { get; } + + /// Set to true to copy nothing. + public bool Cancel { get; set; } + } } diff --git a/src/SharpEquations.Avalonia/PublicAPI.Shipped.txt b/src/SharpEquations.Avalonia/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SharpEquations.Avalonia/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..8723370 --- /dev/null +++ b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt @@ -0,0 +1,138 @@ +#nullable enable +override SharpEquations.Avalonia.MathEditBox.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size +override SharpEquations.Avalonia.MathEditBox.OnCreateAutomationPeer() -> Avalonia.Automation.Peers.AutomationPeer! +override SharpEquations.Avalonia.MathEditBox.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnKeyDown(Avalonia.Input.KeyEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerMoved(Avalonia.Input.PointerEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerPressed(Avalonia.Input.PointerPressedEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPropertyChanged(Avalonia.AvaloniaPropertyChangedEventArgs! change) -> void +override SharpEquations.Avalonia.MathEditBox.OnTextInput(Avalonia.Input.TextInputEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.Render(Avalonia.Media.DrawingContext! context) -> void +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetAutomationControlTypeCore() -> Avalonia.Automation.Peers.AutomationControlType +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetClassNameCore() -> string! +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetNameCore() -> string? +override SharpEquations.Avalonia.MathView.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size +override SharpEquations.Avalonia.MathView.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void +override SharpEquations.Avalonia.MathView.Render(Avalonia.Media.DrawingContext! context) -> void +SharpEquations.Avalonia.AvaloniaMathFont +SharpEquations.Avalonia.AvaloniaMathFont.AvaloniaMathFont(Avalonia.Media.GlyphTypeface! typeface) -> void +SharpEquations.Avalonia.AvaloniaMathFont.GetAdvance(ushort glyph) -> double +SharpEquations.Avalonia.AvaloniaMathFont.GetBounds(ushort glyph) -> SharpEquations.Rendering.GlyphBounds +SharpEquations.Avalonia.AvaloniaMathFont.GetFallback(int codePoint) -> SharpEquations.Rendering.IMathFont? +SharpEquations.Avalonia.AvaloniaMathFont.Math.get -> SharpEquations.Rendering.OpenType.MathTable? +SharpEquations.Avalonia.AvaloniaMathFont.TryGetGlyph(int codePoint, out ushort glyph) -> bool +SharpEquations.Avalonia.AvaloniaMathFont.Typeface.get -> Avalonia.Media.GlyphTypeface! +SharpEquations.Avalonia.AvaloniaMathFont.UnitsPerEm.get -> double +SharpEquations.Avalonia.EquationChangedEventArgs +SharpEquations.Avalonia.EquationChangedEventArgs.Equation.get -> SharpEquations.Equation? +SharpEquations.Avalonia.EquationChangedEventArgs.EquationChangedEventArgs(SharpEquations.Equation? equation) -> void +SharpEquations.Avalonia.MathCopyingEventArgs +SharpEquations.Avalonia.MathCopyingEventArgs.Cancel.get -> bool +SharpEquations.Avalonia.MathCopyingEventArgs.Cancel.set -> void +SharpEquations.Avalonia.MathCopyingEventArgs.Equation.get -> SharpEquations.Equation! +SharpEquations.Avalonia.MathCopyingEventArgs.Item.get -> Avalonia.Input.DataTransferItem! +SharpEquations.Avalonia.MathCopyingEventArgs.MathCopyingEventArgs(SharpEquations.Equation! equation, Avalonia.Input.DataTransferItem! item) -> void +SharpEquations.Avalonia.MathEditBox +SharpEquations.Avalonia.MathEditBox.Background.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.Background.set -> void +SharpEquations.Avalonia.MathEditBox.BorderBrush.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.BorderBrush.set -> void +SharpEquations.Avalonia.MathEditBox.BorderThickness.get -> double +SharpEquations.Avalonia.MathEditBox.BorderThickness.set -> void +SharpEquations.Avalonia.MathEditBox.BuildDown() -> void +SharpEquations.Avalonia.MathEditBox.BuildUp() -> void +SharpEquations.Avalonia.MathEditBox.CaretBrush.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.CaretBrush.set -> void +SharpEquations.Avalonia.MathEditBox.CopyAsync() -> System.Threading.Tasks.Task! +SharpEquations.Avalonia.MathEditBox.Copying -> System.EventHandler? +SharpEquations.Avalonia.MathEditBox.CopyTextFormat.get -> SharpEquations.EquationFormat? +SharpEquations.Avalonia.MathEditBox.CopyTextFormat.set -> void +SharpEquations.Avalonia.MathEditBox.CutAsync() -> System.Threading.Tasks.Task! +SharpEquations.Avalonia.MathEditBox.Editor.get -> SharpEquations.Editing.MathEditor! +SharpEquations.Avalonia.MathEditBox.Equation.get -> SharpEquations.Equation? +SharpEquations.Avalonia.MathEditBox.Equation.set -> void +SharpEquations.Avalonia.MathEditBox.EquationChanged -> System.EventHandler? +SharpEquations.Avalonia.MathEditBox.EquationNumber.get -> string? +SharpEquations.Avalonia.MathEditBox.EquationNumber.set -> void +SharpEquations.Avalonia.MathEditBox.FontSize.get -> double +SharpEquations.Avalonia.MathEditBox.FontSize.set -> void +SharpEquations.Avalonia.MathEditBox.Foreground.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.Foreground.set -> void +SharpEquations.Avalonia.MathEditBox.GetMath() -> string! +SharpEquations.Avalonia.MathEditBox.GetSelectedText(SharpEquations.EquationFormat format, SharpEquations.EquationOptions? options = null) -> string? +SharpEquations.Avalonia.MathEditBox.GetText(SharpEquations.EquationFormat format) -> string! +SharpEquations.Avalonia.MathEditBox.InputFormat.get -> SharpEquations.EquationFormat +SharpEquations.Avalonia.MathEditBox.InputFormat.set -> void +SharpEquations.Avalonia.MathEditBox.IsDisplay.get -> bool +SharpEquations.Avalonia.MathEditBox.IsDisplay.set -> void +SharpEquations.Avalonia.MathEditBox.IsReadOnly.get -> bool +SharpEquations.Avalonia.MathEditBox.IsReadOnly.set -> void +SharpEquations.Avalonia.MathEditBox.Layout.get -> SharpEquations.Rendering.MathLayout? +SharpEquations.Avalonia.MathEditBox.MathEditBox() -> void +SharpEquations.Avalonia.MathEditBox.MathFontFamily.get -> Avalonia.Media.FontFamily? +SharpEquations.Avalonia.MathEditBox.MathFontFamily.set -> void +SharpEquations.Avalonia.MathEditBox.Padding.get -> Avalonia.Thickness +SharpEquations.Avalonia.MathEditBox.Padding.set -> void +SharpEquations.Avalonia.MathEditBox.PasteAsync() -> System.Threading.Tasks.Task! +SharpEquations.Avalonia.MathEditBox.PlaceholderBrush.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.PlaceholderBrush.set -> void +SharpEquations.Avalonia.MathEditBox.SelectionBrush.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathEditBox.SelectionBrush.set -> void +SharpEquations.Avalonia.MathEditBox.SetMath(string! mathML) -> void +SharpEquations.Avalonia.MathEditBox.ToggleViewModeGesture.get -> Avalonia.Input.KeyGesture? +SharpEquations.Avalonia.MathEditBox.ToggleViewModeGesture.set -> void +SharpEquations.Avalonia.MathEditBox.ViewMode.get -> SharpEquations.Avalonia.MathViewMode +SharpEquations.Avalonia.MathEditBox.ViewMode.set -> void +SharpEquations.Avalonia.MathEditBoxAutomationPeer +SharpEquations.Avalonia.MathEditBoxAutomationPeer.IsReadOnly.get -> bool +SharpEquations.Avalonia.MathEditBoxAutomationPeer.MathEditBoxAutomationPeer(SharpEquations.Avalonia.MathEditBox! owner) -> void +SharpEquations.Avalonia.MathEditBoxAutomationPeer.Owner.get -> SharpEquations.Avalonia.MathEditBox! +SharpEquations.Avalonia.MathEditBoxAutomationPeer.SetValue(string? value) -> void +SharpEquations.Avalonia.MathEditBoxAutomationPeer.Value.get -> string! +SharpEquations.Avalonia.MathFonts +SharpEquations.Avalonia.MathView +SharpEquations.Avalonia.MathView.Equation.get -> SharpEquations.Equation? +SharpEquations.Avalonia.MathView.Equation.set -> void +SharpEquations.Avalonia.MathView.FontSize.get -> double +SharpEquations.Avalonia.MathView.FontSize.set -> void +SharpEquations.Avalonia.MathView.Foreground.get -> Avalonia.Media.IBrush? +SharpEquations.Avalonia.MathView.Foreground.set -> void +SharpEquations.Avalonia.MathView.IsDisplay.get -> bool +SharpEquations.Avalonia.MathView.IsDisplay.set -> void +SharpEquations.Avalonia.MathView.Layout.get -> SharpEquations.Rendering.MathLayout? +SharpEquations.Avalonia.MathView.MathFontFamily.get -> Avalonia.Media.FontFamily? +SharpEquations.Avalonia.MathView.MathFontFamily.set -> void +SharpEquations.Avalonia.MathView.MathML.get -> string? +SharpEquations.Avalonia.MathView.MathML.set -> void +SharpEquations.Avalonia.MathView.MathView() -> void +SharpEquations.Avalonia.MathViewMode +SharpEquations.Avalonia.MathViewMode.Linear = 1 -> SharpEquations.Avalonia.MathViewMode +SharpEquations.Avalonia.MathViewMode.Professional = 0 -> SharpEquations.Avalonia.MathViewMode +static readonly SharpEquations.Avalonia.MathEditBox.BackgroundProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.BorderBrushProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.BorderThicknessProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.CaretBrushProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.CopyTextFormatProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.EquationProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.FontSizeProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.ForegroundProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.InputFormatProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.IsDisplayProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.IsReadOnlyProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.MathFontFamilyProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.PaddingProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.PlaceholderBrushProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.SelectionBrushProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.ToggleViewModeGestureProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.ViewModeProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathView.EquationProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathView.FontSizeProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathView.ForegroundProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathView.IsDisplayProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathView.MathFontFamilyProperty -> Avalonia.StyledProperty! +static SharpEquations.Avalonia.MathEditBox.MathMLClipboardFormat.get -> Avalonia.Input.DataFormat! +static SharpEquations.Avalonia.MathFonts.Default.get -> SharpEquations.Avalonia.AvaloniaMathFont! +static SharpEquations.Avalonia.MathFonts.Get(Avalonia.Media.FontFamily? family) -> SharpEquations.Avalonia.AvaloniaMathFont! +static SharpEquations.Avalonia.MathFonts.LibertinusMath.get -> Avalonia.Media.FontFamily! +static SharpEquations.Avalonia.MathFonts.TryGet(Avalonia.Media.FontFamily! family, out SharpEquations.Avalonia.AvaloniaMathFont? font) -> bool diff --git a/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj b/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj index 4752691..639f0ac 100644 --- a/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj +++ b/src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj @@ -17,6 +17,8 @@ + + @@ -25,6 +27,8 @@ + + diff --git a/src/SharpEquations.Editing/BuildUp.cs b/src/SharpEquations.Editing/BuildUp.cs index fdb99ca..6b7ba90 100644 --- a/src/SharpEquations.Editing/BuildUp.cs +++ b/src/SharpEquations.Editing/BuildUp.cs @@ -57,6 +57,24 @@ or 0x252C or 0x2534 or 0x221A or 0x221B or 0x221C or 0x2592 or 0x27E8 or 0x3016 or 0x2061 or 0x2044 or 0x2215 or 0x2298; } + /// + /// Whether the text ends with a control word (\mathring) that the nodes built from it leave as text + /// (mtext), or as the empty box of a style command (\rm): its arguments are still to come. + /// + public static bool EndsWithPendingCommand(string text, IReadOnlyList nodes) + { + var i = text.Length; + while (i > 0 && text[i - 1] < 0x80 && char.IsLetter(text[i - 1])) + i--; + if (i == text.Length || i == 0 || text[i - 1] != '\\') + return false; + var command = text.Substring(i - 1); + return nodes.Any(n => Pending(n, command)); + + static bool Pending(MathNode node, string command) => + node.Name == "mtext" && node.Text.Contains(command) || node.Name == "mi" && node.Text == "\u2B1A" || node.Children.Any(c => Pending(c, command)); + } + /// Whether the brackets (UnicodeMath) or the groups and environments (LaTeX) of the text are closed. public static bool IsBalanced(string text, EquationFormat format) { @@ -203,6 +221,13 @@ private static void Append(StringBuilder sb, MathNode node, List place return; } + // a word of LaTeX (a function: tan, max, or \operatorname{sn}) is one identifier + if (format == EquationFormat.LaTeX && node.Name == "mi" && node.Attributes.Count == 0 && node.Text.Length > 1 && node.Text.All(c => c < 0x80 && char.IsLetter(c))) + { + sb.Append("\\operatorname{").Append(node.Text).Append('}'); + return; + } + if (node.IsRow && node.Attributes.Count == 0 && node.Children.Count >= 2 && node.Children[0].Name == "mo" && node.Children[node.Children.Count - 1].Name == "mo" && IsFence(node.Children[0].Text) && IsFence(node.Children[node.Children.Count - 1].Text)) diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs index 0845d0a..a5028d0 100644 --- a/src/SharpEquations.Editing/MathEditor.cs +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -19,8 +19,8 @@ namespace SharpEquations.Editing /// public sealed class MathEditor { - private readonly Stack undo = new Stack(); - private readonly Stack redo = new Stack(); + private readonly History undo = new History(); + private readonly History redo = new History(); private EquationFormat inputFormat = EquationFormat.UnicodeMath; private MathPosition? typingAt; @@ -87,6 +87,22 @@ public bool IsDisplay /// Whether has something to redo. public bool CanRedo => redo.Count > 0; + /// + /// How many edits can undo at most (1000 by default); the oldest are forgotten. The states are + /// immutable trees that share what an edit did not change, so that each costs only the path it changed. + /// + /// A negative limit. + public int UndoLimit + { + get => undo.Limit; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value)); + undo.Limit = redo.Limit = value; + } + } + /// The equation is empty. public bool IsEmpty => RootRow.Children.Count == 0; @@ -272,6 +288,134 @@ public IReadOnlyList GetSelectedNodes() return BuildUp.ToEquation(row.Children, IsDisplay); } + /// + /// The selection in a format, or null when nothing is selected: UnicodeMath, LaTeX (as Word writes it unless the + /// options say otherwise), MathML or OMML. + /// + /// MTEF, which cannot be written. + public string? GetSelectedText(EquationFormat format, EquationOptions? options = null) + { + if (!EquationConverter.CanWrite(format)) + throw new ArgumentException("The format cannot be written.", nameof(format)); + var equation = GetSelectedEquation(); + if (equation == null) + return null; + if (options == null && format is EquationFormat.UnicodeMath or EquationFormat.LaTeX) + return BuildUp.ToLinear(equation, format); + return equation.ToString(format, options); + } + + /// + /// Inserts a structure with empty slots (mfrac, msqrt, mroot, msub, msup, + /// msubsup, munder, mover, munderover, menclose...), as the buttons of the + /// equation tools of Word: the selection goes into its first slot, and the caret into the first empty slot. + /// + /// An element that is not a structure with slots. + public void InsertStructure(string name, IReadOnlyList>? attributes = null) + { + var slots = name switch + { + "msqrt" or "menclose" or "mstyle" or "mphantom" or "mpadded" or "merror" => 1, + "mfrac" or "mroot" or "msub" or "msup" or "munder" or "mover" => 2, + "msubsup" or "munderover" => 3, + _ => throw new ArgumentException("Not a structure with slots: " + name, nameof(name)), + }; + var first = GetSelectedNodes().ToList(); + var rows = Enumerable.Range(0, slots).Select(i => MathNode.Row(i == 0 ? first : new List())).ToList(); + InsertWithSlots(MathNode.Element(name, rows, attributes)); + } + + /// + /// Inserts a template: a node whose empty rows are slots to fill (an n-ary operator with its limits, an accent + /// over an empty base...). The caret goes into its first empty slot, or after it. + /// + public void InsertTemplate(MathNode node) => InsertWithSlots(node ?? throw new ArgumentNullException(nameof(node))); + + /// Inserts a pair of fences, ( and ) by default, around the selection or an empty slot. + public void InsertFences(string open = "(", string close = ")") + { + var children = new List { MathNode.Token("mo", open) }; + children.AddRange(GetSelectedNodes()); + children.Add(MathNode.Token("mo", close)); + InsertWithSlots(MathNode.Row(children)); + } + + /// Inserts a table of empty cells: a matrix, alone or between fences (such as ( and )). + /// Fewer than one row or column. + public void InsertMatrix(int rows, int columns, string? open = null, string? close = null) + { + if (rows < 1) + throw new ArgumentOutOfRangeException(nameof(rows)); + if (columns < 1) + throw new ArgumentOutOfRangeException(nameof(columns)); + var empty = MathNode.Row(Array.Empty()); + var table = MathNode.Element("mtable", Enumerable.Range(0, rows).Select(_ => + MathNode.Element("mtr", Enumerable.Range(0, columns).Select(_ => MathNode.Element("mtd", new[] { empty }))))); + InsertWithSlots(open == null && close == null + ? table + : MathNode.Row(new[] { MathNode.Token("mo", open ?? string.Empty), table, MathNode.Token("mo", close ?? string.Empty) })); + } + + /// + /// Moves the caret to the next cell of the table it is in (the previous one, backward), selecting nothing; + /// returns false when the caret is not in a table or is in its last cell (its first, backward). + /// + public bool MoveToNextCell(bool backward = false) + { + var (rowPath, _) = RowPosition(Canonical(Caret)); + var current = rowPath; + while (MathTree.Owner(Root, current) is { } owner) + { + if (MathTree.Get(Root, owner.Item).Name == "mtable") + { + var next = owner.Index + (backward ? -1 : 1); + if (next < 0 || next >= owner.Slots.Length) + return false; + var cell = owner.Slots[next]; + Move(Canonical(new MathPosition(cell, backward ? MathTree.Get(Root, cell).Children.Count : 0)), false); + return true; + } + current = MathTree.Parent(owner.Item); + } + return false; + } + + /// + /// The number of the equation, as UnicodeMath writes it after # and LaTeX in \tag: the label of an + /// equation that is one labeled row (mtable with one mlabeledtr); null without one. + /// + public string? EquationNumber => NumberedRow() is { } row ? string.Concat(Text(row.Children[0])) : null; + + /// Numbers the equation, shown at the right of its line; null removes the number. + public void SetEquationNumber(string? number) + { + var labeled = NumberedRow(); + if (labeled == null && number == null) + return; + PushUndo(); + var label = MathNode.Element("mtd", new[] { MathNode.Row(new[] { MathNode.Token("mtext", number ?? string.Empty) }) }); + var content = labeled == null ? RootRow : labeled.Children[1].Children[0]; + MathNode row; + if (number == null) + { + row = MathNode.Row(content.Children); + } + else + { + // the id of the label as UnicodeMath gives it: its text without the brackets, between hyphens + var id = "-" + number.Trim().Trim('(', ')', '[', ']', '{', '}') + "-"; + var labeledRow = MathNode.Element("mlabeledtr", new[] { label, MathNode.Element("mtd", new[] { content }) }, new[] { new KeyValuePair("id", id) }); + row = MathNode.Row(new[] + { + MathNode.Element("mtable", new[] { labeledRow }, new[] { new KeyValuePair("displaystyle", "true") }), + }); + } + Root = Root.WithChild(0, row); + var contentPath = number == null ? MathTree.RootRow : new[] { 0, 0, 0, 1, 0 }; + SetCaret(contentPath, MathTree.Get(Root, contentPath).Children.Count); + OnChanged(true); + } + /// Deletes the selection; returns whether something was selected. public bool DeleteSelection() { @@ -357,6 +501,56 @@ public void Redo() Restore(redo.Pop()); } + /// Inserts a node with slots, and puts the caret in its first empty slot (or after it). + private void InsertWithSlots(MathNode node) + { + PushUndo(); + if (Selection != null) + RemoveSelection(); + var (rowPath, index) = SplitRunAtCaret(); + var row = MathTree.Get(Root, rowPath); + var children = row.Children.ToList(); + children.Insert(index, node); + Root = MathTree.Replace(Root, rowPath, row.WithChildren(children)); + var item = MathTree.Append(rowPath, index); + if (FirstEmptySlot(item) is { } empty) + SetCaret(empty, 0); + else if (node.IsRow) + SetCaret(item, node.Children.Count - 1); + else + SetCaret(rowPath, index + 1); + OnChanged(true); + } + + /// The labeled row of an equation that is one, or null. + private MathNode? NumberedRow() + { + if (RootRow.Children.Count != 1 || RootRow.Children[0] is not { Name: "mtable" } table || table.Children.Count != 1) + return null; + var row = table.Children[0]; + return row.Name == "mlabeledtr" && row.Children.Count == 2 && row.Children.All(c => c.Children.Count == 1 && c.Children[0].IsRow) ? row : null; + } + + private static IEnumerable Text(MathNode node) => + node.IsToken || node.IsRun ? new[] { node.Text } : node.Children.SelectMany(Text); + + private int[]? FirstEmptySlot(int[] item) + { + foreach (var slot in MathSchema.Slots(MathTree.Get(Root, item))) + { + var path = MathTree.Append(item, slot); + var row = MathTree.Get(Root, path); + if (row.Children.Count == 0) + return path; + for (var i = 0; i < row.Children.Count; i++) + { + if (FirstEmptySlot(MathTree.Append(path, i)) is { } inner) + return inner; + } + } + return null; + } + private static MathNode Empty(bool display) => MathNode.Element( "math", new[] { MathNode.Row(Array.Empty()) }, @@ -430,7 +624,7 @@ private bool TryBuildUpAtCaret(bool space) return false; var nodes = BuildUp.Parse(row.Children.Take(j).ToList(), text, InputFormat, IsDisplay); - if (nodes == null) + if (nodes == null || space && BuildUp.EndsWithPendingCommand(text, nodes)) return false; undo.Push(Snapshot()); @@ -843,6 +1037,32 @@ private void OnChanged(bool content) Changed?.Invoke(this, EventArgs.Empty); } + /// A stack of states that forgets the oldest beyond its limit. + private sealed class History + { + private readonly LinkedList states = new LinkedList(); + + public int Limit { get; set; } = 1000; + + public int Count => states.Count; + + public void Push(State state) + { + states.AddLast(state); + while (states.Count > Limit) + states.RemoveFirst(); + } + + public State Pop() + { + var state = states.Last!.Value; + states.RemoveLast(); + return state; + } + + public void Clear() => states.Clear(); + } + private sealed class State { public State(MathNode root, MathPosition caret, MathPosition anchor, bool linear) diff --git a/src/SharpEquations.Editing/MathNode.cs b/src/SharpEquations.Editing/MathNode.cs index 91065af..bee3c76 100644 --- a/src/SharpEquations.Editing/MathNode.cs +++ b/src/SharpEquations.Editing/MathNode.cs @@ -173,9 +173,11 @@ internal IEnumerable ToMathML(Func> ru var result = new XElement(ns + Name, Attributes.Select(a => new XAttribute(XName.Get(a.Key), a.Value))); switch (MathSchema.KindOf(Name)) { - case ElementKind.Token: + case ElementKind.Token when Text.Length > 0: result.Add(Text); break; + case ElementKind.Token: + break; case ElementKind.Leaf: break; case ElementKind.InferredRow when Children.Count == 1 && Children[0].IsRow && Children[0].Attributes.Count == 0: diff --git a/src/SharpEquations.Editing/PublicAPI.Shipped.txt b/src/SharpEquations.Editing/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SharpEquations.Editing/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SharpEquations.Editing/PublicAPI.Unshipped.txt b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..a5276aa --- /dev/null +++ b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt @@ -0,0 +1,95 @@ +#nullable enable +const SharpEquations.Editing.MathNode.RunName = "#run" -> string! +override SharpEquations.Editing.MathNode.ToString() -> string! +override SharpEquations.Editing.MathPosition.Equals(object? obj) -> bool +override SharpEquations.Editing.MathPosition.GetHashCode() -> int +override SharpEquations.Editing.MathPosition.ToString() -> string! +SharpEquations.Editing.IMathGeometry +SharpEquations.Editing.IMathGeometry.GetCaretX(SharpEquations.Editing.MathPosition! position) -> double? +SharpEquations.Editing.IMathGeometry.GetNearestPosition(System.Collections.Generic.IReadOnlyList! rowPath, double x) -> SharpEquations.Editing.MathPosition? +SharpEquations.Editing.MathEditor +SharpEquations.Editing.MathEditor.Anchor.get -> SharpEquations.Editing.MathPosition! +SharpEquations.Editing.MathEditor.Backspace() -> void +SharpEquations.Editing.MathEditor.BuildDown() -> void +SharpEquations.Editing.MathEditor.BuildUpAll() -> void +SharpEquations.Editing.MathEditor.CanRedo.get -> bool +SharpEquations.Editing.MathEditor.CanUndo.get -> bool +SharpEquations.Editing.MathEditor.Caret.get -> SharpEquations.Editing.MathPosition! +SharpEquations.Editing.MathEditor.Changed -> System.EventHandler? +SharpEquations.Editing.MathEditor.ContentChanged -> System.EventHandler? +SharpEquations.Editing.MathEditor.Delete() -> void +SharpEquations.Editing.MathEditor.DeleteSelection() -> bool +SharpEquations.Editing.MathEditor.EquationNumber.get -> string? +SharpEquations.Editing.MathEditor.GetSelectedEquation() -> SharpEquations.Equation? +SharpEquations.Editing.MathEditor.GetSelectedNodes() -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Editing.MathEditor.GetSelectedText(SharpEquations.EquationFormat format, SharpEquations.EquationOptions? options = null) -> string? +SharpEquations.Editing.MathEditor.GetText(SharpEquations.EquationFormat format) -> string! +SharpEquations.Editing.MathEditor.InputFormat.get -> SharpEquations.EquationFormat +SharpEquations.Editing.MathEditor.InputFormat.set -> void +SharpEquations.Editing.MathEditor.Insert(string! text) -> void +SharpEquations.Editing.MathEditor.InsertFences(string! open = "(", string! close = ")") -> void +SharpEquations.Editing.MathEditor.InsertMatrix(int rows, int columns, string? open = null, string? close = null) -> void +SharpEquations.Editing.MathEditor.InsertNodes(System.Collections.Generic.IEnumerable! nodes) -> void +SharpEquations.Editing.MathEditor.InsertStructure(string! name, System.Collections.Generic.IReadOnlyList>? attributes = null) -> void +SharpEquations.Editing.MathEditor.InsertTemplate(SharpEquations.Editing.MathNode! node) -> void +SharpEquations.Editing.MathEditor.IsDisplay.get -> bool +SharpEquations.Editing.MathEditor.IsDisplay.set -> void +SharpEquations.Editing.MathEditor.IsEmpty.get -> bool +SharpEquations.Editing.MathEditor.IsLinear.get -> bool +SharpEquations.Editing.MathEditor.Load(SharpEquations.Equation? equation) -> void +SharpEquations.Editing.MathEditor.MathEditor(SharpEquations.Equation? equation = null) -> void +SharpEquations.Editing.MathEditor.MoveDown(bool extend = false, SharpEquations.Editing.IMathGeometry? geometry = null) -> void +SharpEquations.Editing.MathEditor.MoveEnd(bool extend = false) -> void +SharpEquations.Editing.MathEditor.MoveHome(bool extend = false) -> void +SharpEquations.Editing.MathEditor.MoveLeft(bool extend = false) -> void +SharpEquations.Editing.MathEditor.MoveRight(bool extend = false) -> void +SharpEquations.Editing.MathEditor.MoveToNextCell(bool backward = false) -> bool +SharpEquations.Editing.MathEditor.MoveUp(bool extend = false, SharpEquations.Editing.IMathGeometry? geometry = null) -> void +SharpEquations.Editing.MathEditor.Paste(string! text, SharpEquations.EquationFormat format) -> bool +SharpEquations.Editing.MathEditor.Redo() -> void +SharpEquations.Editing.MathEditor.Root.get -> SharpEquations.Editing.MathNode! +SharpEquations.Editing.MathEditor.SelectAll() -> void +SharpEquations.Editing.MathEditor.Selection.get -> SharpEquations.Editing.MathSelection? +SharpEquations.Editing.MathEditor.SetCaret(SharpEquations.Editing.MathPosition! position, bool extend = false) -> void +SharpEquations.Editing.MathEditor.SetEquationNumber(string? number) -> void +SharpEquations.Editing.MathEditor.SetLinear(bool linear) -> void +SharpEquations.Editing.MathEditor.ToEquation() -> SharpEquations.Equation? +SharpEquations.Editing.MathEditor.ToMathML() -> System.Xml.Linq.XElement! +SharpEquations.Editing.MathEditor.Undo() -> void +SharpEquations.Editing.MathEditor.UndoLimit.get -> int +SharpEquations.Editing.MathEditor.UndoLimit.set -> void +SharpEquations.Editing.MathNode +SharpEquations.Editing.MathNode.Attributes.get -> System.Collections.Generic.IReadOnlyList>! +SharpEquations.Editing.MathNode.Children.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Editing.MathNode.GetAttribute(string! name) -> string? +SharpEquations.Editing.MathNode.IsOpaque.get -> bool +SharpEquations.Editing.MathNode.IsRow.get -> bool +SharpEquations.Editing.MathNode.IsRun.get -> bool +SharpEquations.Editing.MathNode.IsToken.get -> bool +SharpEquations.Editing.MathNode.Name.get -> string! +SharpEquations.Editing.MathNode.Source.get -> System.Xml.Linq.XElement? +SharpEquations.Editing.MathNode.Text.get -> string! +SharpEquations.Editing.MathNode.ToMathML() -> System.Xml.Linq.XElement! +SharpEquations.Editing.MathNode.WithAttribute(string! name, string? value) -> SharpEquations.Editing.MathNode! +SharpEquations.Editing.MathNode.WithChild(int index, SharpEquations.Editing.MathNode! child) -> SharpEquations.Editing.MathNode! +SharpEquations.Editing.MathNode.WithChildren(System.Collections.Generic.IEnumerable! children) -> SharpEquations.Editing.MathNode! +SharpEquations.Editing.MathNode.WithText(string! text) -> SharpEquations.Editing.MathNode! +SharpEquations.Editing.MathPosition +SharpEquations.Editing.MathPosition.Equals(SharpEquations.Editing.MathPosition? other) -> bool +SharpEquations.Editing.MathPosition.MathPosition(System.Collections.Generic.IEnumerable! path, int offset) -> void +SharpEquations.Editing.MathPosition.Offset.get -> int +SharpEquations.Editing.MathPosition.Path.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Editing.MathSelection +SharpEquations.Editing.MathSelection.End.get -> int +SharpEquations.Editing.MathSelection.EndPosition.get -> SharpEquations.Editing.MathPosition! +SharpEquations.Editing.MathSelection.IsInRun.get -> bool +SharpEquations.Editing.MathSelection.Path.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Editing.MathSelection.Start.get -> int +SharpEquations.Editing.MathSelection.StartPosition.get -> SharpEquations.Editing.MathPosition! +static SharpEquations.Editing.MathNode.Element(string! name, System.Collections.Generic.IEnumerable! children, System.Collections.Generic.IReadOnlyList>? attributes = null) -> SharpEquations.Editing.MathNode! +static SharpEquations.Editing.MathNode.FromMathML(System.Xml.Linq.XElement! element) -> SharpEquations.Editing.MathNode! +static SharpEquations.Editing.MathNode.Row(System.Collections.Generic.IEnumerable! children, System.Collections.Generic.IReadOnlyList>? attributes = null) -> SharpEquations.Editing.MathNode! +static SharpEquations.Editing.MathNode.Run(string! text) -> SharpEquations.Editing.MathNode! +static SharpEquations.Editing.MathNode.Token(string! name, string! text, System.Collections.Generic.IReadOnlyList>? attributes = null) -> SharpEquations.Editing.MathNode! +static SharpEquations.Editing.MathPosition.operator !=(SharpEquations.Editing.MathPosition? left, SharpEquations.Editing.MathPosition? right) -> bool +static SharpEquations.Editing.MathPosition.operator ==(SharpEquations.Editing.MathPosition? left, SharpEquations.Editing.MathPosition? right) -> bool diff --git a/src/SharpEquations.Editing/SharpEquations.Editing.csproj b/src/SharpEquations.Editing/SharpEquations.Editing.csproj index 79a4cde..a7d0d43 100644 --- a/src/SharpEquations.Editing/SharpEquations.Editing.csproj +++ b/src/SharpEquations.Editing/SharpEquations.Editing.csproj @@ -18,12 +18,17 @@ + + + + + diff --git a/src/SharpEquations.Rendering/Layout/Engine.cs b/src/SharpEquations.Rendering/Layout/Engine.cs index 9d9b8b0..e038bff 100644 --- a/src/SharpEquations.Rendering/Layout/Engine.cs +++ b/src/SharpEquations.Rendering/Layout/Engine.cs @@ -1267,7 +1267,7 @@ private static bool IsAccent(MathNode node, MathNode script, string attribute) private Box LayoutTable(MathNode table, int[]? path, Style style) { - var cellStyle = style.WithDisplay(false); + var cellStyle = style.WithDisplay(table.GetAttribute("displaystyle") == "true"); var rows = new List<(List Cells, Box? Label, MathNode Row)>(); var columns = 0; for (var r = 0; r < table.Children.Count; r++) @@ -1329,8 +1329,21 @@ private Box LayoutTable(MathNode table, int[]? path, Style style) } var total = -y; var shift = total / 2 + Axis(style); - var labelX = tableWidth + style.Size; - var hasLabels = false; + // the labels of the rows (the numbers of the equations) on the right, or on the left with side="left": at the + // edge of the line when its width is known, with the table in the middle of what is left + var labelWidth = placedRows.Select(p => p.Row.Label?.Width ?? 0).DefaultIfEmpty(0).Max(); + var hasLabels = placedRows.Any(p => p.Row.Label != null); + var left = table.GetAttribute("side") is "left" or "leftoverlap"; + var gap = style.Size; + var width = hasLabels ? tableWidth + gap + labelWidth : tableWidth; + var tableX = hasLabels && left ? labelWidth + gap : 0; + if (hasLabels && options.LineWidth is { } line && line > width) + { + width = line; + var centered = (line - tableWidth) / 2; + tableX = left ? Math.Max(centered, labelWidth + gap) : Math.Min(centered, line - labelWidth - gap - tableWidth); + } + foreach (var (baseline, (cells, label, row)) in placedRows) { var rowAligns = row.GetAttribute("columnalign")?.Split(Spaces, StringSplitOptions.RemoveEmptyEntries); @@ -1345,15 +1358,12 @@ private Box LayoutTable(MathNode table, int[]? path, Style style) "right" => lefts[c] + widths[c] - cells[c].Width, _ => lefts[c] + (widths[c] - cells[c].Width) / 2, }; - box.Add(cells[c], cx, baseline + shift); + box.Add(cells[c], tableX + cx, baseline + shift); } if (label != null) - { - box.Add(label, labelX, baseline + shift); - hasLabels = true; - } + box.Add(label, left ? 0 : width - label.Width, baseline + shift); } - box.Width = hasLabels ? labelX + placedRows.Select(p => p.Row.Label?.Width ?? 0).Max() : tableWidth; + box.Width = width; box.Height = shift; box.Depth = total - shift; return box; diff --git a/src/SharpEquations.Rendering/MathLayoutEngine.cs b/src/SharpEquations.Rendering/MathLayoutEngine.cs index 12d4acf..15c4ddb 100644 --- a/src/SharpEquations.Rendering/MathLayoutEngine.cs +++ b/src/SharpEquations.Rendering/MathLayoutEngine.cs @@ -15,6 +15,12 @@ public sealed class MathLayoutOptions /// Lays out in display style even if the math element is inline. public bool Display { get; set; } + /// + /// The width of the line the equation is in, if it is known: the numbers of equations (the labels of + /// mlabeledtr) go to its right edge, as in Word, with the equation in the middle of what is left. + /// + public double? LineWidth { get; set; } + /// Draws a box in the empty slots, for an editor. public bool ShowPlaceholders { get; set; } } @@ -25,7 +31,7 @@ public sealed class MathLayoutOptions public static class MathLayoutEngine { /// Lays out an equation. - public static MathLayout Layout(Equation equation, IMathFont font, MathLayoutOptions? options = null) + public static MathLayout Layout(Equation equation, IMathFont font, MathLayoutOptions? options) { if (equation == null) throw new ArgumentNullException(nameof(equation)); @@ -33,7 +39,7 @@ public static MathLayout Layout(Equation equation, IMathFont font, MathLayoutOpt } /// Lays out a math element of MathML. - public static MathLayout Layout(XElement math, IMathFont font, MathLayoutOptions? options = null) + public static MathLayout Layout(XElement math, IMathFont font, MathLayoutOptions? options) { if (math == null) throw new ArgumentNullException(nameof(math)); @@ -44,7 +50,7 @@ public static MathLayout Layout(XElement math, IMathFont font, MathLayoutOptions /// Lays out the math node of an editor (), with the geometry of the caret /// in every row and run. /// - public static MathLayout Layout(MathNode root, IMathFont font, MathLayoutOptions? options = null) + public static MathLayout Layout(MathNode root, IMathFont font, MathLayoutOptions? options) { if (root == null) throw new ArgumentNullException(nameof(root)); diff --git a/src/SharpEquations.Rendering/PublicAPI.Shipped.txt b/src/SharpEquations.Rendering/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SharpEquations.Rendering/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7e3b486 --- /dev/null +++ b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt @@ -0,0 +1,164 @@ +#nullable enable +override SharpEquations.Rendering.MathRect.ToString() -> string! +SharpEquations.Rendering.GlyphBounds +SharpEquations.Rendering.GlyphBounds.GlyphBounds() -> void +SharpEquations.Rendering.GlyphBounds.GlyphBounds(double xMin, double yMin, double xMax, double yMax) -> void +SharpEquations.Rendering.GlyphBounds.XMax.get -> double +SharpEquations.Rendering.GlyphBounds.XMin.get -> double +SharpEquations.Rendering.GlyphBounds.YMax.get -> double +SharpEquations.Rendering.GlyphBounds.YMin.get -> double +SharpEquations.Rendering.IMathFont +SharpEquations.Rendering.IMathFont.GetAdvance(ushort glyph) -> double +SharpEquations.Rendering.IMathFont.GetBounds(ushort glyph) -> SharpEquations.Rendering.GlyphBounds +SharpEquations.Rendering.IMathFont.GetFallback(int codePoint) -> SharpEquations.Rendering.IMathFont? +SharpEquations.Rendering.IMathFont.Math.get -> SharpEquations.Rendering.OpenType.MathTable? +SharpEquations.Rendering.IMathFont.TryGetGlyph(int codePoint, out ushort glyph) -> bool +SharpEquations.Rendering.IMathFont.UnitsPerEm.get -> double +SharpEquations.Rendering.MathGlyph +SharpEquations.Rendering.MathGlyph.Color.get -> string? +SharpEquations.Rendering.MathGlyph.Font.get -> SharpEquations.Rendering.IMathFont! +SharpEquations.Rendering.MathGlyph.Glyph.get -> ushort +SharpEquations.Rendering.MathGlyph.MathGlyph() -> void +SharpEquations.Rendering.MathGlyph.Size.get -> double +SharpEquations.Rendering.MathGlyph.X.get -> double +SharpEquations.Rendering.MathGlyph.Y.get -> double +SharpEquations.Rendering.MathLayout +SharpEquations.Rendering.MathLayout.Baseline.get -> double +SharpEquations.Rendering.MathLayout.GetCaretRect(SharpEquations.Editing.MathPosition! position) -> SharpEquations.Rendering.MathRect? +SharpEquations.Rendering.MathLayout.GetCaretX(SharpEquations.Editing.MathPosition! position) -> double? +SharpEquations.Rendering.MathLayout.GetNearestPosition(System.Collections.Generic.IReadOnlyList! rowPath, double x) -> SharpEquations.Editing.MathPosition? +SharpEquations.Rendering.MathLayout.GetSelectionRects(SharpEquations.Editing.MathSelection! selection) -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Rendering.MathLayout.Glyphs.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Rendering.MathLayout.Height.get -> double +SharpEquations.Rendering.MathLayout.HitTest(double x, double y) -> SharpEquations.Editing.MathPosition? +SharpEquations.Rendering.MathLayout.Shapes.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Rendering.MathLayout.Width.get -> double +SharpEquations.Rendering.MathLayoutEngine +SharpEquations.Rendering.MathLayoutOptions +SharpEquations.Rendering.MathLayoutOptions.Display.get -> bool +SharpEquations.Rendering.MathLayoutOptions.Display.set -> void +SharpEquations.Rendering.MathLayoutOptions.FontSize.get -> double +SharpEquations.Rendering.MathLayoutOptions.FontSize.set -> void +SharpEquations.Rendering.MathLayoutOptions.LineWidth.get -> double? +SharpEquations.Rendering.MathLayoutOptions.LineWidth.set -> void +SharpEquations.Rendering.MathLayoutOptions.MathLayoutOptions() -> void +SharpEquations.Rendering.MathLayoutOptions.ShowPlaceholders.get -> bool +SharpEquations.Rendering.MathLayoutOptions.ShowPlaceholders.set -> void +SharpEquations.Rendering.MathRect +SharpEquations.Rendering.MathRect.Bottom.get -> double +SharpEquations.Rendering.MathRect.Height.get -> double +SharpEquations.Rendering.MathRect.MathRect() -> void +SharpEquations.Rendering.MathRect.MathRect(double x, double y, double width, double height) -> void +SharpEquations.Rendering.MathRect.Right.get -> double +SharpEquations.Rendering.MathRect.Width.get -> double +SharpEquations.Rendering.MathRect.X.get -> double +SharpEquations.Rendering.MathRect.Y.get -> double +SharpEquations.Rendering.MathShape +SharpEquations.Rendering.MathShape.Color.get -> string? +SharpEquations.Rendering.MathShape.Height.get -> double +SharpEquations.Rendering.MathShape.Kind.get -> SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShape.MathShape() -> void +SharpEquations.Rendering.MathShape.Role.get -> SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.MathShape.Thickness.get -> double +SharpEquations.Rendering.MathShape.Width.get -> double +SharpEquations.Rendering.MathShape.X.get -> double +SharpEquations.Rendering.MathShape.X2.get -> double +SharpEquations.Rendering.MathShape.Y.get -> double +SharpEquations.Rendering.MathShape.Y2.get -> double +SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShapeKind.Ellipse = 3 -> SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShapeKind.Fill = 0 -> SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShapeKind.Line = 2 -> SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShapeKind.Outline = 1 -> SharpEquations.Rendering.MathShapeKind +SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.MathShapeRole.Background = 1 -> SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.MathShapeRole.Error = 3 -> SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.MathShapeRole.Ink = 0 -> SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.MathShapeRole.Placeholder = 2 -> SharpEquations.Rendering.MathShapeRole +SharpEquations.Rendering.OpenType.FontFile +SharpEquations.Rendering.OpenType.FontFile.Table(string! tag) -> byte[]? +SharpEquations.Rendering.OpenType.GlyphConstruction +SharpEquations.Rendering.OpenType.GlyphConstruction.Assembly.get -> System.Collections.Generic.IReadOnlyList? +SharpEquations.Rendering.OpenType.GlyphConstruction.AssemblyItalicCorrection.get -> int +SharpEquations.Rendering.OpenType.GlyphConstruction.Variants.get -> System.Collections.Generic.IReadOnlyList<(ushort Glyph, int Advance)>! +SharpEquations.Rendering.OpenType.GlyphOutlines +SharpEquations.Rendering.OpenType.GlyphOutlines.Bounds(ushort glyph) -> SharpEquations.Rendering.GlyphBounds +SharpEquations.Rendering.OpenType.GlyphPart +SharpEquations.Rendering.OpenType.GlyphPart.EndConnector.get -> int +SharpEquations.Rendering.OpenType.GlyphPart.FullAdvance.get -> int +SharpEquations.Rendering.OpenType.GlyphPart.Glyph.get -> ushort +SharpEquations.Rendering.OpenType.GlyphPart.GlyphPart() -> void +SharpEquations.Rendering.OpenType.GlyphPart.IsExtender.get -> bool +SharpEquations.Rendering.OpenType.GlyphPart.StartConnector.get -> int +SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.AccentBaseHeight = 6 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.AxisHeight = 5 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.DelimitedSubFormulaMinHeight = 2 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.DisplayOperatorMinHeight = 3 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FlattenedAccentBaseHeight = 7 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionDenomDisplayStyleGapMin = 40 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionDenominatorDisplayStyleShiftDown = 35 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionDenominatorGapMin = 39 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionDenominatorShiftDown = 34 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionNumDisplayStyleGapMin = 37 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionNumeratorDisplayStyleShiftUp = 33 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionNumeratorGapMin = 36 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionNumeratorShiftUp = 32 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.FractionRuleThickness = 38 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.LowerLimitBaselineDropMin = 21 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.LowerLimitGapMin = 20 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.MathLeading = 4 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.OverbarExtraAscender = 45 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.OverbarRuleThickness = 44 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.OverbarVerticalGap = 43 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalDegreeBottomRaisePercent = 55 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalDisplayStyleVerticalGap = 50 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalExtraAscender = 52 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalKernAfterDegree = 54 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalKernBeforeDegree = 53 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalRuleThickness = 51 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.RadicalVerticalGap = 49 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.ScriptPercentScaleDown = 0 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.ScriptScriptPercentScaleDown = 1 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SkewedFractionHorizontalGap = 41 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SkewedFractionVerticalGap = 42 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SpaceAfterScript = 17 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackBottomDisplayStyleShiftDown = 25 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackBottomShiftDown = 24 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackDisplayStyleGapMin = 27 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackGapMin = 26 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackTopDisplayStyleShiftUp = 23 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StackTopShiftUp = 22 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StretchStackBottomShiftDown = 29 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StretchStackGapAboveMin = 30 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StretchStackGapBelowMin = 31 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.StretchStackTopShiftUp = 28 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SubscriptBaselineDropMin = 10 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SubscriptShiftDown = 8 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SubscriptTopMax = 9 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SubSuperscriptGapMin = 15 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBaselineDropMax = 14 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBottomMaxWithSubscript = 16 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBottomMin = 13 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SuperscriptShiftUp = 11 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SuperscriptShiftUpCramped = 12 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.UnderbarExtraDescender = 48 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.UnderbarRuleThickness = 47 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.UnderbarVerticalGap = 46 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.UpperLimitBaselineRiseMin = 19 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.UpperLimitGapMin = 18 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathTable +SharpEquations.Rendering.OpenType.MathTable.Horizontal(ushort glyph) -> SharpEquations.Rendering.OpenType.GlyphConstruction? +SharpEquations.Rendering.OpenType.MathTable.IsExtendedShape(ushort glyph) -> bool +SharpEquations.Rendering.OpenType.MathTable.ItalicCorrection(ushort glyph) -> int +SharpEquations.Rendering.OpenType.MathTable.MinConnectorOverlap.get -> int +SharpEquations.Rendering.OpenType.MathTable.this[SharpEquations.Rendering.OpenType.MathConstant constant].get -> int +SharpEquations.Rendering.OpenType.MathTable.TopAccentAttachment(ushort glyph) -> int? +SharpEquations.Rendering.OpenType.MathTable.Vertical(ushort glyph) -> SharpEquations.Rendering.OpenType.GlyphConstruction? +static SharpEquations.Rendering.MathLayoutEngine.Layout(SharpEquations.Editing.MathNode! root, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! +static SharpEquations.Rendering.MathLayoutEngine.Layout(SharpEquations.Equation! equation, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! +static SharpEquations.Rendering.MathLayoutEngine.Layout(System.Xml.Linq.XElement! math, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! +static SharpEquations.Rendering.OpenType.FontFile.Parse(byte[]! data, int glyphCount = -1) -> SharpEquations.Rendering.OpenType.FontFile! +static SharpEquations.Rendering.OpenType.GlyphOutlines.Create(System.Func! table) -> SharpEquations.Rendering.OpenType.GlyphOutlines? +static SharpEquations.Rendering.OpenType.MathTable.Parse(byte[]! data) -> SharpEquations.Rendering.OpenType.MathTable! +static SharpEquations.Rendering.OpenType.MathTable.Parse(System.ReadOnlySpan data) -> SharpEquations.Rendering.OpenType.MathTable! diff --git a/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj b/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj index eacb05e..7a36b84 100644 --- a/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj +++ b/src/SharpEquations.Rendering/SharpEquations.Rendering.csproj @@ -16,12 +16,17 @@ + + + + + diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/DemoTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/DemoTests.cs new file mode 100644 index 0000000..634fe01 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Controls/DemoTests.cs @@ -0,0 +1,51 @@ +#if NET10_0_OR_GREATER +namespace SharpEquations.Avalonia.Tests.Controls +{ + using System; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + using global::Avalonia.Controls; + using global::Avalonia.Headless; + using global::Avalonia.VisualTree; + using SharpEquations; + using SharpEquations.Avalonia.Demo; + + /// The demo window opens, edits and shows the equation in every format (a picture with SHARPEQUATIONS_SCREENSHOTS). + [NotInParallel("headless")] + public class DemoTests + { + [Test] + public async Task DemoWindowWorks() + { + var (equation, outputs) = await Headless.Run(() => + { + var window = new MainWindow(); + window.Show(); + var editor = window.GetVisualDescendants().OfType().Single(); + editor.Equation = null; + editor.Focus(); + window.KeyTextInput("x="); + editor.Editor.InsertStructure("mfrac"); + window.KeyTextInput("-b±\\sqrt(b^2-4ac) "); + editor.Editor.MoveDown(); + window.KeyTextInput("2a "); + editor.EquationNumber = "(1)"; + window.UpdateLayout(); + var frame = window.CaptureRenderedFrame(); + if (Environment.GetEnvironmentVariable("SHARPEQUATIONS_SCREENSHOTS") is { } directory && frame != null) + { + Directory.CreateDirectory(directory); + frame.Save(Path.Combine(directory, "demo.png"), global::Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); + } + var texts = window.GetVisualDescendants().OfType().Count(t => t.IsReadOnly && !string.IsNullOrEmpty(t.Text)); + var result = editor.Equation; + window.Close(); + return (result, texts); + }); + await Assert.That(equation).IsEqualTo(Equation.Parse("x=(-b±√(b^2-4ac))/2a#(1)", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + await Assert.That(outputs).IsGreaterThanOrEqualTo(1); + } + } +} +#endif diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs index bc34acc..8e1626a 100644 --- a/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Controls/MathEditBoxTests.cs @@ -5,6 +5,7 @@ namespace SharpEquations.Avalonia.Tests.Controls using global::Avalonia.Controls; using global::Avalonia.Headless; using global::Avalonia.Input; + using global::Avalonia.Input.Platform; using SharpEquations; /// The control in a window of the headless session: typing, keys, MathML in and out. @@ -107,6 +108,62 @@ public async Task LinearViewMode() await Assert.That(back).IsEqualTo(Display("a/b")); } + [Test] + public async Task TabMovesThroughTheCells() + { + var equation = await Headless.Run(() => WithEditor((window, editor) => + { + editor.Editor.InsertMatrix(1, 2); + window.KeyTextInput("a"); + window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + window.KeyTextInput("b"); + return editor.Equation; + })); + await Assert.That(equation).IsEqualTo(Display("■(a&b)")); + } + + [Test] + public async Task AccessibleValueIsUnicodeMath() + { + var value = await Headless.Run(() => WithEditor((window, editor) => + { + window.KeyTextInput("a/b "); + var peer = (MathEditBoxAutomationPeer)global::Avalonia.Automation.Peers.ControlAutomationPeer.CreatePeerForElement(editor); + var before = peer.Value; + peer.SetValue("x^2"); + return (before, editor.Equation); + })); + await Assert.That(Equation.Parse(value.before, EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("a/b", EquationFormat.UnicodeMath)); + await Assert.That(value.Equation).IsEqualTo(Display("x^2")); + } + + [Test] + public async Task CopyAndPaste() + { + var result = await Headless.RunAsync(async () => + { + var editor = new MathEditBox { FontSize = 20, CopyTextFormat = EquationFormat.LaTeX }; + var window = new Window { Content = editor, Width = 600, Height = 200 }; + window.Show(); + editor.Focus(); + string? copied = null; + editor.Copying += (_, e) => copied = e.Equation.ToString(EquationFormat.UnicodeMath); + window.KeyTextInput("a/b "); + editor.Editor.SelectAll(); + await editor.CopyAsync(); + var text = await window.Clipboard!.TryGetTextAsync(); + editor.Editor.MoveEnd(); + window.KeyTextInput("+"); + await editor.PasteAsync(); + var equation = editor.Equation; + window.Close(); + return (copied, text, equation); + }); + await Assert.That(result.copied).IsNotNull(); + await Assert.That(result.text).StartsWith("\\frac"); + await Assert.That(result.equation).IsEqualTo(Display("a/b+a/b")); + } + [Test] public async Task ClickPlacesTheCaret() { diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs new file mode 100644 index 0000000..a6617fc --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs @@ -0,0 +1,170 @@ +namespace SharpEquations.Avalonia.Tests.Editing +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + + /// The commands of an application: structures, tables, copy in any format, undo and redo. + public class CommandTests + { + private static Equation U(string unicodeMath) => Equation.Parse(unicodeMath, EquationFormat.UnicodeMath); + + [Test] + public async Task StructureTakesTheSelection() + { + var editor = new MathEditor(U("x+a")); + editor.SetCaret(new MathPosition(new[] { 0 }, 2)); + editor.SetCaret(new MathPosition(new[] { 0 }, 3), extend: true); + editor.InsertStructure("mfrac"); + await Assert.That(editor.Caret.Path).IsEquivalentTo(new[] { 0, 2, 1 }); + editor.Insert("b "); + await Assert.That(editor.ToEquation()).IsEqualTo(U("x+a/b")); + } + + [Test] + public async Task MatrixAndCells() + { + var editor = new MathEditor(); + editor.InsertMatrix(2, 2, "(", ")"); + var cells = new List(); + foreach (var text in new[] { "a", "b", "c", "d" }) + { + editor.Insert(text); + cells.Add(string.Join("/", editor.Caret.Path.Take(editor.Caret.Path.Count - 1))); + if (text != "d") + await Assert.That(editor.MoveToNextCell()).IsTrue(); + } + await Assert.That(editor.MoveToNextCell()).IsFalse(); + await Assert.That(cells.Distinct().Count()).IsEqualTo(4); + await Assert.That(editor.ToEquation()).IsEqualTo(U("(■(a&b@c&d))")); + await Assert.That(editor.MoveToNextCell(backward: true)).IsTrue(); + } + + [Test] + public async Task FencesAroundTheSelection() + { + var editor = new MathEditor(U("a+b")); + editor.SelectAll(); + editor.InsertFences("[", "]"); + await Assert.That(editor.ToEquation()).IsEqualTo(U("[a+b]")); + } + + [Test] + public async Task SelectedTextInEveryFormat() + { + var editor = new MathEditor(U("x+a/b")); + await Assert.That(editor.GetSelectedText(EquationFormat.MathML)).IsNull(); + editor.SelectAll(); + await Assert.That(U(editor.GetSelectedText(EquationFormat.UnicodeMath)!)).IsEqualTo(U("x+a/b")); + await Assert.That(editor.GetSelectedText(EquationFormat.LaTeX, new EquationOptions { LaTeXFlavor = LaTeXFlavor.Standard })).IsEqualTo("x+\\frac{a}{b}"); + await Assert.That(editor.GetSelectedText(EquationFormat.MathML)).Contains(""); + await Assert.That(editor.GetSelectedText(EquationFormat.Omml)).Contains("m:f"); + } + + /// Everything typed and deleted undoes back to the empty equation, and redoes to the end. + [Test] + public async Task UndoAndRedoEverything() + { + var editor = new MathEditor(); + var states = new List { editor.ToEquation() }; + void Record() => states.Add(editor.ToEquation()); + + editor.Insert("a/b "); + Record(); + editor.Insert("+\\sqrt(x) "); + Record(); + editor.Backspace(); // selects the radical + editor.Backspace(); // deletes it + Record(); + editor.MoveHome(); + editor.MoveHome(); + editor.Delete(); // selects the fraction + editor.Delete(); + Record(); + await Assert.That(states[^1]).IsEqualTo(U("+")); + + var undone = new List(); + while (editor.CanUndo) + { + editor.Undo(); + undone.Add(editor.ToEquation()); + } + await Assert.That(editor.IsEmpty).IsTrue(); + foreach (var state in states.Take(states.Count - 1)) + await Assert.That(undone).Contains(state); + + while (editor.CanRedo) + editor.Redo(); + await Assert.That(editor.ToEquation()).IsEqualTo(U("+")); + } + + [Test] + public async Task TypingIsOneUndo() + { + var editor = new MathEditor(); + editor.Insert("x"); + editor.Insert("y"); + editor.Insert("z"); + editor.Undo(); + await Assert.That(editor.IsEmpty).IsTrue(); + } + + [Test] + public async Task UndoLimitForgetsTheOldest() + { + var editor = new MathEditor { UndoLimit = 3 }; + for (var i = 0; i < 10; i++) + editor.Insert(i + " "); + var count = 0; + while (editor.CanUndo) + { + editor.Undo(); + count++; + } + await Assert.That(count).IsEqualTo(3); + } + + [Test] + public async Task DeleteInsideARunAndAcrossTokens() + { + var editor = new MathEditor(); + editor.Insert("abc+def"); + editor.MoveLeft(); + editor.MoveLeft(); + editor.Backspace(); + editor.Delete(); + editor.Insert(" "); + await Assert.That(editor.ToEquation()).IsEqualTo(U("abc+f")); + } + + [Test] + public async Task EquationNumber() + { + var editor = new MathEditor(Equation.Parse("E=mc^2", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + await Assert.That(editor.EquationNumber).IsNull(); + editor.SetEquationNumber("(1)"); + await Assert.That(editor.EquationNumber).IsEqualTo("(1)"); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("E=mc^2#(1)", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + await Assert.That(editor.GetText(EquationFormat.LaTeX)).IsEqualTo(Equation.Parse("E=mc^2#(1)", EquationFormat.UnicodeMath, new EquationOptions { Display = true }).ToString(EquationFormat.LaTeX, new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word })); + editor.Insert("+1 "); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("E=mc^2+1#(1)", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + editor.SetEquationNumber(null); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("E=mc^2+1", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + editor.Undo(); + await Assert.That(editor.EquationNumber).IsEqualTo("(1)"); + } + + [Test] + public async Task EquationNumberTyped() + { + var editor = new MathEditor { IsDisplay = true }; + editor.Insert("E=mc^2#(1) "); + await Assert.That(editor.EquationNumber).IsEqualTo("(1)"); + var latex = new MathEditor { IsDisplay = true, InputFormat = EquationFormat.LaTeX }; + latex.Insert("E=mc^2\\tag{2} "); + await Assert.That(latex.EquationNumber).IsEqualTo("(2)"); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/RepositoryCorporaTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/RepositoryCorporaTests.cs new file mode 100644 index 0000000..8082cda --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Editing/RepositoryCorporaTests.cs @@ -0,0 +1,178 @@ +namespace SharpEquations.Avalonia.Tests.Editing +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json; + using System.Threading.Tasks; + using System.Xml.Linq; + using SharpEquations; + using SharpEquations.Editing; + using SharpEquations.Rendering; + using SharpEquations.Tests; + + /// + /// The editor and the layout on the other corpora of the repository: the LaTeX of latex2mathml, the OMML of Word + /// documents, the MathML of mml2tex and the MTEF of the fixtures. + /// + [NotInParallel("headless")] + public class RepositoryCorporaTests + { + private static readonly EquationOptions WordLaTeX = new EquationOptions { LaTeXFlavor = LaTeXFlavor.Word }; + + private static List<(string Name, string LaTeX, Equation Equation)>? latex; + private static List<(string Name, Equation Equation)>? omml; + + private static List<(string Name, string LaTeX, Equation Equation)> LaTeX() => latex ??= Load("latex2mathml-converter.json") + .Concat(Load("latex2mathml-macros.json")) + .ToList(); + + private static IEnumerable<(string, string, Equation)> Load(string file) + { + using var json = JsonDocument.Parse(File.ReadAllText(TestData.Path("latex", file))); + foreach (var e in json.RootElement.EnumerateArray()) + { + var source = e.GetProperty("latex").GetString()!; + var display = e.TryGetProperty("display", out var d) && d.GetBoolean(); + if (Equation.TryParse(source, EquationFormat.LaTeX, new EquationOptions { Display = display }, out var equation) && equation!.ToMathML().HasElements) + yield return (e.GetProperty("id").GetString()!, source, equation); + } + } + + private static List<(string Name, Equation Equation)> Omml() => omml ??= Directory.GetFiles(TestData.Path("omml"), "*.omml") + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => (Path.GetFileName(f), Equation.Parse(File.ReadAllText(f), EquationFormat.Omml))) + .Where(e => e.Item2.ToMathML().HasElements) + .ToList(); + + /// The MathML of two equations is the same but for the whitespace between elements. + private static bool Same(Equation? a, Equation? b) => a != null && b != null && Canonical(a) == Canonical(b); + + private static string Canonical(Equation equation) + { + var math = equation.ToMathML(); + foreach (var text in math.DescendantNodes().OfType().Where(t => t.Parent!.HasElements && string.IsNullOrWhiteSpace(t.Value)).ToList()) + text.Remove(); + return math.ToString(SaveOptions.DisableFormatting); + } + + [Test] + public async Task CorporaAreLarge() + { + await Assert.That(LaTeX()).Count().IsGreaterThan(300); + await Assert.That(Omml()).Count().IsGreaterThan(180); + } + + [Test] + public async Task LoadedEquationIsUnchanged() + { + var failures = LaTeX().Select(e => (e.Name, e.Equation)).Concat(Omml()) + .Where(e => !Same(new MathEditor(e.Equation).ToEquation(), e.Equation)) + .Select(e => e.Name) + .ToList(); + await Assert.That(failures).IsEmpty(); + } + + /// + /// Linear and back, in UnicodeMath and in the LaTeX of Word: what the format cannot hold is lost the first time + /// only, so the equation is that of its linear text read again. + /// + [Test] + [Arguments(EquationFormat.UnicodeMath)] + [Arguments(EquationFormat.LaTeX)] + public async Task BuiltDownAndUpIsTheLinearText(EquationFormat format) + { + var failures = new List(); + foreach (var (name, equation) in LaTeX().Select(e => (e.Name, e.Equation)).Concat(Omml())) + { + var text = format == EquationFormat.LaTeX ? equation.ToString(format, WordLaTeX) : equation.ToString(format); + if (!Equation.TryParse(text, format, new EquationOptions { Display = equation.IsDisplay }, out var once)) + continue; + var editor = new MathEditor(equation) { InputFormat = format }; + editor.SetLinear(true); + editor.SetLinear(false); + if (!Same(editor.ToEquation(), once)) + failures.Add(name + ": " + text); + } + // \lvert alone, which UnicodeMath writes as a bar that reads back as text in a row of its own + await Assert.That(failures.Count).IsLessThanOrEqualTo(1).Because(string.Join("\n", failures)); + } + + /// + /// The LaTeX of latex2mathml typed one character at a time, building up at each space and operator: for 90% of it, + /// the equation of the whole text. The others are commands that take the text after them as their argument + /// (\over, \rm, \mathcal {...} with a space) or are not equations (comments, spaces alone). + /// + [Test] + public async Task TypedLaTeXIsMostlyTheEquationOfTheText() + { + var same = 0; + var errors = new List(); + foreach (var (name, source, equation) in LaTeX()) + { + var editor = new MathEditor { InputFormat = EquationFormat.LaTeX, IsDisplay = equation.IsDisplay }; + try + { + editor.Insert(source); + if (Same(editor.ToEquation(), equation)) + same++; + } + catch (Exception e) + { + errors.Add(name + ": " + e.Message); + } + } + await Assert.That(errors).IsEmpty(); + await Assert.That(same).IsGreaterThanOrEqualTo(LaTeX().Count * 88 / 100); + } + + [Test] + public async Task CorporaLayOut() + { + var equations = LaTeX().Select(e => (e.Name, e.Equation)).Concat(Omml()).ToList(); + foreach (var file in Directory.GetFiles(TestData.Path("mtef"), "*.bin", SearchOption.AllDirectories).Concat(Directory.GetFiles(TestData.Path("mtef"), "*.eqn", SearchOption.AllDirectories))) + { + if (!file.Contains("invalid", StringComparison.Ordinal)) + { + try + { + equations.Add((Path.GetFileName(file), Equation.Parse(File.ReadAllBytes(file).AsSpan(), EquationFormat.Mtef))); + } + catch (FormatException) + { + } + } + } + using (var json = JsonDocument.Parse(File.ReadAllText(TestData.Path("mathml", "mml2tex.json")))) + { + foreach (var e in json.RootElement.EnumerateArray()) + equations.Add((e.GetProperty("label").GetString()!, Equation.Parse(e.GetProperty("mathml").GetString()!, EquationFormat.MathML))); + } + + var failures = await Headless.Run(() => + { + var failed = new List(); + foreach (var (name, equation) in equations) + { + try + { + var editor = new MathEditor(equation); + var layout = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, new MathLayoutOptions { FontSize = 20, ShowPlaceholders = true, LineWidth = 800 }); + // \vphantom has no width and \smash no height, but nothing has a negative or infinite size + if (!(layout.Width >= 0) || !(layout.Height >= 0) || layout.Width + layout.Height <= 0 || double.IsInfinity(layout.Width + layout.Height) + || layout.GetCaretRect(editor.Caret) == null) + failed.Add(name + ": " + layout.Width + "×" + layout.Height); + } + catch (Exception e) + { + failed.Add(name + ": " + e.GetType().Name + " " + e.Message); + } + } + return failed; + }); + await Assert.That(equations.Count).IsGreaterThan(500); + await Assert.That(failures).IsEmpty(); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Headless.cs b/tests/SharpEquations.Avalonia.Tests/Headless.cs index 579f410..a8b67d6 100644 --- a/tests/SharpEquations.Avalonia.Tests/Headless.cs +++ b/tests/SharpEquations.Avalonia.Tests/Headless.cs @@ -30,5 +30,7 @@ internal static class Headless public static Task Run(Action action) => Session.Value.Dispatch(action, CancellationToken.None); public static Task Run(Func function) => Session.Value.Dispatch(function, CancellationToken.None); + + public static Task RunAsync(Func> function) => Session.Value.Dispatch(function, CancellationToken.None); } } diff --git a/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj index f238980..6589c67 100644 --- a/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj +++ b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj @@ -19,6 +19,8 @@ + + From 1b6d412e8b425407f7c56f9c9e82668d19ddedde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:10:30 +0000 Subject: [PATCH 27/45] [no-new-version] Commands in the public API, completion, lines, tables, script variants, floating bar - Core: EquationCommand and EquationCommands (public, unshipped): the control words of UnicodeMath and the commands of LaTeX with their symbols, checked against the parsers by PublicApi/EquationCommandsTests. - Editor: completion of commands (CommandPrefix, GetCompletions, Complete); lines (InsertLineBreak, Shift+Enter: equation arrays aligned at &, joined by Backspace and Delete, built up as lines of an array); rows and columns of tables (InsertTableRow, InsertTableColumn, DeleteTableRow, DeleteTableColumn). - Rendering: alignment marks (malignmark) in tables, equation arrays in display style; the ssty variants of the GSUB table in scripts (ScriptStyles); cmap and advances read from the font file. - Avalonia: the floating bar (MathQuickBar) with completions, structures for the selection, table commands and quick symbols; the glyph typeface asked for at each drawing and the embedded font registered with the current font manager, so that no released typeface is drawn with. - Demo and docs updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- AGENTS.md | 9 +- README.md | 6 + docs/EDITOR.md | 33 +- .../MainWindow.cs | 16 +- .../AvaloniaMathFont.cs | 118 ++++---- src/SharpEquations.Avalonia/MathDrawing.cs | 7 +- src/SharpEquations.Avalonia/MathEditBox.cs | 151 +++++++++- src/SharpEquations.Avalonia/MathFonts.cs | 31 +- src/SharpEquations.Avalonia/MathQuickBar.cs | 160 ++++++++++ .../PublicAPI.Unshipped.txt | 68 +++-- src/SharpEquations.Editing/BuildUp.cs | 27 +- src/SharpEquations.Editing/MathEditor.cs | 285 +++++++++++++++++- .../PublicAPI.Unshipped.txt | 20 +- src/SharpEquations.Rendering/IMathFont.cs | 6 + src/SharpEquations.Rendering/Layout/Engine.cs | 77 ++++- .../OpenType/FontFile.cs | 118 ++++++++ .../OpenType/ScriptStyles.cs | 152 ++++++++++ .../PublicAPI.Unshipped.txt | 15 +- .../Internal/LaTeX/LaTeXSymbols.cs | 3 + src/SharpEquations/Public/EquationCommands.cs | 135 +++++++++ src/SharpEquations/PublicAPI.Unshipped.txt | 9 + .../Controls/QuickBarTests.cs | 142 +++++++++ .../Controls/ScreenshotTests.cs | 2 + .../Editing/CommandTests.cs | 91 ++++++ .../SharpEquations.Avalonia.Tests/Headless.cs | 2 +- .../Rendering/MathTableTests.cs | 13 + .../PublicApi/EquationCommandsTests.cs | 67 ++++ 27 files changed, 1636 insertions(+), 127 deletions(-) create mode 100644 src/SharpEquations.Avalonia/MathQuickBar.cs create mode 100644 src/SharpEquations.Rendering/OpenType/ScriptStyles.cs create mode 100644 src/SharpEquations/Public/EquationCommands.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs create mode 100644 tests/SharpEquations.Tests/PublicApi/EquationCommandsTests.cs diff --git a/AGENTS.md b/AGENTS.md index 26ad7ec..3ac781a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,9 +92,12 @@ net10.0 with no package, as the core). - `Rendering`: `Layout/Engine.cs` lays out after MathML Core with the constants of the MATH table; the ink boxes of the glyphs come from the outlines of the font (`OpenType/GlyphOutlines.cs`: `glyf`, or the CFF charstrings), never from the rasterizer, and the tables from a copy of the font file (`OpenType/FontFile.cs`). -- `Avalonia`: `AvaloniaMathFont` reads the font file once with `TryGetStream` (the memory of `TryGetTable` does not stay - valid); the tests that use the headless session are `[NotInParallel("headless")]`, since showing a window or typing - runs the jobs of the dispatcher. +- `Avalonia`: `AvaloniaMathFont` reads the font file once with `TryGetStream` and asks Avalonia for its glyph typeface + only to draw, each time (`MathFonts.Register` registers the embedded font again with a new font manager: a headless + test session gives each test one, and a glyph typeface of the old one is released); the tests that use the headless + session are `[NotInParallel("headless")]`, since showing a window or typing runs the jobs of the dispatcher. +- The core's `EquationCommands` (public) lists the control words of UnicodeMath and the commands of LaTeX with their + symbols, for the completion of the editor; `PublicApi/EquationCommandsTests` checks that the parsers read them. ## Building and Testing diff --git a/README.md b/README.md index 299ac6e..0e950e2 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,14 @@ editor.ViewMode = MathViewMode.Linear; // the equation as one line of UnicodeM editor.EquationNumber = "(1)"; // at the right of the line, as #(1) or \tag{1} string latex = editor.GetSelectedText(EquationFormat.LaTeX)!; editor.CopyTextFormat = EquationFormat.MathML; // Ctrl+C pastes as an equation into Word +editor.Editor.InsertLineBreak(); // Shift+Enter: lines aligned at & +editor.Editor.InsertTableRow(); // and columns, in the matrix of the caret ``` +Typing `\` and the start of a command opens a floating bar at the caret with its completions +(`EquationCommands.Complete("al", EquationFormat.UnicodeMath)` gives them from the core); the bar also offers structures +for the selection, the commands of a table and quick symbols. + The packages are `SharpEquations.Avalonia` (the controls), `SharpEquations.Rendering` and `SharpEquations.Editing`, with the version of `SharpEquations`. diff --git a/docs/EDITOR.md b/docs/EDITOR.md index a7f5f89..1240168 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -6,7 +6,7 @@ as it is typed, and the content is read and written as MathML (`GetMath`/`SetMat SharpEquations is the core under it: it reads what is typed, gives the MathML, and writes the linear form back when a part of the equation is built down. The editor edits one equation (math only), not math zones in a text document. -Phases 1 to 3 of the plan below are done, and phase 4 in part; the three projects are published as NuGet packages with +Phases 1 to 5 of the plan below are done; the three projects are published as NuGet packages with the version of the core. ## What the user sees @@ -45,6 +45,22 @@ The behaviour is the one of the equations of Word and of `RichEditBox` in math m - **Equation numbers**: `#(1)` at the end in UnicodeMath, `\tag{1}` in LaTeX, or `EquationNumber = "(1)"`: the number goes to the right edge of the line (or the left, with `side="left"`), with the equation in the middle of what is left, as in Word; the layout knows the line from `MathLayoutOptions.LineWidth`, which the controls set to their width. +- **Lines**: Shift+Enter breaks the line at the caret, as in Word: the equation becomes an equation array (the + `█(...@...)` of UnicodeMath, `aligned` of LaTeX: a table of one column whose lines start with `maligngroup`), or gets a + line more; the lines line up at their first `&` (`malignmark`), and keep the display style. Backspace at the start of + a line and Delete at its end join it to its neighbour; Up and Down move between lines. A line is built up as the line + of an array, where `&` is an alignment mark. +- **Tables and matrices**: `InsertTableRow`, `InsertTableColumn` (above or below, left or right), `DeleteTableRow`, + `DeleteTableColumn` on the table of the caret (the last row or column deleted takes the table); Tab and Shift+Tab go + through the cells. +- **Completion**: after `\` and the start of a command, `CommandPrefix`, `GetCompletions` and `Complete` complete it from + the commands of the core (`EquationCommands`: the control words of UnicodeMath, the commands of LaTeX, each with its + symbol); a command with a symbol becomes the symbol, as Word's math autocorrect does, a LaTeX structure its text with + the caret in its first argument (`\frac{|}{}`). +- **Floating bar** (`MathQuickBar`, `IsQuickBarEnabled`): under the equation, at the caret, it offers the completions of + the command being typed (Up and Down choose, Tab or Enter take, Esc closes), the structures that take the selection + (fraction, root, scripts, brackets, bar, box, build down), the commands of the table of the caret, and quick symbols + (`QuickSymbols`) in an empty slot. Its buttons do not take the focus. - **Structures from code**: `InsertStructure("mfrac")`, `InsertFences`, `InsertMatrix(2, 2, "(", ")")`, `InsertTemplate(node)`, with the selection in the first slot and the caret in the first empty one, as the buttons of the equation tools of Word; Tab and Shift+Tab move through the cells of a table. @@ -195,8 +211,12 @@ public class MathView : Control // read-only: Equati - **Fonts**: `MathFonts.Default` is Cambria Math on Windows, where it is a font of the system, and Libertinus Math (SIL Open Font License, embedded as an Avalonia resource) elsewhere or when Cambria Math is missing; `MathFontFamily` takes any family with a MATH table. Characters the font lacks come from a fallback font of the system. - `AvaloniaMathFont` reads the font file once (`TryGetStream`): the memory of `IFontMemory.TryGetTable` does not stay - valid after a garbage collection. + `AvaloniaMathFont` reads the font file once (`TryGetStream`) and takes from it the glyphs, advances, ink boxes, MATH + and GSUB tables; it asks Avalonia for its glyph typeface only to draw, each time, after registering the embedded font + with the current font manager: a glyph typeface held for long can be released with its font manager (a headless + test session gives each test a new one), and its memory given to another font. +- **Script variants**: in scripts the glyphs are the `ssty` variants of the GSUB table of the font (`ScriptStyles`), drawn + for the sizes of scripts and of scripts of scripts. - **Text input** comes from `TextInput`, so that IMEs and dead keys work, and a `TextInputMethodClient` gives the IME the rectangle of the caret. - The `Equation` property is set on every change of the content, with the runs built up where they parse. @@ -209,12 +229,13 @@ public class MathView : Control // read-only: Equati | 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, selection, build up and down, undo | done: 100% of the corpus loads and round-trips through linear text, 93% typed a character at a time | | 3 | `MathEditBox`: input, caret, hit testing, selection, linear view, clipboard, demo | done: headless tests of typing, keys, mouse, MathML in and out | | 4 | Accessibility (`MathEditBoxAutomationPeer`: an edit control whose value is the equation in UnicodeMath), Tab in tables, equation numbers, copy in any format, structures from code, NuGet packages | done | -| 5 | Autocomplete of control words (with the public list of the core), new rows and columns of tables, the `ssty` variants of scripts, line breaks of long equations | to do | +| 5 | The commands of the core in its public API (`EquationCommands`) and their completion, the floating bar, lines (equation arrays aligned at `&`), rows and columns of tables, the `ssty` variants of scripts | done | +| 6 | Automatic line breaks of long equations (at the relations and the operators, as Word), templates of the floating bar chosen by the application | to do | ## Open points -- **Public API of the core** (for its next major version): the list of the control words of UnicodeMath and of the - LaTeX commands with their symbols (`ControlWords`, `LaTeXSymbols`, internal today), for the autocomplete of phase 4. +- **Public API of the core**: `EquationCommand` and `EquationCommands` are new public API of `SharpEquations` (in + `PublicAPI.Unshipped.txt`), which a minor version brings. - **Packages**: the CI packs the three projects with the core, with its version, and publishes them with it; their public API is tracked in `PublicAPI.Shipped.txt`/`PublicAPI.Unshipped.txt`, as that of the core. - **Reference images**: the rendering is tested by the geometry of the layout, not by pictures; the tests write diff --git a/samples/SharpEquations.Avalonia.Demo/MainWindow.cs b/samples/SharpEquations.Avalonia.Demo/MainWindow.cs index f8c11fc..ccac7f3 100644 --- a/samples/SharpEquations.Avalonia.Demo/MainWindow.cs +++ b/samples/SharpEquations.Avalonia.Demo/MainWindow.cs @@ -30,6 +30,7 @@ private static readonly (string Name, string UnicodeMath)[] Examples = ("Limit", "lim┬(x→0) sin⁡x/x=1"), ("Maxwell", "∇×𝐁=μ_0 (𝐉+ε_0 ∂𝐄/∂t)"), ("Numbered", "E=mc^2#(1)"), + ("Aligned lines", "█(f(x)&=(x+1)^2@&=x^2+2x+1)"), }; private readonly MathEditBox editor; @@ -76,7 +77,8 @@ public MainWindow() { Text = "Type UnicodeMath (a/b, x^2, \\sqrt(x), \\alpha, \\sum_(i=1)^n, (■(a&b@c&d))) or LaTeX (\\frac{a}{b}, \\sqrt{x}) and a space or an operator " + "builds it up. Arrows move through the slots, Tab through the cells of a matrix; Backspace selects a structure, then deletes it; " - + "Ctrl+Z undoes. #(1) at the end, or \\tag{1}, numbers the equation.", + + "Ctrl+Z undoes. #(1) at the end, or \\tag{1}, numbers the equation; Shift+Enter starts a new line, & aligns the lines. " + + "Type \\ and the start of a command: the floating bar completes it (Up, Down, Tab).", TextWrapping = TextWrapping.Wrap, Opacity = 0.7, }, @@ -130,6 +132,9 @@ private StackPanel Settings() editor.FontSize = size.Value; }; + var quickBar = new CheckBox { Content = "Quick bar", IsChecked = true }; + quickBar.IsCheckedChanged += (_, _) => editor.IsQuickBarEnabled = quickBar.IsChecked == true; + var examples = new ComboBox { PlaceholderText = "Examples", ItemsSource = Examples.Select(e => e.Name).ToList() }; examples.SelectionChanged += (_, _) => { @@ -140,7 +145,7 @@ private StackPanel Settings() } }; - return Row(Label("Input"), format, linear, display, Label("Font"), font, size, Label("Load"), examples); + return Row(Label("Input"), format, linear, display, quickBar, Label("Font"), font, size, Label("Load"), examples); } private StackPanel Structures() @@ -180,7 +185,12 @@ MathNode Accent(string mark, bool under = false) => MathNode.Element( }, new[] { new KeyValuePair("columnalign", "left") }), MathNode.Token("mo", string.Empty), }))), - Tool("▭", "Box", () => editor.Editor.InsertStructure("menclose", new[] { new KeyValuePair("notation", "box") }))); + Tool("▭", "Box", () => editor.Editor.InsertStructure("menclose", new[] { new KeyValuePair("notation", "box") })), + Tool("↵", "New line (Shift+Enter)", () => editor.Editor.InsertLineBreak()), + Tool("+row", "Insert a row in the table", () => editor.Editor.InsertTableRow(), 50), + Tool("+col", "Insert a column in the table", () => editor.Editor.InsertTableColumn(), 50), + Tool("−row", "Delete the row of the table", () => editor.Editor.DeleteTableRow(), 50), + Tool("−col", "Delete the column of the table", () => editor.Editor.DeleteTableColumn(), 50)); } private StackPanel Symbols() diff --git a/src/SharpEquations.Avalonia/AvaloniaMathFont.cs b/src/SharpEquations.Avalonia/AvaloniaMathFont.cs index 83eb234..1538414 100644 --- a/src/SharpEquations.Avalonia/AvaloniaMathFont.cs +++ b/src/SharpEquations.Avalonia/AvaloniaMathFont.cs @@ -4,80 +4,108 @@ namespace SharpEquations.Avalonia using System.Collections.Generic; using System.IO; using global::Avalonia.Media; - using global::Avalonia.Media.Fonts; using SharpEquations.Rendering; using SharpEquations.Rendering.OpenType; - /// A font of Avalonia for the layout of equations: its glyphs, metrics and OpenType MATH table. + /// + /// A font of Avalonia for the layout of equations. Its glyphs, advances, ink boxes, MATH table and script variants + /// are read from a copy of the font file, once; the glyph typeface of Avalonia is asked for only to draw, each time + /// (), since one held for long can be released by Avalonia, its memory given to another + /// font. + /// public sealed class AvaloniaMathFont : IMathFont { - private readonly Dictionary advances = new Dictionary(); private readonly Dictionary fallbacks = new Dictionary(StringComparer.Ordinal); + private readonly FontFile file; private readonly Lazy math; private readonly Lazy outlines; - private readonly Lazy file; + private readonly Lazy scriptStyles; + private readonly string familyName; - /// A font of a glyph typeface. - public AvaloniaMathFont(GlyphTypeface typeface) + /// The font of a typeface. + /// The typeface is not installed, or its font file cannot be read. + public AvaloniaMathFont(Typeface typeface) { - Typeface = typeface ?? throw new ArgumentNullException(nameof(typeface)); - file = new Lazy(ReadFile); - math = new Lazy(ReadMath); - outlines = new Lazy(() => GlyphOutlines.Create(Table)); + Typeface = typeface; + if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs) || ReadFile(glyphs) is not { } read) + throw new ArgumentException("The font of the typeface cannot be read: " + typeface.FontFamily, nameof(typeface)); + file = read; + familyName = glyphs.FamilyName; + UnitsPerEm = file.UnitsPerEm; + math = new Lazy(() => file.Table("MATH") is { } table ? TryParse(table) : null); + scriptStyles = new Lazy(() => ScriptStyles.Parse(file.Table("GSUB"))); + outlines = new Lazy(() => GlyphOutlines.Create(file.Table)); } - /// The glyph typeface. - public GlyphTypeface Typeface { get; } + /// The typeface. + public Typeface Typeface { get; } /// - public double UnitsPerEm => Typeface.Metrics.DesignEmHeight; + public double UnitsPerEm { get; } /// public MathTable? Math => math.Value; + /// The glyph typeface of Avalonia to draw with, as Avalonia has it now; null if it no longer has the font. + public GlyphTypeface? GetGlyphTypeface() + { + MathFonts.Register(); + return FontManager.Current.TryGetGlyphTypeface(Typeface, out var glyphs) ? glyphs : null; + } + /// - public bool TryGetGlyph(int codePoint, out ushort glyph) => Typeface.CharacterToGlyphMap.TryGetGlyph(codePoint, out glyph) && glyph != 0; + public bool TryGetGlyph(int codePoint, out ushort glyph) => file.TryGetGlyph(codePoint, out glyph); /// - public double GetAdvance(ushort glyph) - { - if (!advances.TryGetValue(glyph, out var advance)) - { - advance = Typeface.TryGetHorizontalGlyphAdvance(glyph, out var a) ? a : 0; - advances[glyph] = advance; - } - return advance; - } + public double GetAdvance(ushort glyph) => file.Advance(glyph); /// public GlyphBounds GetBounds(ushort glyph) => outlines.Value?.Bounds(glyph) ?? default; + /// + public ushort GetScriptVariant(ushort glyph, int scriptLevel) => scriptStyles.Value?.Variant(glyph, scriptLevel) ?? glyph; + /// public IMathFont? GetFallback(int codePoint) { if (!FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface)) return null; var key = typeface.FontFamily.Name; - if (!fallbacks.TryGetValue(key, out var font)) + lock (fallbacks) { - font = FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs) && glyphs.FamilyName != Typeface.FamilyName - ? new AvaloniaMathFont(glyphs) - : null; - fallbacks[key] = font; + if (!fallbacks.TryGetValue(key, out var font)) + { + try + { + font = new AvaloniaMathFont(typeface); + if (font.familyName == familyName) + font = null; + } + catch (ArgumentException) + { + font = null; + } + fallbacks[key] = font; + } + return font; } - return font; } - /// - /// A table of the font, from a copy of the font file: the memory of the tables that Avalonia gives is not valid - /// for long. The ink boxes come from the outlines of the font rather than from the rasterizer, whose bounds of a - /// glyph run can be empty (and the metrics of Avalonia are, for CFF outlines). - /// - private byte[]? Table(string tag) => file.Value?.Table(tag); + private static MathTable? TryParse(byte[] table) + { + try + { + return MathTable.Parse(table); + } + catch (FormatException) + { + return null; + } + } - private FontFile? ReadFile() + private static FontFile? ReadFile(GlyphTypeface glyphs) { - if (!Typeface.PlatformTypeface.TryGetStream(out var stream) || stream == null) + if (!glyphs.PlatformTypeface.TryGetStream(out var stream) || stream == null) return null; using (stream) { @@ -85,7 +113,7 @@ public double GetAdvance(ushort glyph) stream.CopyTo(copy); try { - return FontFile.Parse(copy.ToArray(), Typeface.GlyphCount); + return FontFile.Parse(copy.ToArray(), glyphs.GlyphCount); } catch (FormatException) { @@ -93,19 +121,5 @@ public double GetAdvance(ushort glyph) } } } - - private MathTable? ReadMath() - { - if (Table("MATH") is not { } table) - return null; - try - { - return MathTable.Parse(table); - } - catch (FormatException) - { - return null; - } - } } } diff --git a/src/SharpEquations.Avalonia/MathDrawing.cs b/src/SharpEquations.Avalonia/MathDrawing.cs index c4d13f0..1d54644 100644 --- a/src/SharpEquations.Avalonia/MathDrawing.cs +++ b/src/SharpEquations.Avalonia/MathDrawing.cs @@ -34,12 +34,17 @@ internal sealed class MathDrawing : IDisposable public MathDrawing(MathLayout layout) { Layout = layout; + var typefaces = new Dictionary(); foreach (var glyph in layout.Glyphs) { if (glyph.Font is not AvaloniaMathFont font) continue; + if (!typefaces.TryGetValue(font, out var typeface)) + typefaces[font] = typeface = font.GetGlyphTypeface(); + if (typeface == null) + continue; var info = new[] { new GlyphInfo(glyph.Glyph, 0, font.GetAdvance(glyph.Glyph) * glyph.Size / font.UnitsPerEm) }; - runs.Add((new GlyphRun(font.Typeface, glyph.Size, "".AsMemory(), info, new Point(glyph.X, glyph.Y)), glyph.Color)); + runs.Add((new GlyphRun(typeface, glyph.Size, ReadOnlyMemory.Empty, info, new Point(glyph.X, glyph.Y)), glyph.Color)); } } diff --git a/src/SharpEquations.Avalonia/MathEditBox.cs b/src/SharpEquations.Avalonia/MathEditBox.cs index 13be271..9319579 100644 --- a/src/SharpEquations.Avalonia/MathEditBox.cs +++ b/src/SharpEquations.Avalonia/MathEditBox.cs @@ -1,12 +1,14 @@ namespace SharpEquations.Avalonia { using System; + using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using global::Avalonia; using global::Avalonia.Automation.Peers; using global::Avalonia.Controls; + using global::Avalonia.Controls.Primitives; using global::Avalonia.Controls.Documents; using global::Avalonia.Data; using global::Avalonia.Input; @@ -94,11 +96,17 @@ public class MathEditBox : Control public static readonly StyledProperty CopyTextFormatProperty = AvaloniaProperty.Register(nameof(CopyTextFormat), validate: f => f != EquationFormat.Mtef); + /// Whether the floating bar shows completions, structures, table commands and symbols at the caret. + public static readonly StyledProperty IsQuickBarEnabledProperty = + AvaloniaProperty.Register(nameof(IsQuickBarEnabled), true); + /// The gesture that switches between the built up and the linear view. public static readonly StyledProperty ToggleViewModeGestureProperty = AvaloniaProperty.Register(nameof(ToggleViewModeGesture), new KeyGesture(Key.L, KeyModifiers.Control | KeyModifiers.Shift)); private readonly DispatcherTimer blink; + private readonly Popup popup; + private string? dismissedPrefix; private readonly InputClient inputClient; private MathDrawing? drawing; private bool caretVisible = true; @@ -121,6 +129,17 @@ public MathEditBox() Editor.ContentChanged += OnEditorContentChanged; inputClient = new InputClient(this); TextInputMethodClientRequested += (_, e) => e.Client = inputClient; + QuickBar = new MathQuickBar(this); + popup = new Popup + { + Child = QuickBar, + PlacementTarget = this, + Placement = PlacementMode.Bottom, + IsLightDismissEnabled = false, + Focusable = false, + }; + LogicalChildren.Add(popup); + VisualChildren.Add(popup); blink = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) }; blink.Tick += (_, _) => { @@ -253,6 +272,22 @@ public KeyGesture? ToggleViewModeGesture set => SetValue(ToggleViewModeGestureProperty, value); } + /// Whether the floating bar () shows at the caret. + public bool IsQuickBarEnabled + { + get => GetValue(IsQuickBarEnabledProperty); + set => SetValue(IsQuickBarEnabledProperty, value); + } + + /// The floating bar at the caret: completions of commands, structures for the selection, table commands, symbols. + public MathQuickBar QuickBar { get; } + + /// Whether the floating bar is open. + public bool IsQuickBarOpen => popup.IsOpen; + + /// The symbols the floating bar offers in an empty slot. + public IList QuickSymbols { get; } = new List { "α", "β", "θ", "π", "∞", "±", "×", "≤", "≥", "≠", "≈", "→", "∈", "∂", "∑", "∫" }; + /// The format of the text copied to the clipboard; null for the input format. public EquationFormat? CopyTextFormat { @@ -263,6 +298,14 @@ public EquationFormat? CopyTextFormat /// The layout of the equation, after a measure. public MathLayout? Layout => drawing?.Layout; + /// Where the layout is drawn: inside the border and the padding, in the middle of the height. + private Point ContentOrigin() + { + var origin = Origin; + var height = drawing?.Layout.Height ?? 0; + return new Point(origin.X, origin.Y + Math.Max(0, (Bounds.Height - Padding.Top - Padding.Bottom - BorderThickness * 2 - height) / 2)); + } + private Point Origin { get @@ -390,6 +433,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang blink.Stop(); } InvalidateVisual(); + UpdateQuickBar(); + } + else if (change.Property == IsQuickBarEnabledProperty) + { + UpdateQuickBar(); } } @@ -416,10 +464,8 @@ public override void Render(DrawingContext context) if (drawing == null) return; - var origin = Origin; + var origin = ContentOrigin(); var layout = drawing.Layout; - var offsetY = Math.Max(0, (Bounds.Height - Padding.Top - Padding.Bottom - BorderThickness * 2 - layout.Height) / 2); - origin = new Point(origin.X, origin.Y + offsetY); if (Editor.Selection is { } selection && SelectionBrush != null) { @@ -466,6 +512,31 @@ protected override void OnKeyDown(KeyEventArgs e) return; var shift = (e.KeyModifiers & KeyModifiers.Shift) != 0; + if (popup.IsOpen && QuickBar.Mode == MathQuickBarMode.Completions && e.KeyModifiers == KeyModifiers.None) + { + switch (e.Key) + { + case Key.Down: + QuickBar.SelectedIndex++; + e.Handled = true; + return; + case Key.Up: + QuickBar.SelectedIndex--; + e.Handled = true; + return; + case Key.Tab or Key.Enter: + e.Handled = QuickBar.AcceptCompletion(); + if (e.Handled) + return; + break; + case Key.Escape: + dismissedPrefix = Editor.CommandPrefix; + UpdateQuickBar(); + e.Handled = true; + return; + } + } + var hotkeys = Application.Current?.PlatformSettings?.HotkeyConfiguration; bool Matches(System.Collections.Generic.List? gestures, Key key) => gestures != null ? gestures.Any(g => g.Matches(e)) : e.Key == key && (e.KeyModifiers & KeyModifiers.Control) != 0; @@ -515,6 +586,9 @@ bool Matches(System.Collections.Generic.List? gestures, Key key) => break; case Key.Tab when (e.KeyModifiers & ~KeyModifiers.Shift) == 0 && Editor.MoveToNextCell(shift): break; + case Key.Enter when !IsReadOnly && shift: + Editor.InsertLineBreak(); + break; case Key.Enter when !IsReadOnly: Editor.BuildUpAll(); break; @@ -562,9 +636,8 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) { if (drawing == null) return null; - var origin = Origin; - var offsetY = Math.Max(0, (Bounds.Height - Padding.Top - Padding.Bottom - BorderThickness * 2 - drawing.Layout.Height) / 2); - return drawing.Layout.HitTest(point.X - origin.X, point.Y - origin.Y - offsetY); + var origin = ContentOrigin(); + return drawing.Layout.HitTest(point.X - origin.X, point.Y - origin.Y); } private void OnEditorChanged(object? sender, EventArgs e) @@ -573,6 +646,70 @@ private void OnEditorChanged(object? sender, EventArgs e) InvalidateMeasure(); InvalidateVisual(); inputClient.RaiseCursorChanged(); + Dispatcher.UIThread.Post(UpdateQuickBar, DispatcherPriority.Background); + } + + /// Opens the floating bar at the caret with what fits the place of the caret, or closes it. + private void UpdateQuickBar() + { + var mode = MathQuickBarMode.Hidden; + IReadOnlyList completions = Array.Empty(); + if (IsQuickBarEnabled && IsFocused && !IsReadOnly && !Editor.IsLinear && drawing != null) + { + var prefix = Editor.CommandPrefix; + if (prefix != dismissedPrefix) + dismissedPrefix = null; + if (prefix != null && prefix.Length > 1 && dismissedPrefix == null && Editor.GetCompletions(8) is { Count: > 0 } found) + { + mode = MathQuickBarMode.Completions; + completions = found; + } + else if (prefix == null && Editor.Selection != null) + { + mode = MathQuickBarMode.Selection; + } + else if (prefix == null && Editor.IsInTable) + { + mode = MathQuickBarMode.Table; + } + else if (prefix == null && IsInEmptySlot()) + { + mode = MathQuickBarMode.Symbols; + } + } + + if (mode == MathQuickBarMode.Hidden) + { + popup.IsOpen = false; + QuickBar.Show(mode, completions); + return; + } + + QuickBar.Show(mode, completions); + var layout = drawing!.Layout; + var rects = Editor.Selection is { } selection ? layout.GetSelectionRects(selection) : Array.Empty(); + var anchor = rects.Count > 0 && rects[0].Width > 0 ? rects[0] : layout.GetCaretRect(Editor.Caret) ?? default; + var origin = ContentOrigin(); + // under the whole equation, where the caret is, so that the bar covers nothing of it + popup.PlacementRect = new Rect(anchor.X + origin.X, origin.Y, Math.Max(1, anchor.Width), Math.Max(layout.Height, anchor.Y + anchor.Height) + Padding.Bottom); + popup.IsOpen = true; + } + + private bool IsInEmptySlot() + { + var caret = Editor.Caret; + return caret.Path.Count > 1 && Editor.Root is { } root && Node(root, caret.Path) is { IsRow: true, Children.Count: 0 }; + + static MathNode? Node(MathNode node, IReadOnlyList path) + { + foreach (var i in path) + { + if (i >= node.Children.Count) + return null; + node = node.Children[i]; + } + return node; + } } private void OnEditorContentChanged(object? sender, EventArgs e) @@ -613,7 +750,7 @@ public override Rect CursorRectangle { if (owner.Layout?.GetCaretRect(owner.Editor.Caret) is not { } caret) return default; - var origin = owner.Origin; + var origin = owner.ContentOrigin(); return new Rect(caret.X + origin.X, caret.Y + origin.Y, 1, caret.Height); } } diff --git a/src/SharpEquations.Avalonia/MathFonts.cs b/src/SharpEquations.Avalonia/MathFonts.cs index 4dabdd7..f2c5878 100644 --- a/src/SharpEquations.Avalonia/MathFonts.cs +++ b/src/SharpEquations.Avalonia/MathFonts.cs @@ -13,7 +13,7 @@ public static class MathFonts { private const string CollectionKey = "fonts:SharpEquations"; private static readonly object Gate = new object(); - private static bool registered; + private static FontManager? registeredOn; private static AvaloniaMathFont? defaultFont; /// The family of the embedded Libertinus Math. @@ -50,9 +50,21 @@ public static AvaloniaMathFont Default public static bool TryGet(FontFamily family, out AvaloniaMathFont? font) { font = null; - if (!FontManager.Current.TryGetGlyphTypeface(new Typeface(family), out var glyphs)) + var typeface = new Typeface(family); + if (!FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs) + || !string.Equals(glyphs.FamilyName, family.FamilyNames[0], StringComparison.OrdinalIgnoreCase) && !family.Name.Contains('#')) + { return false; - var candidate = new AvaloniaMathFont(glyphs); + } + AvaloniaMathFont candidate; + try + { + candidate = new AvaloniaMathFont(typeface); + } + catch (ArgumentException) + { + return false; + } if (candidate.Math == null) return false; font = candidate; @@ -62,16 +74,21 @@ public static bool TryGet(FontFamily family, out AvaloniaMathFont? font) /// The math font of a family, or the default one if the family is null or not a math font. public static AvaloniaMathFont Get(FontFamily? family) => family != null && TryGet(family, out var font) ? font! : Default; - private static void Register() + /// + /// Registers the embedded fonts with the font manager of Avalonia, again when it is another one (an application + /// that starts Avalonia again, a test session that isolates each test). + /// + internal static void Register() { lock (Gate) { - if (registered) + var manager = FontManager.Current; + if (ReferenceEquals(registeredOn, manager)) return; - FontManager.Current.AddFontCollection(new EmbeddedFontCollection( + manager.AddFontCollection(new EmbeddedFontCollection( new Uri(CollectionKey, UriKind.Absolute), new Uri("avares://SharpEquations.Avalonia/Assets/Fonts", UriKind.Absolute))); - registered = true; + registeredOn = manager; } } } diff --git a/src/SharpEquations.Avalonia/MathQuickBar.cs b/src/SharpEquations.Avalonia/MathQuickBar.cs new file mode 100644 index 0000000..141d898 --- /dev/null +++ b/src/SharpEquations.Avalonia/MathQuickBar.cs @@ -0,0 +1,160 @@ +namespace SharpEquations.Avalonia +{ + using System; + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations.Editing; + + /// What shows. + public enum MathQuickBarMode + { + /// Nothing: the bar is closed. + Hidden, + + /// The commands that complete the one typed before the caret (\al: \alpha, \aleph...). + Completions, + + /// The structures that take the selection (a fraction of it, its root, brackets around it...). + Selection, + + /// The commands of the table of the caret: rows and columns. + Table, + + /// The quick symbols, in an empty slot. + Symbols, + } + + /// + /// The floating bar of , at the caret: the completions of a command being typed (Up and + /// Down choose, Tab or Enter take, Esc closes), the structures for the selection, the commands of a table, and quick + /// symbols in an empty slot. Its buttons do not take the focus, so typing goes on in the editor. + /// + public class MathQuickBar : Border + { + private readonly MathEditBox owner; + private readonly WrapPanel panel = new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 2, LineSpacing = 2, MaxWidth = 420 }; + private IReadOnlyList completions = Array.Empty(); + private int selectedIndex; + + internal MathQuickBar(MathEditBox owner) + { + this.owner = owner; + Child = panel; + Padding = new Thickness(4); + CornerRadius = new CornerRadius(6); + BorderThickness = new Thickness(1); + BorderBrush = new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)); + Background = new SolidColorBrush(Color.FromArgb(0xF8, 0xFB, 0xFB, 0xFD)); + BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 8, Color = Color.FromArgb(0x40, 0, 0, 0) }); + } + + /// What the bar shows. + public MathQuickBarMode Mode { get; private set; } + + /// The completions shown, in . + public IReadOnlyList Completions => completions; + + /// The completion that Tab or Enter takes. + public int SelectedIndex + { + get => selectedIndex; + set + { + if (completions.Count == 0) + return; + selectedIndex = (value % completions.Count + completions.Count) % completions.Count; + Highlight(); + } + } + + /// Takes the selected completion; returns false when there is none. + public bool AcceptCompletion() + { + if (Mode != MathQuickBarMode.Completions || completions.Count == 0) + return false; + owner.Editor.Complete(completions[selectedIndex]); + return true; + } + + internal void Show(MathQuickBarMode mode, IReadOnlyList commands) + { + if (mode == Mode && mode == MathQuickBarMode.Completions && commands.Select(c => c.Name).SequenceEqual(completions.Select(c => c.Name))) + return; + Mode = mode; + completions = commands; + selectedIndex = 0; + panel.Children.Clear(); + switch (mode) + { + case MathQuickBarMode.Completions: + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + var button = Button(command.Symbol.Length > 0 ? command.Symbol + " \\" + command.Name : command.Text, "\\" + command.Name, () => owner.Editor.Complete(command)); + button.HorizontalContentAlignment = HorizontalAlignment.Left; + button.MinWidth = 130; + panel.Children.Add(button); + } + Highlight(); + break; + case MathQuickBarMode.Selection: + var e = owner.Editor; + panel.Children.Add(Button("a/b", "Fraction of the selection", () => e.InsertStructure("mfrac"))); + panel.Children.Add(Button("√x", "Root of the selection", () => e.InsertStructure("msqrt"))); + panel.Children.Add(Button("xⁿ", "Superscript", () => e.InsertStructure("msup"))); + panel.Children.Add(Button("xₙ", "Subscript", () => e.InsertStructure("msub"))); + panel.Children.Add(Button("(x)", "Parentheses", () => e.InsertFences())); + panel.Children.Add(Button("[x]", "Brackets", () => e.InsertFences("[", "]"))); + panel.Children.Add(Button("|x|", "Absolute value", () => e.InsertFences("|", "|"))); + panel.Children.Add(Button("x̄", "Overline", () => e.InsertStructure("menclose", Notation("top")))); + panel.Children.Add(Button("▭", "Box", () => e.InsertStructure("menclose", Notation("box")))); + panel.Children.Add(Button("Linear", "Build down the selection", () => e.BuildDown())); + break; + case MathQuickBarMode.Table: + var t = owner.Editor; + panel.Children.Add(Button("+ row", "Insert a row below", () => t.InsertTableRow())); + panel.Children.Add(Button("+ row ↑", "Insert a row above", () => t.InsertTableRow(above: true))); + panel.Children.Add(Button("+ col", "Insert a column on the right", () => t.InsertTableColumn())); + panel.Children.Add(Button("+ col ←", "Insert a column on the left", () => t.InsertTableColumn(before: true))); + panel.Children.Add(Button("− row", "Delete the row", () => t.DeleteTableRow())); + panel.Children.Add(Button("− col", "Delete the column", () => t.DeleteTableColumn())); + break; + case MathQuickBarMode.Symbols: + foreach (var symbol in owner.QuickSymbols) + panel.Children.Add(Button(symbol, symbol, () => owner.Editor.Insert(symbol), 30)); + break; + } + } + + private static KeyValuePair[] Notation(string notation) => new[] { new KeyValuePair("notation", notation) }; + + private static Button Button(string content, string tip, Action action, double minWidth = 40) + { + var button = new Button + { + Content = content, + MinWidth = minWidth, + Padding = new Thickness(6, 2), + Focusable = false, + Background = Brushes.Transparent, + HorizontalContentAlignment = HorizontalAlignment.Center, + }; + ToolTip.SetTip(button, tip); + button.Click += (_, _) => action(); + return button; + } + + private void Highlight() + { + for (var i = 0; i < panel.Children.Count; i++) + { + if (panel.Children[i] is Button button) + button.Background = i == selectedIndex && Mode == MathQuickBarMode.Completions ? new SolidColorBrush(Color.FromArgb(0x40, 0x33, 0x99, 0xFF)) : Brushes.Transparent; + } + } + } +} diff --git a/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt index 8723370..0f3e132 100644 --- a/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt @@ -1,28 +1,14 @@ #nullable enable -override SharpEquations.Avalonia.MathEditBox.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size -override SharpEquations.Avalonia.MathEditBox.OnCreateAutomationPeer() -> Avalonia.Automation.Peers.AutomationPeer! -override SharpEquations.Avalonia.MathEditBox.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.OnKeyDown(Avalonia.Input.KeyEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.OnPointerMoved(Avalonia.Input.PointerEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.OnPointerPressed(Avalonia.Input.PointerPressedEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.OnPropertyChanged(Avalonia.AvaloniaPropertyChangedEventArgs! change) -> void -override SharpEquations.Avalonia.MathEditBox.OnTextInput(Avalonia.Input.TextInputEventArgs! e) -> void -override SharpEquations.Avalonia.MathEditBox.Render(Avalonia.Media.DrawingContext! context) -> void -override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetAutomationControlTypeCore() -> Avalonia.Automation.Peers.AutomationControlType -override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetClassNameCore() -> string! -override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetNameCore() -> string? -override SharpEquations.Avalonia.MathView.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size -override SharpEquations.Avalonia.MathView.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void -override SharpEquations.Avalonia.MathView.Render(Avalonia.Media.DrawingContext! context) -> void SharpEquations.Avalonia.AvaloniaMathFont -SharpEquations.Avalonia.AvaloniaMathFont.AvaloniaMathFont(Avalonia.Media.GlyphTypeface! typeface) -> void +SharpEquations.Avalonia.AvaloniaMathFont.AvaloniaMathFont(Avalonia.Media.Typeface typeface) -> void SharpEquations.Avalonia.AvaloniaMathFont.GetAdvance(ushort glyph) -> double SharpEquations.Avalonia.AvaloniaMathFont.GetBounds(ushort glyph) -> SharpEquations.Rendering.GlyphBounds SharpEquations.Avalonia.AvaloniaMathFont.GetFallback(int codePoint) -> SharpEquations.Rendering.IMathFont? +SharpEquations.Avalonia.AvaloniaMathFont.GetGlyphTypeface() -> Avalonia.Media.GlyphTypeface? +SharpEquations.Avalonia.AvaloniaMathFont.GetScriptVariant(ushort glyph, int scriptLevel) -> ushort SharpEquations.Avalonia.AvaloniaMathFont.Math.get -> SharpEquations.Rendering.OpenType.MathTable? SharpEquations.Avalonia.AvaloniaMathFont.TryGetGlyph(int codePoint, out ushort glyph) -> bool -SharpEquations.Avalonia.AvaloniaMathFont.Typeface.get -> Avalonia.Media.GlyphTypeface! +SharpEquations.Avalonia.AvaloniaMathFont.Typeface.get -> Avalonia.Media.Typeface SharpEquations.Avalonia.AvaloniaMathFont.UnitsPerEm.get -> double SharpEquations.Avalonia.EquationChangedEventArgs SharpEquations.Avalonia.EquationChangedEventArgs.Equation.get -> SharpEquations.Equation? @@ -45,9 +31,9 @@ SharpEquations.Avalonia.MathEditBox.BuildUp() -> void SharpEquations.Avalonia.MathEditBox.CaretBrush.get -> Avalonia.Media.IBrush? SharpEquations.Avalonia.MathEditBox.CaretBrush.set -> void SharpEquations.Avalonia.MathEditBox.CopyAsync() -> System.Threading.Tasks.Task! -SharpEquations.Avalonia.MathEditBox.Copying -> System.EventHandler? SharpEquations.Avalonia.MathEditBox.CopyTextFormat.get -> SharpEquations.EquationFormat? SharpEquations.Avalonia.MathEditBox.CopyTextFormat.set -> void +SharpEquations.Avalonia.MathEditBox.Copying -> System.EventHandler? SharpEquations.Avalonia.MathEditBox.CutAsync() -> System.Threading.Tasks.Task! SharpEquations.Avalonia.MathEditBox.Editor.get -> SharpEquations.Editing.MathEditor! SharpEquations.Avalonia.MathEditBox.Equation.get -> SharpEquations.Equation? @@ -66,6 +52,9 @@ SharpEquations.Avalonia.MathEditBox.InputFormat.get -> SharpEquations.EquationFo SharpEquations.Avalonia.MathEditBox.InputFormat.set -> void SharpEquations.Avalonia.MathEditBox.IsDisplay.get -> bool SharpEquations.Avalonia.MathEditBox.IsDisplay.set -> void +SharpEquations.Avalonia.MathEditBox.IsQuickBarEnabled.get -> bool +SharpEquations.Avalonia.MathEditBox.IsQuickBarEnabled.set -> void +SharpEquations.Avalonia.MathEditBox.IsQuickBarOpen.get -> bool SharpEquations.Avalonia.MathEditBox.IsReadOnly.get -> bool SharpEquations.Avalonia.MathEditBox.IsReadOnly.set -> void SharpEquations.Avalonia.MathEditBox.Layout.get -> SharpEquations.Rendering.MathLayout? @@ -77,6 +66,8 @@ SharpEquations.Avalonia.MathEditBox.Padding.set -> void SharpEquations.Avalonia.MathEditBox.PasteAsync() -> System.Threading.Tasks.Task! SharpEquations.Avalonia.MathEditBox.PlaceholderBrush.get -> Avalonia.Media.IBrush? SharpEquations.Avalonia.MathEditBox.PlaceholderBrush.set -> void +SharpEquations.Avalonia.MathEditBox.QuickBar.get -> SharpEquations.Avalonia.MathQuickBar! +SharpEquations.Avalonia.MathEditBox.QuickSymbols.get -> System.Collections.Generic.IList! SharpEquations.Avalonia.MathEditBox.SelectionBrush.get -> Avalonia.Media.IBrush? SharpEquations.Avalonia.MathEditBox.SelectionBrush.set -> void SharpEquations.Avalonia.MathEditBox.SetMath(string! mathML) -> void @@ -91,6 +82,18 @@ SharpEquations.Avalonia.MathEditBoxAutomationPeer.Owner.get -> SharpEquations.Av SharpEquations.Avalonia.MathEditBoxAutomationPeer.SetValue(string? value) -> void SharpEquations.Avalonia.MathEditBoxAutomationPeer.Value.get -> string! SharpEquations.Avalonia.MathFonts +SharpEquations.Avalonia.MathQuickBar +SharpEquations.Avalonia.MathQuickBar.AcceptCompletion() -> bool +SharpEquations.Avalonia.MathQuickBar.Completions.get -> System.Collections.Generic.IReadOnlyList! +SharpEquations.Avalonia.MathQuickBar.Mode.get -> SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBar.SelectedIndex.get -> int +SharpEquations.Avalonia.MathQuickBar.SelectedIndex.set -> void +SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBarMode.Completions = 1 -> SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBarMode.Hidden = 0 -> SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBarMode.Selection = 2 -> SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBarMode.Symbols = 4 -> SharpEquations.Avalonia.MathQuickBarMode +SharpEquations.Avalonia.MathQuickBarMode.Table = 3 -> SharpEquations.Avalonia.MathQuickBarMode SharpEquations.Avalonia.MathView SharpEquations.Avalonia.MathView.Equation.get -> SharpEquations.Equation? SharpEquations.Avalonia.MathView.Equation.set -> void @@ -109,6 +112,27 @@ SharpEquations.Avalonia.MathView.MathView() -> void SharpEquations.Avalonia.MathViewMode SharpEquations.Avalonia.MathViewMode.Linear = 1 -> SharpEquations.Avalonia.MathViewMode SharpEquations.Avalonia.MathViewMode.Professional = 0 -> SharpEquations.Avalonia.MathViewMode +override SharpEquations.Avalonia.MathEditBox.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size +override SharpEquations.Avalonia.MathEditBox.OnCreateAutomationPeer() -> Avalonia.Automation.Peers.AutomationPeer! +override SharpEquations.Avalonia.MathEditBox.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnKeyDown(Avalonia.Input.KeyEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerMoved(Avalonia.Input.PointerEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerPressed(Avalonia.Input.PointerPressedEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.OnPropertyChanged(Avalonia.AvaloniaPropertyChangedEventArgs! change) -> void +override SharpEquations.Avalonia.MathEditBox.OnTextInput(Avalonia.Input.TextInputEventArgs! e) -> void +override SharpEquations.Avalonia.MathEditBox.Render(Avalonia.Media.DrawingContext! context) -> void +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetAutomationControlTypeCore() -> Avalonia.Automation.Peers.AutomationControlType +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetClassNameCore() -> string! +override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetNameCore() -> string? +override SharpEquations.Avalonia.MathView.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size +override SharpEquations.Avalonia.MathView.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void +override SharpEquations.Avalonia.MathView.Render(Avalonia.Media.DrawingContext! context) -> void +static SharpEquations.Avalonia.MathEditBox.MathMLClipboardFormat.get -> Avalonia.Input.DataFormat! +static SharpEquations.Avalonia.MathFonts.Default.get -> SharpEquations.Avalonia.AvaloniaMathFont! +static SharpEquations.Avalonia.MathFonts.Get(Avalonia.Media.FontFamily? family) -> SharpEquations.Avalonia.AvaloniaMathFont! +static SharpEquations.Avalonia.MathFonts.LibertinusMath.get -> Avalonia.Media.FontFamily! +static SharpEquations.Avalonia.MathFonts.TryGet(Avalonia.Media.FontFamily! family, out SharpEquations.Avalonia.AvaloniaMathFont? font) -> bool static readonly SharpEquations.Avalonia.MathEditBox.BackgroundProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.BorderBrushProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.BorderThicknessProperty -> Avalonia.StyledProperty! @@ -119,6 +143,7 @@ static readonly SharpEquations.Avalonia.MathEditBox.FontSizeProperty -> Avalonia static readonly SharpEquations.Avalonia.MathEditBox.ForegroundProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.InputFormatProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.IsDisplayProperty -> Avalonia.StyledProperty! +static readonly SharpEquations.Avalonia.MathEditBox.IsQuickBarEnabledProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.IsReadOnlyProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.MathFontFamilyProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathEditBox.PaddingProperty -> Avalonia.StyledProperty! @@ -131,8 +156,3 @@ static readonly SharpEquations.Avalonia.MathView.FontSizeProperty -> Avalonia.St static readonly SharpEquations.Avalonia.MathView.ForegroundProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathView.IsDisplayProperty -> Avalonia.StyledProperty! static readonly SharpEquations.Avalonia.MathView.MathFontFamilyProperty -> Avalonia.StyledProperty! -static SharpEquations.Avalonia.MathEditBox.MathMLClipboardFormat.get -> Avalonia.Input.DataFormat! -static SharpEquations.Avalonia.MathFonts.Default.get -> SharpEquations.Avalonia.AvaloniaMathFont! -static SharpEquations.Avalonia.MathFonts.Get(Avalonia.Media.FontFamily? family) -> SharpEquations.Avalonia.AvaloniaMathFont! -static SharpEquations.Avalonia.MathFonts.LibertinusMath.get -> Avalonia.Media.FontFamily! -static SharpEquations.Avalonia.MathFonts.TryGet(Avalonia.Media.FontFamily! family, out SharpEquations.Avalonia.AvaloniaMathFont? font) -> bool diff --git a/src/SharpEquations.Editing/BuildUp.cs b/src/SharpEquations.Editing/BuildUp.cs index 6b7ba90..1be6780 100644 --- a/src/SharpEquations.Editing/BuildUp.cs +++ b/src/SharpEquations.Editing/BuildUp.cs @@ -144,12 +144,26 @@ public static bool IsBalanced(string text, EquationFormat format) sb.Append(' '); AppendSeparated(sb, text); - if (!Equation.TryParse(sb.ToString(), format, new EquationOptions { Display = display }, out var equation) || equation == null) + // a line of an equation array (its row starts with maligngroup), where & is an alignment mark, is read as the + // line of an array of its own + var line = before.Count > 0 && before[0].Name == "maligngroup"; + var source = sb.ToString(); + if (line) + source = format == EquationFormat.LaTeX ? "\\begin{aligned}" + source + "\\end{aligned}" : "█(" + source + ")"; + if (!Equation.TryParse(source, format, new EquationOptions { Display = display }, out var equation) || equation == null) return null; + var elements = equation.ToMathML().Elements().ToList(); + if (line) + { + if (elements.Count != 1 || elements[0].Elements().ToList() is not { Count: 1 } rows || rows[0].Elements().ToList() is not { Count: 1 } cells) + return null; + elements = cells[0].Elements().ToList(); + } + var used = new bool[placeholders.Count]; var nodes = new List(); - foreach (var element in equation.ToMathML().Elements()) + foreach (var element in elements) { var node = Substitute(MathNode.FromMathML(element), placeholders, used); if (node == null) @@ -215,6 +229,15 @@ private static void Append(StringBuilder sb, MathNode node, List place return; } + switch (node.Name) + { + case "maligngroup": + return; + case "malignmark": + sb.Append('&'); + return; + } + if (node.IsToken && node.Attributes.Count == 0 && IsLiteral(node, format)) { AppendSeparated(sb, node.Text); diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs index a5028d0..1ee86fa 100644 --- a/src/SharpEquations.Editing/MathEditor.cs +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -416,6 +416,206 @@ public void SetEquationNumber(string? number) OnChanged(true); } + /// + /// The command being typed before the caret (\al), with its backslash, or null: what + /// completes. + /// + public string? CommandPrefix + { + get + { + if (Selection != null || Canonical(Caret) is not { } caret || MathTree.Get(Root, caret.Path) is not { IsRun: true } run) + return null; + var before = CodePointText.Substring(run.Text, 0, caret.Offset); + var i = before.Length; + while (i > 0 && before[i - 1] < 0x80 && char.IsLetterOrDigit(before[i - 1])) + i--; + return i > 0 && before[i - 1] == '\\' ? before.Substring(i - 1) : null; + } + } + + /// The commands of the input format that complete ; empty when none is typed. + public IReadOnlyList GetCompletions(int max = 20) => + CommandPrefix is { } prefix ? EquationCommands.Complete(prefix.Substring(1), InputFormat, max) : Array.Empty(); + + /// + /// Completes the command typed before the caret: a command with a symbol becomes the symbol, as Word's math + /// autocorrect does; a structure of LaTeX becomes its text with the caret in its first argument + /// (\frac{|}{}), and one of UnicodeMath its control word and a space. + /// + public void Complete(EquationCommand command) + { + if (command == null) + throw new ArgumentNullException(nameof(command)); + if (CommandPrefix is not { } prefix) + return; + + PushUndo(); + var caret = Canonical(Caret); + var run = MathTree.Get(Root, caret.Path); + var start = caret.Offset - CodePointText.Length(prefix); + var text = command.Symbol.Length > 0 ? command.Symbol : command.Arguments > 0 ? command.Text : command.Text + " "; + var offset = command.Symbol.Length == 0 && command.Arguments > 0 ? CodePointText.Length("\\" + command.Name) + 1 : CodePointText.Length(text); + var rest = CodePointText.Remove(run.Text, start, caret.Offset - start); + Root = MathTree.Replace(Root, caret.Path, run.WithText(CodePointText.Insert(rest, start, text))); + Caret = Anchor = new MathPosition(caret.Path, start + offset); + if (!IsLinear && command.Symbol.Length == 0 && command.Arguments == 0) + TryBuildUpAtCaret(true); + OnChanged(true); + } + + /// Whether the caret is in a cell of a table (a matrix, an equation array): the table commands apply. + public bool IsInTable => CellAtCaret() != null; + + /// Inserts an empty row in the table of the caret, below its row (above, with ), and puts the caret in it. + /// Whether the caret is in a table. + public bool InsertTableRow(bool above = false) + { + if (CellAtCaret() is not { } cell) + return false; + PushUndo(); + var table = MathTree.Get(Root, cell.Table); + var rows = table.Children.ToList(); + var current = rows[cell.Row]; + var empty = MathNode.Row(Array.Empty()); + var array = ArrayTable() != null && cell.Table.SequenceEqual(ArrayPath); + var cells = current.Children.Select(_ => MathNode.Element("mtd", new[] { array ? Line(Array.Empty()).Children[0].Children[0] : empty })); + var at = above ? cell.Row : cell.Row + 1; + rows.Insert(at, MathNode.Element(current.Name == "mlabeledtr" ? "mtr" : current.Name, current.Name == "mlabeledtr" ? cells.Skip(1) : cells, current.Name == "mlabeledtr" ? null : current.Attributes)); + Root = MathTree.Replace(Root, cell.Table, table.WithChildren(rows)); + SetCaret(MathTree.Append(cell.Table, at, Math.Min(cell.Column, rows[at].Children.Count - 1), 0), array ? 1 : 0); + OnChanged(true); + return true; + } + + /// Inserts an empty column in the table of the caret, right of its column (left, with ), and puts the caret in it. + /// Whether the caret is in a table. + public bool InsertTableColumn(bool before = false) + { + if (CellAtCaret() is not { } cell) + return false; + PushUndo(); + var table = MathTree.Get(Root, cell.Table); + var at = before ? cell.Column : cell.Column + 1; + var rows = table.Children.Select(row => + { + var cells = row.Children.ToList(); + cells.Insert(Math.Min(at, cells.Count), MathNode.Element("mtd", new[] { MathNode.Row(Array.Empty()) })); + return row.WithChildren(cells); + }); + Root = MathTree.Replace(Root, cell.Table, table.WithChildren(rows)); + SetCaret(MathTree.Append(cell.Table, cell.Row, at, 0), 0); + OnChanged(true); + return true; + } + + /// Deletes the row of the caret from its table; the table itself when it is its only row. + /// Whether the caret is in a table. + public bool DeleteTableRow() + { + if (CellAtCaret() is not { } cell) + return false; + PushUndo(); + var table = MathTree.Get(Root, cell.Table); + if (table.Children.Count == 1) + return RemoveTable(cell.Table); + var rows = table.Children.ToList(); + rows.RemoveAt(cell.Row); + Root = MathTree.Replace(Root, cell.Table, table.WithChildren(rows)); + var row = Math.Min(cell.Row, rows.Count - 1); + SetCaret(MathTree.Append(cell.Table, row, Math.Min(cell.Column, rows[row].Children.Count - 1), 0), 0); + OnChanged(true); + return true; + } + + /// Deletes the column of the caret from its table; the table itself when it is its only column. + /// Whether the caret is in a table. + public bool DeleteTableColumn() + { + if (CellAtCaret() is not { } cell) + return false; + PushUndo(); + var table = MathTree.Get(Root, cell.Table); + if (table.Children.All(r => r.Children.Count <= 1)) + return RemoveTable(cell.Table); + var rows = table.Children.Select(row => + { + var cells = row.Children.ToList(); + if (cell.Column < cells.Count && cells.Count > 1) + cells.RemoveAt(cell.Column); + return row.WithChildren(cells); + }).ToList(); + Root = MathTree.Replace(Root, cell.Table, table.WithChildren(rows)); + SetCaret(MathTree.Append(cell.Table, cell.Row, Math.Min(cell.Column, rows[cell.Row].Children.Count - 1), 0), 0); + OnChanged(true); + return true; + } + + private bool RemoveTable(int[] tablePath) + { + var parent = MathTree.Parent(tablePath); + var row = MathTree.Get(Root, parent); + var children = row.Children.ToList(); + children.RemoveAt(tablePath[tablePath.Length - 1]); + ReplaceChildren(parent, children, tablePath[tablePath.Length - 1]); + OnChanged(true); + return true; + } + + /// The innermost table cell of the caret: the path of the table, and the row and the column of the cell. + private (int[] Table, int Row, int Column)? CellAtCaret() + { + var (current, _) = RowPosition(Canonical(Caret)); + while (MathTree.Owner(Root, current) is { } owner) + { + if (MathTree.Get(Root, owner.Item).Name == "mtable") + { + var slot = owner.Slots[owner.Index]; + return (owner.Item, slot[owner.Item.Length], slot[owner.Item.Length + 1]); + } + current = MathTree.Parent(owner.Item); + } + return null; + } + + /// The number of lines of the equation: those of its equation array, or 1. + public int LineCount => ArrayTable() is { } table ? table.Children.Count : 1; + + /// + /// Breaks the line at the caret, as Shift+Enter in Word: the equation becomes an equation array (the + /// █(...@...) of UnicodeMath, aligned of LaTeX), or gets a line more, with what is after the caret + /// on the new line. The lines line up at their first & (an alignment mark), and are centred without one. + /// + public void InsertLineBreak() + { + if (IsLinear) + return; + PushUndo(); + if (Selection != null) + RemoveSelection(); + + var (lineRow, index) = LineAtCaret(); + var row = MathTree.Get(Root, lineRow); + var before = row.Children.Take(index).ToList(); + var after = row.Children.Skip(index).ToList(); + if (ArrayTable() is { } table) + { + var line = lineRow[2]; + var lines = table.Children.ToList(); + lines[line] = Line(before); + lines.Insert(line + 1, Line(after)); + Root = MathTree.Replace(Root, ArrayPath, table.WithChildren(lines)); + SetCaret(LinePath(line + 1), 1); + } + else + { + var array = MathNode.Element("mtable", new[] { Line(before), Line(after) }, new[] { new KeyValuePair("columnspacing", "0pt") }); + Root = Root.WithChild(0, MathNode.Row(new[] { array })); + SetCaret(LinePath(1), 1); + } + OnChanged(true); + } + /// Deletes the selection; returns whether something was selected. public bool DeleteSelection() { @@ -522,6 +722,63 @@ private void InsertWithSlots(MathNode node) OnChanged(true); } + private static readonly int[] ArrayPath = { 0, 0 }; + + private static int[] LinePath(int line) => new[] { 0, 0, line, 0, 0 }; + + /// A line of an equation array: a row in a cell, which starts with maligngroup. + private static MathNode Line(IEnumerable nodes) + { + var children = nodes.ToList(); + if (children.Count == 0 || children[0].Name != "maligngroup") + children.Insert(0, MathNode.Element("maligngroup", Array.Empty())); + return MathNode.Element("mtr", new[] { MathNode.Element("mtd", new[] { MathNode.Row(children) }) }); + } + + /// The table of the equation when the equation is an equation array (its lines), or null. + private MathNode? ArrayTable() + { + if (RootRow.Children.Count != 1 || RootRow.Children[0] is not { Name: "mtable" } table || table.Children.Count == 0) + return null; + return table.Children.All(r => r.Name == "mtr" && r.Children.Count == 1 && r.Children[0].Children.Count == 1 + && r.Children[0].Children[0] is { IsRow: true } row && row.Children.Count > 0 && row.Children[0].Name == "maligngroup") + ? table + : null; + } + + /// The row of the line of the caret (the equation, or a line of its equation array) and the index in it where the caret is, or after the structure it is in. + private (int[] Row, int Index) LineAtCaret() + { + var (rowPath, index) = SplitRunAtCaret(); + var line = ArrayTable() != null && rowPath.Length >= 5 ? rowPath.Take(5).ToArray() : MathTree.RootRow; + if (rowPath.SequenceEqual(line)) + return (line, index); + return (line, rowPath[line.Length] + 1); + } + + /// Merges a line of the equation array with the next one; an array of one line becomes the equation. + private void MergeLines(int line) + { + var table = ArrayTable()!; + var lines = table.Children.ToList(); + var first = lines[line].Children[0].Children[0]; + var second = lines[line + 1].Children[0].Children[0]; + var junction = first.Children.Count; + var merged = first.Children.Concat(second.Children.Skip(1)).ToList(); + lines[line] = Line(merged); + lines.RemoveAt(line + 1); + if (lines.Count == 1) + { + Root = Root.WithChild(0, MathNode.Row(merged.Skip(1))); + SetCaret(MathTree.RootRow, junction - 1); + } + else + { + Root = MathTree.Replace(Root, ArrayPath, table.WithChildren(lines)); + SetCaret(LinePath(line), junction); + } + } + /// The labeled row of an equation that is one, or null. private MathNode? NumberedRow() { @@ -678,6 +935,29 @@ private void DeleteAt(int direction) var (rowPath, index) = RowPosition(caret); var row = MathTree.Get(Root, rowPath); + + // at the start (after its maligngroup) or the end of a line of an equation array: the lines merge + if (ArrayTable() is { } array && rowPath.Length == 5 && rowPath.Take(2).SequenceEqual(ArrayPath)) + { + var line = rowPath[2]; + if (direction < 0 && index <= 1 && line > 0) + { + PushUndo(); + MergeLines(line - 1); + OnChanged(true); + return; + } + if (direction > 0 && index == row.Children.Count && line < array.Children.Count - 1) + { + PushUndo(); + MergeLines(line); + OnChanged(true); + return; + } + if (direction < 0 && index <= 1) + return; + } + var target = direction < 0 ? index - 1 : index; if (target < 0 || target >= row.Children.Count) { @@ -939,11 +1219,14 @@ internal MathPosition Canonical(MathPosition position) if (!node.IsRow) return position; var i = position.Offset; + // never before the maligngroup that starts a line of an equation array + if (i == 0 && node.Children.Count > 0 && node.Children[0].Name == "maligngroup") + i = 1; if (i > 0 && node.Children[i - 1].IsRun) return new MathPosition(MathTree.Append(position.Path, i - 1), CodePointText.Length(node.Children[i - 1].Text)); if (i < node.Children.Count && node.Children[i].IsRun) return new MathPosition(MathTree.Append(position.Path, i), 0); - return position; + return i == position.Offset ? position : new MathPosition(position.Path, i); } /// The row of a position and the index of the child after it; inside a run, the index of the run. diff --git a/src/SharpEquations.Editing/PublicAPI.Unshipped.txt b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt index a5276aa..655c46f 100644 --- a/src/SharpEquations.Editing/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt @@ -1,9 +1,4 @@ #nullable enable -const SharpEquations.Editing.MathNode.RunName = "#run" -> string! -override SharpEquations.Editing.MathNode.ToString() -> string! -override SharpEquations.Editing.MathPosition.Equals(object? obj) -> bool -override SharpEquations.Editing.MathPosition.GetHashCode() -> int -override SharpEquations.Editing.MathPosition.ToString() -> string! SharpEquations.Editing.IMathGeometry SharpEquations.Editing.IMathGeometry.GetCaretX(SharpEquations.Editing.MathPosition! position) -> double? SharpEquations.Editing.IMathGeometry.GetNearestPosition(System.Collections.Generic.IReadOnlyList! rowPath, double x) -> SharpEquations.Editing.MathPosition? @@ -16,10 +11,15 @@ SharpEquations.Editing.MathEditor.CanRedo.get -> bool SharpEquations.Editing.MathEditor.CanUndo.get -> bool SharpEquations.Editing.MathEditor.Caret.get -> SharpEquations.Editing.MathPosition! SharpEquations.Editing.MathEditor.Changed -> System.EventHandler? +SharpEquations.Editing.MathEditor.CommandPrefix.get -> string? +SharpEquations.Editing.MathEditor.Complete(SharpEquations.EquationCommand! command) -> void SharpEquations.Editing.MathEditor.ContentChanged -> System.EventHandler? SharpEquations.Editing.MathEditor.Delete() -> void SharpEquations.Editing.MathEditor.DeleteSelection() -> bool +SharpEquations.Editing.MathEditor.DeleteTableColumn() -> bool +SharpEquations.Editing.MathEditor.DeleteTableRow() -> bool SharpEquations.Editing.MathEditor.EquationNumber.get -> string? +SharpEquations.Editing.MathEditor.GetCompletions(int max = 20) -> System.Collections.Generic.IReadOnlyList! SharpEquations.Editing.MathEditor.GetSelectedEquation() -> SharpEquations.Equation? SharpEquations.Editing.MathEditor.GetSelectedNodes() -> System.Collections.Generic.IReadOnlyList! SharpEquations.Editing.MathEditor.GetSelectedText(SharpEquations.EquationFormat format, SharpEquations.EquationOptions? options = null) -> string? @@ -28,14 +28,19 @@ SharpEquations.Editing.MathEditor.InputFormat.get -> SharpEquations.EquationForm SharpEquations.Editing.MathEditor.InputFormat.set -> void SharpEquations.Editing.MathEditor.Insert(string! text) -> void SharpEquations.Editing.MathEditor.InsertFences(string! open = "(", string! close = ")") -> void +SharpEquations.Editing.MathEditor.InsertLineBreak() -> void SharpEquations.Editing.MathEditor.InsertMatrix(int rows, int columns, string? open = null, string? close = null) -> void SharpEquations.Editing.MathEditor.InsertNodes(System.Collections.Generic.IEnumerable! nodes) -> void SharpEquations.Editing.MathEditor.InsertStructure(string! name, System.Collections.Generic.IReadOnlyList>? attributes = null) -> void +SharpEquations.Editing.MathEditor.InsertTableColumn(bool before = false) -> bool +SharpEquations.Editing.MathEditor.InsertTableRow(bool above = false) -> bool SharpEquations.Editing.MathEditor.InsertTemplate(SharpEquations.Editing.MathNode! node) -> void SharpEquations.Editing.MathEditor.IsDisplay.get -> bool SharpEquations.Editing.MathEditor.IsDisplay.set -> void SharpEquations.Editing.MathEditor.IsEmpty.get -> bool +SharpEquations.Editing.MathEditor.IsInTable.get -> bool SharpEquations.Editing.MathEditor.IsLinear.get -> bool +SharpEquations.Editing.MathEditor.LineCount.get -> int SharpEquations.Editing.MathEditor.Load(SharpEquations.Equation? equation) -> void SharpEquations.Editing.MathEditor.MathEditor(SharpEquations.Equation? equation = null) -> void SharpEquations.Editing.MathEditor.MoveDown(bool extend = false, SharpEquations.Editing.IMathGeometry? geometry = null) -> void @@ -86,6 +91,11 @@ SharpEquations.Editing.MathSelection.IsInRun.get -> bool SharpEquations.Editing.MathSelection.Path.get -> System.Collections.Generic.IReadOnlyList! SharpEquations.Editing.MathSelection.Start.get -> int SharpEquations.Editing.MathSelection.StartPosition.get -> SharpEquations.Editing.MathPosition! +const SharpEquations.Editing.MathNode.RunName = "#run" -> string! +override SharpEquations.Editing.MathNode.ToString() -> string! +override SharpEquations.Editing.MathPosition.Equals(object? obj) -> bool +override SharpEquations.Editing.MathPosition.GetHashCode() -> int +override SharpEquations.Editing.MathPosition.ToString() -> string! static SharpEquations.Editing.MathNode.Element(string! name, System.Collections.Generic.IEnumerable! children, System.Collections.Generic.IReadOnlyList>? attributes = null) -> SharpEquations.Editing.MathNode! static SharpEquations.Editing.MathNode.FromMathML(System.Xml.Linq.XElement! element) -> SharpEquations.Editing.MathNode! static SharpEquations.Editing.MathNode.Row(System.Collections.Generic.IEnumerable! children, System.Collections.Generic.IReadOnlyList>? attributes = null) -> SharpEquations.Editing.MathNode! diff --git a/src/SharpEquations.Rendering/IMathFont.cs b/src/SharpEquations.Rendering/IMathFont.cs index ba153d1..7e00c9e 100644 --- a/src/SharpEquations.Rendering/IMathFont.cs +++ b/src/SharpEquations.Rendering/IMathFont.cs @@ -48,6 +48,12 @@ public interface IMathFont /// The ink box of a glyph, in design units. GlyphBounds GetBounds(ushort glyph); + /// + /// The variant of a glyph drawn for a script level (1 for scripts, 2 for scripts of scripts): the ssty + /// feature of a math font (); the glyph itself without one. + /// + ushort GetScriptVariant(ushort glyph, int scriptLevel); + /// A font for a code point that the font has no glyph for, or null. IMathFont? GetFallback(int codePoint); } diff --git a/src/SharpEquations.Rendering/Layout/Engine.cs b/src/SharpEquations.Rendering/Layout/Engine.cs index e038bff..6e0e116 100644 --- a/src/SharpEquations.Rendering/Layout/Engine.cs +++ b/src/SharpEquations.Rendering/Layout/Engine.cs @@ -536,6 +536,8 @@ private Box LayoutText(string text, Style style, bool italic) f = font; glyph = 0; } + if (style.Level > 0) + glyph = f.GetScriptVariant(glyph, style.Level); var scale = style.Size / f.UnitsPerEm; var bounds = f.GetBounds(glyph); box.Glyphs.Add(new GlyphItem(f, glyph, x, 0, style.Size)); @@ -1267,32 +1269,66 @@ private static bool IsAccent(MathNode node, MathNode script, string attribute) private Box LayoutTable(MathNode table, int[]? path, Style style) { - var cellStyle = style.WithDisplay(table.GetAttribute("displaystyle") == "true"); + // an equation array (the lines of █(...@...) or of aligned, which start with maligngroup) keeps the style of + // the equation; any other table is in text style, as MathML Core says + var isArray = table.Children.Count > 0 && table.Children.All(r => r.Children.Where(c => c.Name == "mtd").All(StartsWithAlignGroup)); + var cellStyle = style.WithDisplay(table.GetAttribute("displaystyle") is { } displaystyle ? displaystyle == "true" : isArray && style.Display); + var marks = new List>(); var rows = new List<(List Cells, Box? Label, MathNode Row)>(); var columns = 0; for (var r = 0; r < table.Children.Count; r++) { var row = table.Children[r]; var cells = new List(); + var rowMarks = new List(); Box? label = null; for (var c = 0; c < row.Children.Count; c++) { var cell = row.Children[c]; - var cellBox = Layout(cell.Name == "mtd" && cell.Children.Count == 1 ? cell.Children[0] : cell, cell.Name == "mtd" && cell.Children.Count == 1 ? Sub(path, r, c, 0) : null, cellStyle, Stretch.None); + var content = cell.Name == "mtd" && cell.Children.Count == 1 ? cell.Children[0] : cell; + var cellBox = Layout(content, cell.Name == "mtd" && cell.Children.Count == 1 ? Sub(path, r, c, 0) : null, cellStyle, Stretch.None); if (row.Name == "mlabeledtr" && c == 0) + { label = cellBox; + } else + { cells.Add(cellBox); + rowMarks.Add(AlignMark(content, cellBox)); + } } columns = Math.Max(columns, cells.Count); rows.Add((cells, label, row)); + marks.Add(rowMarks); } + // the cells of a column with alignment marks (malignmark, the & of UnicodeMath and of aligned) line up at + // the first mark; the others by columnalign var widths = new double[columns]; - foreach (var (cells, _, _) in rows) + var markLefts = new double?[columns]; + for (var c = 0; c < columns; c++) { - for (var c = 0; c < cells.Count; c++) - widths[c] = Math.Max(widths[c], cells[c].Width); + double markLeft = 0, markRight = 0, other = 0; + var marked = false; + for (var r = 0; r < rows.Count; r++) + { + if (c >= rows[r].Cells.Count) + continue; + var cell = rows[r].Cells[c]; + if (marks[r][c] is { } mark) + { + marked = true; + markLeft = Math.Max(markLeft, mark); + markRight = Math.Max(markRight, cell.Width - mark); + } + else + { + other = Math.Max(other, cell.Width); + } + } + widths[c] = marked ? Math.Max(markLeft + markRight, other) : other; + if (marked) + markLefts[c] = markLeft; } var columnSpacing = Lists(table.GetAttribute("columnspacing"), "0.8em", style, columns - 1); @@ -1352,12 +1388,15 @@ private Box LayoutTable(MathNode table, int[]? path, Style style) { var cellNode = row.Children[c + cellOffset]; var align = cellNode.GetAttribute("columnalign") ?? Pick(rowAligns, c) ?? Pick(aligns, c) ?? "center"; - var cx = align switch - { - "left" => lefts[c], - "right" => lefts[c] + widths[c] - cells[c].Width, - _ => lefts[c] + (widths[c] - cells[c].Width) / 2, - }; + var rowIndex = placedRows.FindIndex(p => ReferenceEquals(p.Row.Row, row)); + var cx = markLefts[c] is { } columnMark && marks[rowIndex][c] is { } mark + ? lefts[c] + columnMark - mark + : align switch + { + "left" => lefts[c], + "right" => lefts[c] + widths[c] - cells[c].Width, + _ => lefts[c] + (widths[c] - cells[c].Width) / 2, + }; box.Add(cells[c], tableX + cx, baseline + shift); } if (label != null) @@ -1369,6 +1408,22 @@ private Box LayoutTable(MathNode table, int[]? path, Style style) return box; } + private static bool StartsWithAlignGroup(MathNode cell) => + cell.Children.Count == 1 && cell.Children[0] is { IsRow: true } row && row.Children.Count > 0 && row.Children[0].Name == "maligngroup"; + + /// The x of the first alignment mark of the content of a cell, from the caret positions of its row; null without one. + private static double? AlignMark(MathNode content, Box box) + { + if (!content.IsRow || box.CaretX == null) + return null; + for (var i = 0; i < content.Children.Count && i < box.CaretX.Length; i++) + { + if (content.Children[i].Name == "malignmark") + return box.CaretX[i]; + } + return null; + } + private static string? Pick(string[]? values, int index) => values == null || values.Length == 0 ? null : values[Math.Min(index, values.Length - 1)]; diff --git a/src/SharpEquations.Rendering/OpenType/FontFile.cs b/src/SharpEquations.Rendering/OpenType/FontFile.cs index 9b807a6..21b6be1 100644 --- a/src/SharpEquations.Rendering/OpenType/FontFile.cs +++ b/src/SharpEquations.Rendering/OpenType/FontFile.cs @@ -53,6 +53,124 @@ public static FontFile Parse(byte[] data, int glyphCount = -1) } } + /// The units of the design in an em, from the head table; 1000 without one. + public int UnitsPerEm => tables.TryGetValue("head", out var head) ? U16(data, head.Offset + 18) : 1000; + + /// The glyph of a code point, from the Unicode subtable of the cmap table (format 12 or 4). + public bool TryGetGlyph(int codePoint, out ushort glyph) + { + glyph = 0; + var cmap = Cmap(); + if (cmap < 0) + return false; + switch (U16(data, cmap)) + { + case 12: + var groups = (int)U32(data, cmap + 12); + int lo = 0, hi = groups - 1; + while (lo <= hi) + { + var mid = (lo + hi) / 2; + var g = cmap + 16 + mid * 12; + var start = U32(data, g); + var end = U32(data, g + 4); + if (codePoint < start) + { + hi = mid - 1; + } + else if (codePoint > end) + { + lo = mid + 1; + } + else + { + glyph = (ushort)(U32(data, g + 8) + (uint)codePoint - start); + return glyph != 0; + } + } + return false; + case 4 when codePoint <= 0xFFFF: + var segments = U16(data, cmap + 6) / 2; + var ends = cmap + 14; + var starts = ends + segments * 2 + 2; + var deltas = starts + segments * 2; + var offsets = deltas + segments * 2; + for (var i = 0; i < segments; i++) + { + if (codePoint > U16(data, ends + i * 2)) + continue; + var start = U16(data, starts + i * 2); + if (codePoint < start) + return false; + var delta = U16(data, deltas + i * 2); + var rangeOffset = U16(data, offsets + i * 2); + if (rangeOffset == 0) + { + glyph = (ushort)(codePoint + delta); + } + else + { + var at = offsets + i * 2 + rangeOffset + (codePoint - start) * 2; + var value = U16(data, at); + glyph = value == 0 ? (ushort)0 : (ushort)(value + delta); + } + return glyph != 0; + } + return false; + default: + return false; + } + } + + /// The advance of a glyph, in design units, from the hmtx table. + public int Advance(ushort glyph) + { + if (!tables.TryGetValue("hhea", out var hhea) || !tables.TryGetValue("hmtx", out var hmtx)) + return 0; + var metrics = U16(data, hhea.Offset + 34); + if (metrics == 0) + return 0; + var index = Math.Min(glyph, metrics - 1); + return U16(data, hmtx.Offset + index * 4); + } + + private int cmapAt = int.MinValue; + + /// The offset of the best Unicode subtable of the cmap table: full Unicode (format 12) rather than the basic plane (format 4). + private int Cmap() + { + if (cmapAt != int.MinValue) + return cmapAt; + cmapAt = -1; + if (!tables.TryGetValue("cmap", out var cmap)) + return cmapAt; + var count = U16(data, cmap.Offset + 2); + var best = -1; + var bestRank = 0; + for (var i = 0; i < count; i++) + { + var record = cmap.Offset + 4 + i * 8; + var platform = U16(data, record); + var encoding = U16(data, record + 2); + var at = cmap.Offset + (int)U32(data, record + 4); + var format = U16(data, at); + var rank = (platform, encoding, format) switch + { + (3, 10, 12) or (0, 4, 12) or (0, 6, 12) => 3, + (3, 1, 4) or (0, 3, 4) => 2, + (0, _, 4) => 1, + _ => 0, + }; + if (rank > bestRank) + { + best = at; + bestRank = rank; + } + } + cmapAt = best; + return cmapAt; + } + /// A copy of a table, or null if the font has none. public byte[]? Table(string tag) { diff --git a/src/SharpEquations.Rendering/OpenType/ScriptStyles.cs b/src/SharpEquations.Rendering/OpenType/ScriptStyles.cs new file mode 100644 index 0000000..adfe22a --- /dev/null +++ b/src/SharpEquations.Rendering/OpenType/ScriptStyles.cs @@ -0,0 +1,152 @@ +namespace SharpEquations.Rendering.OpenType +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// The script style variants of the glyphs of a math font: the ssty feature of its GSUB table, glyphs drawn + /// for the sizes of scripts (level 1) and of scripts of scripts (level 2), as MathML Core uses them. + /// + public sealed class ScriptStyles + { + private readonly Dictionary variants; + + private ScriptStyles(Dictionary variants) => this.variants = variants; + + /// The variants of a font from its GSUB table; null if it has none. + public static ScriptStyles? Parse(byte[]? gsub) + { + if (gsub == null || gsub.Length < 10) + return null; + try + { + var variants = new Dictionary(); + var featureList = U16(gsub, 6); + var lookupList = U16(gsub, 8); + var lookups = new SortedSet(); + var features = U16(gsub, featureList); + for (var i = 0; i < features; i++) + { + var record = featureList + 2 + i * 6; + if (Encoding.ASCII.GetString(gsub, record, 4) != "ssty") + continue; + var feature = featureList + U16(gsub, record + 4); + var count = U16(gsub, feature + 2); + for (var j = 0; j < count; j++) + lookups.Add(U16(gsub, feature + 4 + j * 2)); + } + + // the lookups of the feature in order: each gives the variant of one level more, or the variants of all + var level = 0; + foreach (var index in lookups) + { + var lookup = lookupList + U16(gsub, lookupList + 2 + index * 2); + var type = U16(gsub, lookup); + var subtables = U16(gsub, lookup + 4); + for (var k = 0; k < subtables; k++) + { + var subtable = lookup + U16(gsub, lookup + 6 + k * 2); + var subtableType = type; + if (type == 7) + { + subtableType = U16(gsub, subtable + 2); + subtable += (int)U32(gsub, subtable + 4); + } + Read(gsub, subtable, subtableType, level, variants); + } + level++; + } + return variants.Count == 0 ? null : new ScriptStyles(variants); + } + catch (IndexOutOfRangeException) + { + return null; + } + } + + /// The variant of a glyph for a script level (1 or 2, higher is 2); the glyph itself without one. + public ushort Variant(ushort glyph, int level) + { + if (level <= 0 || !variants.TryGetValue(glyph, out var list)) + return glyph; + var index = Math.Min(level, 2) - 1; + for (var i = Math.Min(index, list.Length - 1); i >= 0; i--) + { + if (list[i] != 0) + return list[i]; + } + return glyph; + } + + private static void Read(byte[] d, int at, int type, int level, Dictionary variants) + { + var format = U16(d, at); + var coverage = Coverage(d, at + U16(d, at + 2)); + void Set(ushort glyph, int lvl, ushort variant) + { + if (!variants.TryGetValue(glyph, out var list)) + variants[glyph] = list = new ushort[2]; + if (lvl < 2 && list[lvl] == 0) + list[lvl] = variant; + } + + switch (type) + { + case 1 when format == 1: + var delta = (short)U16(d, at + 4); + foreach (var glyph in coverage) + Set(glyph, level, (ushort)(glyph + delta)); + break; + case 1 when format == 2: + var count = U16(d, at + 4); + for (var i = 0; i < count && i < coverage.Count; i++) + Set(coverage[i], level, (ushort)U16(d, at + 6 + i * 2)); + break; + case 3: + // an alternate for each level, in order + var sets = U16(d, at + 4); + for (var i = 0; i < sets && i < coverage.Count; i++) + { + var set = at + U16(d, at + 6 + i * 2); + var alternates = U16(d, set); + for (var a = 0; a < alternates && a < 2; a++) + Set(coverage[i], a, (ushort)U16(d, set + 2 + a * 2)); + } + break; + } + } + + private static List Coverage(byte[] d, int at) + { + var glyphs = new List(); + switch (U16(d, at)) + { + case 1: + var count = U16(d, at + 2); + for (var i = 0; i < count; i++) + glyphs.Add((ushort)U16(d, at + 4 + i * 2)); + break; + case 2: + var ranges = U16(d, at + 2); + var byIndex = new SortedDictionary(); + for (var i = 0; i < ranges; i++) + { + var r = at + 4 + i * 6; + var start = U16(d, r); + var end = U16(d, r + 2); + var index = U16(d, r + 4); + for (var g = start; g <= end; g++) + byIndex[index + g - start] = (ushort)g; + } + glyphs.AddRange(byIndex.Values); + break; + } + return glyphs; + } + + private static int U16(byte[] d, int at) => d[at] << 8 | d[at + 1]; + + private static uint U32(byte[] d, int at) => (uint)(d[at] << 24 | d[at + 1] << 16 | d[at + 2] << 8 | d[at + 3]); + } +} diff --git a/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt index 7e3b486..e38832d 100644 --- a/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -override SharpEquations.Rendering.MathRect.ToString() -> string! SharpEquations.Rendering.GlyphBounds SharpEquations.Rendering.GlyphBounds.GlyphBounds() -> void SharpEquations.Rendering.GlyphBounds.GlyphBounds(double xMin, double yMin, double xMax, double yMax) -> void @@ -11,6 +10,7 @@ SharpEquations.Rendering.IMathFont SharpEquations.Rendering.IMathFont.GetAdvance(ushort glyph) -> double SharpEquations.Rendering.IMathFont.GetBounds(ushort glyph) -> SharpEquations.Rendering.GlyphBounds SharpEquations.Rendering.IMathFont.GetFallback(int codePoint) -> SharpEquations.Rendering.IMathFont? +SharpEquations.Rendering.IMathFont.GetScriptVariant(ushort glyph, int scriptLevel) -> ushort SharpEquations.Rendering.IMathFont.Math.get -> SharpEquations.Rendering.OpenType.MathTable? SharpEquations.Rendering.IMathFont.TryGetGlyph(int codePoint, out ushort glyph) -> bool SharpEquations.Rendering.IMathFont.UnitsPerEm.get -> double @@ -76,7 +76,10 @@ SharpEquations.Rendering.MathShapeRole.Error = 3 -> SharpEquations.Rendering.Mat SharpEquations.Rendering.MathShapeRole.Ink = 0 -> SharpEquations.Rendering.MathShapeRole SharpEquations.Rendering.MathShapeRole.Placeholder = 2 -> SharpEquations.Rendering.MathShapeRole SharpEquations.Rendering.OpenType.FontFile +SharpEquations.Rendering.OpenType.FontFile.Advance(ushort glyph) -> int SharpEquations.Rendering.OpenType.FontFile.Table(string! tag) -> byte[]? +SharpEquations.Rendering.OpenType.FontFile.TryGetGlyph(int codePoint, out ushort glyph) -> bool +SharpEquations.Rendering.OpenType.FontFile.UnitsPerEm.get -> int SharpEquations.Rendering.OpenType.GlyphConstruction SharpEquations.Rendering.OpenType.GlyphConstruction.Assembly.get -> System.Collections.Generic.IReadOnlyList? SharpEquations.Rendering.OpenType.GlyphConstruction.AssemblyItalicCorrection.get -> int @@ -133,10 +136,10 @@ SharpEquations.Rendering.OpenType.MathConstant.StretchStackBottomShiftDown = 29 SharpEquations.Rendering.OpenType.MathConstant.StretchStackGapAboveMin = 30 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.StretchStackGapBelowMin = 31 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.StretchStackTopShiftUp = 28 -> SharpEquations.Rendering.OpenType.MathConstant +SharpEquations.Rendering.OpenType.MathConstant.SubSuperscriptGapMin = 15 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SubscriptBaselineDropMin = 10 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SubscriptShiftDown = 8 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SubscriptTopMax = 9 -> SharpEquations.Rendering.OpenType.MathConstant -SharpEquations.Rendering.OpenType.MathConstant.SubSuperscriptGapMin = 15 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBaselineDropMax = 14 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBottomMaxWithSubscript = 16 -> SharpEquations.Rendering.OpenType.MathConstant SharpEquations.Rendering.OpenType.MathConstant.SuperscriptBottomMin = 13 -> SharpEquations.Rendering.OpenType.MathConstant @@ -152,13 +155,17 @@ SharpEquations.Rendering.OpenType.MathTable.Horizontal(ushort glyph) -> SharpEqu SharpEquations.Rendering.OpenType.MathTable.IsExtendedShape(ushort glyph) -> bool SharpEquations.Rendering.OpenType.MathTable.ItalicCorrection(ushort glyph) -> int SharpEquations.Rendering.OpenType.MathTable.MinConnectorOverlap.get -> int -SharpEquations.Rendering.OpenType.MathTable.this[SharpEquations.Rendering.OpenType.MathConstant constant].get -> int SharpEquations.Rendering.OpenType.MathTable.TopAccentAttachment(ushort glyph) -> int? SharpEquations.Rendering.OpenType.MathTable.Vertical(ushort glyph) -> SharpEquations.Rendering.OpenType.GlyphConstruction? +SharpEquations.Rendering.OpenType.MathTable.this[SharpEquations.Rendering.OpenType.MathConstant constant].get -> int +SharpEquations.Rendering.OpenType.ScriptStyles +SharpEquations.Rendering.OpenType.ScriptStyles.Variant(ushort glyph, int level) -> ushort +override SharpEquations.Rendering.MathRect.ToString() -> string! static SharpEquations.Rendering.MathLayoutEngine.Layout(SharpEquations.Editing.MathNode! root, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! static SharpEquations.Rendering.MathLayoutEngine.Layout(SharpEquations.Equation! equation, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! static SharpEquations.Rendering.MathLayoutEngine.Layout(System.Xml.Linq.XElement! math, SharpEquations.Rendering.IMathFont! font, SharpEquations.Rendering.MathLayoutOptions? options) -> SharpEquations.Rendering.MathLayout! static SharpEquations.Rendering.OpenType.FontFile.Parse(byte[]! data, int glyphCount = -1) -> SharpEquations.Rendering.OpenType.FontFile! static SharpEquations.Rendering.OpenType.GlyphOutlines.Create(System.Func! table) -> SharpEquations.Rendering.OpenType.GlyphOutlines? -static SharpEquations.Rendering.OpenType.MathTable.Parse(byte[]! data) -> SharpEquations.Rendering.OpenType.MathTable! static SharpEquations.Rendering.OpenType.MathTable.Parse(System.ReadOnlySpan data) -> SharpEquations.Rendering.OpenType.MathTable! +static SharpEquations.Rendering.OpenType.MathTable.Parse(byte[]! data) -> SharpEquations.Rendering.OpenType.MathTable! +static SharpEquations.Rendering.OpenType.ScriptStyles.Parse(byte[]? gsub) -> SharpEquations.Rendering.OpenType.ScriptStyles? diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs index 8cc2c6f..f0d9b51 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -68,6 +68,9 @@ internal static partial class LaTeXSymbols ["{"] = "{", ["}"] = "}", ["〈"] = "langle", ["〉"] = "rangle", }; + /// The symbols the writer writes as a command, and the command of each (without the backslash). + public static IEnumerable> SymbolCommands => Commands; + private static readonly Dictionary FontCommands = new Dictionary { ["mup"] = "mathrm", ["mbf"] = "mathbf", ["mbfit"] = "boldsymbol", ["mscr"] = "mathcal", ["mbfscr"] = "mathcal", ["mfrak"] = "mathfrak", diff --git a/src/SharpEquations/Public/EquationCommands.cs b/src/SharpEquations/Public/EquationCommands.cs new file mode 100644 index 0000000..f09389a --- /dev/null +++ b/src/SharpEquations/Public/EquationCommands.cs @@ -0,0 +1,135 @@ +#nullable enable + +namespace SharpEquations +{ + using System; + using System.Collections.Generic; + using System.Linq; + using SharpEquations.Internal.LaTeX; + using SharpEquations.Internal.Text; + using SharpEquations.Internal.UnicodeMath; + + /// + /// A command of UnicodeMath (a control word, such as \alpha, which Word's math autocorrect replaces) or of + /// LaTeX (\alpha, \frac), as an editor offers it to complete what is typed. + /// + public sealed class EquationCommand + { + internal EquationCommand(string name, string symbol, int arguments) + { + Name = name; + Symbol = symbol; + Arguments = arguments; + } + + /// The name of the command, without the backslash. + public string Name { get; } + + /// + /// The character the command stands for (α, ∑, the √ of \sqrt in UnicodeMath), or empty + /// for a command that has none (a structure or a style of LaTeX, \frac, \mathbf). + /// + public string Symbol { get; } + + /// The braced arguments the command takes in LaTeX (2 for \frac); 0 in UnicodeMath. + public int Arguments { get; } + + /// The text of the command as it is typed: the backslash, the name and its empty arguments (\frac{}{}). + public string Text => "\\" + Name + string.Concat(Enumerable.Repeat("{}", Arguments)); + + /// + public override string ToString() => Symbol.Length > 0 ? Text + " " + Symbol : Text; + } + + /// + /// The commands an equation is typed with: the control words of UnicodeMath, and the commands of LaTeX that + /// reads. + /// + public static class EquationCommands + { + // the commands of LaTeX with arguments, and the styles, that the LaTeX reader reads + private static readonly (string Name, int Arguments)[] LaTeXStructures = + { + ("frac", 2), ("dfrac", 2), ("tfrac", 2), ("cfrac", 2), ("binom", 2), ("dbinom", 2), ("tbinom", 2), ("sqrt", 1), + ("overset", 2), ("underset", 2), ("stackrel", 2), ("hat", 1), ("widehat", 1), ("check", 1), ("tilde", 1), + ("widetilde", 1), ("acute", 1), ("grave", 1), ("dot", 1), ("ddot", 1), ("breve", 1), ("bar", 1), ("vec", 1), + ("overline", 1), ("underline", 1), ("overbrace", 1), ("underbrace", 1), ("overrightarrow", 1), ("overleftarrow", 1), + ("mathring", 1), ("boxed", 1), ("text", 1), ("textrm", 1), ("textbf", 1), ("textit", 1), ("mathrm", 1), ("mathbf", 1), + ("mathit", 1), ("mathcal", 1), ("mathbb", 1), ("mathfrak", 1), ("mathsf", 1), ("mathtt", 1), + ("boldsymbol", 1), ("operatorname", 1), ("tag", 1), ("phantom", 1), ("left", 0), ("right", 0), ("middle", 0), + ("lim", 0), ("max", 0), ("min", 0), ("sup", 0), ("inf", 0), ("det", 0), ("gcd", 0), + ("sin", 0), ("cos", 0), ("tan", 0), ("cot", 0), ("sec", 0), ("csc", 0), ("arcsin", 0), ("arccos", 0), ("arctan", 0), + ("sinh", 0), ("cosh", 0), ("tanh", 0), ("coth", 0), ("log", 0), ("ln", 0), ("exp", 0), ("arg", 0), + ("deg", 0), ("dim", 0), ("ker", 0), ("hom", 0), ("Pr", 0), ("quad", 0), ("qquad", 0), + }; + + private static readonly Lazy> UnicodeMath = new Lazy>(() => + ControlWords.Table + .Where(w => w.Key.All(char.IsLetterOrDigit)) + .Select(w => new EquationCommand(w.Key, w.Value, 0)) + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ToList()); + + private static readonly Lazy> LaTeX = new Lazy>(() => + { + var commands = new Dictionary(StringComparer.Ordinal); + // the symbols LaTeX and UnicodeMath name alike, then those of the writer, then the structures + foreach (var word in ControlWords.Table.Where(w => w.Key.All(char.IsLetter) && IsLaTeXSymbol(w.Value))) + commands[word.Key] = new EquationCommand(word.Key, word.Value, 0); + foreach (var symbol in LaTeXSymbols.SymbolCommands.Where(s => s.Value.All(char.IsLetter))) + { + if (!commands.ContainsKey(symbol.Value)) + commands[symbol.Value] = new EquationCommand(symbol.Value, symbol.Key, 0); + } + foreach (var (name, arguments) in LaTeXStructures) + commands[name] = new EquationCommand(name, string.Empty, arguments); + return commands.Values.OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + }); + + /// + /// A symbol that a control word of UnicodeMath stands for in LaTeX too: not an operator of UnicodeMath alone (the + /// boxes, enclosed numbers and brackets that build its structures), a combining accent or a lone bracket. + /// + private static bool IsLaTeXSymbol(string symbol) + { + if (!CodePoints.IsSingle(symbol, out var rune)) + return false; + return rune.Value switch + { + >= 0x0300 and <= 0x036F or >= 0x20D0 and <= 0x20FF => false, + >= 0x2460 and <= 0x24FF or >= 0x2500 and <= 0x25FF or >= 0x2600 and <= 0x27BF => false, + '(' or ')' or '[' or ']' or '{' or '}' or '|' or 0x2016 or 0x2223 or 0x27E8 or 0x27E9 or 0x2308 or 0x2309 or 0x230A or 0x230B + or 0x3016 or 0x3017 => false, + _ => true, + }; + } + + /// The commands of a format, by name: UnicodeMath or LaTeX. + /// Another format, which has no commands. + public static IReadOnlyList Get(EquationFormat format) => format switch + { + EquationFormat.UnicodeMath => UnicodeMath.Value, + EquationFormat.LaTeX => LaTeX.Value, + _ => throw new ArgumentException("Only UnicodeMath and LaTeX have commands.", nameof(format)), + }; + + /// + /// The commands whose name starts with a prefix (without the backslash; ordinal, then case-insensitive), the + /// exact name first and then the shortest: the completions of \al are \aleph, \alpha... + /// + /// A format other than UnicodeMath or LaTeX. + public static IReadOnlyList Complete(string prefix, EquationFormat format, int max = 20) + { + if (prefix == null) + throw new ArgumentNullException(nameof(prefix)); + prefix = prefix.TrimStart('\\'); + return Get(format) + .Where(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .OrderBy(c => c.Name == prefix ? 0 : c.Name.StartsWith(prefix, StringComparison.Ordinal) ? 1 : 2) + .ThenBy(c => c.Name.Length) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .Take(max) + .ToList(); + } + } +} diff --git a/src/SharpEquations/PublicAPI.Unshipped.txt b/src/SharpEquations/PublicAPI.Unshipped.txt index 7dc5c58..c5ea12e 100644 --- a/src/SharpEquations/PublicAPI.Unshipped.txt +++ b/src/SharpEquations/PublicAPI.Unshipped.txt @@ -1 +1,10 @@ #nullable enable +SharpEquations.EquationCommand +SharpEquations.EquationCommand.Arguments.get -> int +SharpEquations.EquationCommand.Name.get -> string! +SharpEquations.EquationCommand.Symbol.get -> string! +SharpEquations.EquationCommand.Text.get -> string! +SharpEquations.EquationCommands +override SharpEquations.EquationCommand.ToString() -> string! +static SharpEquations.EquationCommands.Complete(string! prefix, SharpEquations.EquationFormat format, int max = 20) -> System.Collections.Generic.IReadOnlyList! +static SharpEquations.EquationCommands.Get(SharpEquations.EquationFormat format) -> System.Collections.Generic.IReadOnlyList! diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs new file mode 100644 index 0000000..293ab5d --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs @@ -0,0 +1,142 @@ +namespace SharpEquations.Avalonia.Tests.Controls +{ + using System; + using System.IO; + using System.Threading.Tasks; + using global::Avalonia.Controls; + using global::Avalonia.Headless; + using global::Avalonia.Input; + using global::Avalonia.Media; + using global::Avalonia.Threading; + using SharpEquations; + using SharpEquations.Editing; + + /// The floating bar at the caret: completions, structures for the selection, table commands, symbols. + [NotInParallel("headless")] + public class QuickBarTests + { + private static T WithEditor(Func test, string? picture = null) + { + var editor = new MathEditBox { FontSize = 28, Foreground = Brushes.Black }; + var window = new Window { Content = new StackPanel { Children = { editor } }, Width = 700, Height = 360, Background = Brushes.White }; + window.Show(); + editor.Focus(); + try + { + var result = test(window, editor); + if (picture != null && Environment.GetEnvironmentVariable("SHARPEQUATIONS_SCREENSHOTS") is { } directory && window.CaptureRenderedFrame() is { } frame) + { + Directory.CreateDirectory(directory); + frame.Save(Path.Combine(directory, picture), global::Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); + } + return result; + } + finally + { + window.Close(); + } + } + + private static void Settle(Window window) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + } + + [Test] + public async Task CompletionsOfACommand() + { + var (mode, count, equation) = await Headless.Run(() => WithEditor((window, editor) => + { + window.KeyTextInput("x+\\al"); + Settle(window); + var shown = (editor.QuickBar.Mode, editor.QuickBar.Completions.Count); + var index = 0; + for (var i = 0; i < editor.QuickBar.Completions.Count; i++) + { + if (editor.QuickBar.Completions[i].Name == "alpha") + index = i; + } + for (var i = 0; i < index; i++) + window.KeyPress(Key.Down, RawInputModifiers.None, PhysicalKey.ArrowDown, null); + Settle(window); + window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + window.KeyTextInput(" "); + Settle(window); + return (shown.Mode, shown.Count, editor.Equation); + }, "quickbar-completions.png")); + await Assert.That(mode).IsEqualTo(MathQuickBarMode.Completions); + await Assert.That(count).IsGreaterThan(1); + await Assert.That(equation).IsEqualTo(Equation.Parse("x+α", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + } + + [Test] + public async Task EscapeClosesTheCompletions() + { + var (open, closed) = await Headless.Run(() => WithEditor((window, editor) => + { + window.KeyTextInput("\\su"); + Settle(window); + var wasOpen = editor.IsQuickBarOpen; + window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); + Settle(window); + return (wasOpen, !editor.IsQuickBarOpen); + })); + await Assert.That(open).IsTrue(); + await Assert.That(closed).IsTrue(); + } + + [Test] + public async Task StructuresForTheSelectionAndTableCommands() + { + var (selection, table, symbols) = await Headless.Run(() => WithEditor((window, editor) => + { + editor.Equation = Equation.Parse("a+b", EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + editor.Editor.SelectAll(); + Settle(window); + var onSelection = editor.QuickBar.Mode; + editor.Editor.MoveEnd(); + editor.Editor.InsertMatrix(2, 2, "(", ")"); + Settle(window); + var inTable = editor.QuickBar.Mode; + editor.Editor.Undo(); + editor.Editor.InsertStructure("mfrac"); + Settle(window); + return (onSelection, inTable, editor.QuickBar.Mode); + }, "quickbar-slot.png")); + await Assert.That(selection).IsEqualTo(MathQuickBarMode.Selection); + await Assert.That(table).IsEqualTo(MathQuickBarMode.Table); + await Assert.That(symbols).IsEqualTo(MathQuickBarMode.Symbols); + } + + [Test] + public async Task PictureOfTheCompletions() + { + var open = await Headless.Run(() => WithEditor((window, editor) => + { + editor.Equation = Equation.Parse("a+b", EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + editor.Editor.MoveEnd(); + editor.Editor.InsertStructure("mfrac"); + window.KeyTextInput("\\sq"); + Settle(window); + Settle(window); + return editor.IsQuickBarOpen; + }, "quickbar-open.png")); + await Assert.That(open).IsTrue(); + } + + [Test] + public async Task DisabledBarStaysClosed() + { + var open = await Headless.Run(() => WithEditor((window, editor) => + { + editor.IsQuickBarEnabled = false; + window.KeyTextInput("\\al"); + Settle(window); + return editor.IsQuickBarOpen; + })); + await Assert.That(open).IsFalse(); + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs index 2ccd5d3..354b94a 100644 --- a/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs @@ -28,6 +28,8 @@ public class ScreenshotTests "L:\\sqrt[3]{x^3+y^3}+\\overbrace{a+b+c}^{n}", "L:\\binom{n}{k}=\\frac{n!}{k!(n-k)!}", "|x|+‖y‖+⌊z⌋+⟨ψ|φ⟩", + "█(f(x)&=(x+1)^2@&=x^2+2x+1)", + "E=mc^2#(1)", }; [Test] diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs index a6617fc..ad07a88 100644 --- a/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs @@ -166,5 +166,96 @@ public async Task EquationNumberTyped() latex.Insert("E=mc^2\\tag{2} "); await Assert.That(latex.EquationNumber).IsEqualTo("(2)"); } + + private static Equation D(string unicodeMath) => Equation.Parse(unicodeMath, EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + + [Test] + public async Task LinesOfAnEquationArray() + { + var editor = new MathEditor { IsDisplay = true }; + editor.Insert("a&=b+c"); + editor.InsertLineBreak(); + await Assert.That(editor.LineCount).IsEqualTo(2); + editor.Insert("&=d "); + await Assert.That(editor.ToEquation()).IsEqualTo(D("█(a&=b+c@&=d)")); + editor.InsertLineBreak(); + editor.Insert("&=e "); + await Assert.That(editor.ToEquation()).IsEqualTo(D("█(a&=b+c@&=d@&=e)")); + + // Backspace at the start of a line joins it to the line before + editor.MoveHome(); + editor.Backspace(); + await Assert.That(editor.LineCount).IsEqualTo(2); + editor.Undo(); + await Assert.That(editor.LineCount).IsEqualTo(3); + } + + [Test] + public async Task LineBreakInTheMiddle() + { + var editor = new MathEditor(D("x+y=1")); + editor.SetCaret(new MathPosition(new[] { 0 }, 3)); + editor.InsertLineBreak(); + await Assert.That(editor.ToEquation()).IsEqualTo(D("█(x+y@=1)")); + editor.MoveUp(); + editor.Delete(); + await Assert.That(editor.LineCount).IsEqualTo(1); + await Assert.That(editor.ToEquation()).IsEqualTo(D("x+y=1")); + } + + [Test] + public async Task LinesFromLaTeX() + { + var editor = new MathEditor(Equation.Parse("\\begin{aligned}a&=b\\\\&=c\\end{aligned}", EquationFormat.LaTeX, new EquationOptions { Display = true })); + await Assert.That(editor.LineCount).IsEqualTo(2); + } + + [Test] + public async Task CompletionOfCommands() + { + var editor = new MathEditor(); + editor.Insert("x+\\alp"); + await Assert.That(editor.CommandPrefix).IsEqualTo("\\alp"); + var alpha = editor.GetCompletions().First(c => c.Name == "alpha"); + editor.Complete(alpha); + editor.Insert(" "); + await Assert.That(editor.ToEquation()).IsEqualTo(U("x+α")); + + var latex = new MathEditor { InputFormat = EquationFormat.LaTeX }; + latex.Insert("\\fra"); + latex.Complete(latex.GetCompletions().First(c => c.Name == "frac")); + latex.Insert("1"); + latex.MoveRight(); + latex.MoveRight(); + latex.Insert("2"); + latex.BuildUpAll(); + await Assert.That(latex.ToEquation()).IsEqualTo(U("1/2")); + } + + [Test] + public async Task RowsAndColumnsOfAMatrix() + { + var editor = new MathEditor(U("(■(a&b@c&d))")); + await Assert.That(editor.IsInTable).IsFalse(); + editor.SetCaret(new MathPosition(new[] { 0, 0, 1, 0, 0, 0 }, 1)); + await Assert.That(editor.IsInTable).IsTrue(); + editor.InsertTableRow(); + editor.Insert("e"); + editor.MoveToNextCell(); + editor.Insert("f "); + await Assert.That(editor.ToEquation()).IsEqualTo(U("(■(a&b@e&f@c&d))")); + editor.InsertTableColumn(); + editor.Insert("g "); + // an empty cell, which UnicodeMath reads as an empty operator + await Assert.That(U(editor.GetText(EquationFormat.UnicodeMath))).IsEqualTo(U("(■(a&b&@e&f&g@c&d&))")); + editor.DeleteTableColumn(); + editor.DeleteTableRow(); + await Assert.That(editor.ToEquation()).IsEqualTo(U("(■(a&b@c&d))")); + editor.DeleteTableRow(); + editor.DeleteTableRow(); + // the last row deleted, the table goes, and the brackets stay + await Assert.That(editor.Root.Children[0].Children[0].Children.Select(c => c.Text)).IsEquivalentTo(new[] { "(", ")" }); + await Assert.That(editor.InsertTableRow()).IsFalse(); + } } } diff --git a/tests/SharpEquations.Avalonia.Tests/Headless.cs b/tests/SharpEquations.Avalonia.Tests/Headless.cs index a8b67d6..d7fe65f 100644 --- a/tests/SharpEquations.Avalonia.Tests/Headless.cs +++ b/tests/SharpEquations.Avalonia.Tests/Headless.cs @@ -12,7 +12,7 @@ public class TestApp : Application { public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UseSkia() - .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false, OverlayPopups = true }); public override void Initialize() => Styles.Add(new FluentTheme()); } diff --git a/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs b/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs index 05ebd4a..53fe765 100644 --- a/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Rendering/MathTableTests.cs @@ -2,6 +2,7 @@ namespace SharpEquations.Avalonia.Tests.Rendering { using System; using System.IO; + using System.Linq; using System.Text; using System.Threading.Tasks; using SharpEquations.Rendering.OpenType; @@ -37,6 +38,18 @@ public async Task Constants() await Assert.That(math.MinConnectorOverlap).IsGreaterThan(0); } + /// The ssty variants: Libertinus Math has them for the primes, which scripts use. + [Test] + public async Task ScriptStyleVariants() + { + var styles = ScriptStyles.Parse(Table("GSUB")); + await Assert.That(styles).IsNotNull(); + var varied = Enumerable.Range(1, 5000).Count(g => styles!.Variant((ushort)g, 1) != g); + await Assert.That(varied).IsGreaterThan(0); + await Assert.That(Enumerable.Range(1, 5000).All(g => styles!.Variant((ushort)g, 0) == g)).IsTrue(); + await Assert.That(ScriptStyles.Parse(null)).IsNull(); + } + [Test] public async Task NotAMathTable() { diff --git a/tests/SharpEquations.Tests/PublicApi/EquationCommandsTests.cs b/tests/SharpEquations.Tests/PublicApi/EquationCommandsTests.cs new file mode 100644 index 0000000..76431e2 --- /dev/null +++ b/tests/SharpEquations.Tests/PublicApi/EquationCommandsTests.cs @@ -0,0 +1,67 @@ +#nullable enable + +namespace SharpEquations.Tests.PublicApi +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + + /// The commands an editor offers to complete what is typed, which the parsers read. + public class EquationCommandsTests + { + [Test] + public async Task ListsAreLarge() + { + await Assert.That(EquationCommands.Get(EquationFormat.UnicodeMath).Count).IsGreaterThan(500); + await Assert.That(EquationCommands.Get(EquationFormat.LaTeX).Count).IsGreaterThan(300); + await Assert.That(() => EquationCommands.Get(EquationFormat.MathML)).Throws(); + } + + [Test] + public async Task Completion() + { + var names = EquationCommands.Complete("\\al", EquationFormat.UnicodeMath).Select(c => c.Name).ToList(); + await Assert.That(names).Contains("alpha"); + await Assert.That(names).Contains("aleph"); + await Assert.That(EquationCommands.Complete("alpha", EquationFormat.LaTeX)[0].Symbol).IsEqualTo("α"); + var frac = EquationCommands.Complete("frac", EquationFormat.LaTeX)[0]; + await Assert.That(frac.Text).IsEqualTo("\\frac{}{}"); + await Assert.That(EquationCommands.Complete("sum", EquationFormat.UnicodeMath)[0].Symbol).IsEqualTo("∑"); + } + + /// The structures of LaTeX are read as commands, not left as their text. + [Test] + public async Task LaTeXStructuresAreRead() + { + var failures = new List(); + foreach (var command in EquationCommands.Get(EquationFormat.LaTeX).Where(c => c.Symbol.Length == 0 && c.Name is not ("left" or "right" or "middle"))) + { + var text = "\\" + command.Name + (command.Arguments == 0 ? " x" : string.Concat(Enumerable.Repeat("{x}", command.Arguments))); + if (!Equation.TryParse(text, EquationFormat.LaTeX, out var equation) || equation!.ToString().Contains("\\" + command.Name, StringComparison.Ordinal)) + failures.Add(text); + } + await Assert.That(failures).IsEmpty(); + } + + /// + /// A command with a symbol reads as its symbol, as Word's math autocorrect replaces it: but for a few, which stand + /// for more than a character in UnicodeMath (the examples \\quadratic or \\Fourier, the functions \\det or \\arg) + /// or that LaTeX reads as another symbol. + /// + [Test] + [Arguments(EquationFormat.UnicodeMath, 97)] + [Arguments(EquationFormat.LaTeX, 95)] + public async Task CommandsAreTheirSymbols(EquationFormat format, int percent) + { + var commands = EquationCommands.Get(format).Where(c => c.Symbol.Length > 0).ToList(); + var same = commands.Count(c => + { + var read = Equation.TryParse("a" + c.Text + " b", format, out var typed); + return read == Equation.TryParse("a" + c.Symbol + "b", EquationFormat.UnicodeMath, out var symbol) && typed == symbol; + }); + await Assert.That(same * 100).IsGreaterThanOrEqualTo(commands.Count * percent); + } + } +} From e7f27c9d178dc9ad97c07cec3a534cd5000803e4 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Mon, 9 Nov 2026 19:12:30 +0100 Subject: [PATCH 28/45] LaTeX reader: the space commands, \ outside environments, \middle beside empty sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - \hspace, \hskip, \kern, \mkern, \mskip and \mspace read with their length in any form (1em, {10ex}, -1.5pt, 1em plus 1fil) as the UnicodeMath space nearest to it; \thinspace, \negthinspace, \negmedspace, \negthickspace, \nobreakspace and \space as the spaces they are - \ outside an environment, a line break an equation does not keep, left out - two bars with only zero spaces between them (|\!|) are no pair, which UnicodeMath would read as a double bar; the writer parts a bar alone from the next bar with \! - \middle with an empty side holds the placeholder there (├{⬚│⬚┤}) - the UnicodeMath grammar takes a no-break space after a bracket ((a)~b), reserved only before a diacritic - latex2mathml cases spaces and middle read; the corpus test compares the separator │ as | Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 229 +++++++++++++++--- .../Internal/LaTeX/LaTeXSerializer.cs | 5 + .../UnicodeMath/UnicodeMathGrammar.parlot.cs | 2 +- .../LaTeX/LaTeXReaderTests.cs | 15 +- .../LaTeX/Latex2MathMLCorpusTests.cs | 11 +- 5 files changed, 225 insertions(+), 37 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 50174f4..987acb6 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -484,6 +484,15 @@ private static string RewriteTeXOnly(string latex) i += 2; continue; } + // \\ outside an environment, a line break, which an equation does not keep: \!, no space + if (tex[i + 1] == "\\" && !InEnvironment(tex, i)) + { + result.Append("\\!"); + for (i += 2; i < tex.Length && tex[i] == " "; i++) + { + } + continue; + } // \_ held, which the passes would read as a subscript of what follows (\_\{\}) if (tex[i + 1] == "_") { @@ -507,36 +516,30 @@ private static string RewriteTeXOnly(string latex) k++; while (result.Length > 0 && result[result.Length - 1] == ' ') result.Length--; - if (tex[k] == "\\" && tex[k + 1] == "|") - { - result.Append("\\‖"); - for (i = k + 2; i < tex.Length && tex[i] == " "; i++) - { - } - continue; - } - if (tex[k] == "|") - { - result.Append('│'); - i = k + 1; - continue; - } - if (tex.Sub(k, k + 5) == "\\vert") - { - result.Append('│'); - i = k + 5; - continue; - } // another delimiter in the middle is the delimiter itself, a slash the slash of UnicodeMath - if (tex[k] == "/") - { - result.Append("\\/"); - for (i = k + 1; i < tex.Length && tex[i] == " "; i++) - { - } + var (middle, afterMiddle) = tex[k] == "\\" && tex[k + 1] == "|" ? ("\\‖", k + 2) + : tex[k] == "|" ? ("│", k + 1) + : tex.Sub(k, k + 5) == "\\vert" && !IsAsciiAlphabetic(tex[k + 5]) ? ("│", k + 5) + : tex[k] == "/" ? ("\\/", k + 1) + : (null, k); + i = afterMiddle; + if (middle == null) continue; + if (middle != "│") + { + while (i < tex.Length && tex[i] == " ") + i++; } - i = k; + // UnicodeMath wants something on each side of the bar (├{│┤} is no UnicodeMath): an empty side holds + // the placeholder, which the writer writes as {} + if (EndsWithOpening(result)) + result.Append('⬚'); + result.Append(middle); + int afterBlanks = i; + while (tex[afterBlanks] == " ") + afterBlanks++; + if (tex[afterBlanks] == "\\" && ControlWord(tex, afterBlanks + 1) is "right" or "middle") + result.Append('⬚'); continue; } if (cw == "vert" && angles > 0) @@ -615,9 +618,20 @@ private static string RewriteTeXOnly(string latex) i = afterModulus; continue; } - if (cw == "hspace" && BracedArgument(tex, tex[j] == "*" ? j + 1 : j) is var (_, afterSpace) && afterSpace >= 0) + if (SpaceCommands.TryGetValue(cw, out var spaceCommand)) { - result.Append("\\quad "); + result.Append(spaceCommand); + i = j; + while (i < tex.Length && tex[i] == " ") + i++; + continue; + } + if (LengthCommands.Contains(cw)) + { + // \hspace{10ex}, \kern-1.5pt, \mkern10mu: the space of UnicodeMath nearest to the length, an em one when + // the length is not a number with a unit + var (length, afterSpace) = LengthArgument(tex, cw == "hspace" && tex[j] == "*" ? j + 1 : j); + result.Append(Space(length ?? 18)); i = afterSpace; continue; } @@ -900,13 +914,37 @@ private static string EscapeOddBars(string latex) frames.Push(new List()); var escaped = new List(); var kets = new HashSet(); + bool ZeroSpacesBetween(int first, int second) + { + bool zero = false; + for (int k = first + 1; k < second; k++) + { + if (tex[k] == "\\" && tex[k + 1] == "!") + { + zero = true; + k++; + } + else if (tex[k] != " ") + return false; + } + return zero; + } void Close(List bars, bool inBrackets) { foreach (var bar in new[] { "|", "‖", "∣" }) { var some = bars.Where(b => tex[b] == bar).ToList(); - if (some.Count % 2 == 1 && !(inBrackets && some.Count == 1 && bar != "‖")) - escaped.Add(some[some.Count - 1]); + if (inBrackets && some.Count == 1 && bar != "‖") + continue; + // bars in pairs, but two with only zero spaces between them (|\!|, which UnicodeMath would read as + // ||, one double bar): the first is then a bar alone + for (int k = 0; k < some.Count; k++) + { + if (k + 1 < some.Count && !ZeroSpacesBetween(some[k], some[k + 1])) + k++; + else + escaped.Add(some[k]); + } } } for (int i = 0; i < tex.Length; i++) @@ -1746,5 +1784,134 @@ private static string NonBlank(Text tex, int i) i++; return tex[i]; } + + /// Whether the index is between a \begin and its \end, where \\ parts rows. + private static bool InEnvironment(Text tex, int i) + { + int depth = 0; + for (int k = 0; k < i; k++) + { + if (tex[k] == "\\") + { + var cw = ControlWord(tex, k + 1); + depth += cw switch { "begin" => 1, "end" => -1, _ => 0 }; + k += Math.Max(1, cw.Length); + } + } + return depth > 0; + } + + /// Whether the result ends with \left and its delimiter, with nothing after them. + private static bool EndsWithOpening(StringBuilder result) + { + var text = result.ToString(); + var at = text.LastIndexOf("\\left", StringComparison.Ordinal); + if (at < 0) + return false; + var delimiter = new Text(text.Substring(at + 5).Trim()); + return delimiter.Length <= 1 || delimiter[0] == "\\" && (delimiter.Length == 2 || ControlWord(delimiter, 1).Length == delimiter.Length - 1); + } + + // the commands of a space whose length follows them + private static readonly HashSet LengthCommands = new HashSet(StringComparer.Ordinal) + { + "hspace", "hskip", "kern", "mkern", "mskip", "mspace", + }; + + // the spaces of plain TeX and amsmath that UnicodeMath has no control word for; the negative ones none, as \! + private static readonly Dictionary SpaceCommands = new Dictionary(StringComparer.Ordinal) + { + ["thinspace"] = "\\,", ["negthinspace"] = "\\!", ["negmedspace"] = "\\!", ["negthickspace"] = "\\!", ["nobreakspace"] = "~", + ["space"] = "\\ ", + }; + + /// The space of UnicodeMath nearest to a length in mu (18 to the em), \! (none) for a negative one. + private static string Space(double mu) => mu switch + { + <= 0 => "\\!", + < 3.5 => "\\,", + < 4.5 => "\\:", + < 7 => "\\;", + < 13.5 => "\\enspace ", + < 27 => "\\quad ", + _ => "\\qquad ", + }; + + /// + /// The length after a space command, in mu, braced ({10ex}) or not (-1.5pt, 1em plus 2em), and the index after + /// it; null if it is no number with a unit (\hspace{\fill}). + /// + private static (double? Mu, int Next) LengthArgument(Text tex, int i) + { + while (tex[i] == " ") + i++; + if (tex[i] == "{") + { + var (braced, afterBraced) = BracedArgument(tex, i); + if (afterBraced < 0) + return (null, i); + var inner = new Text(braced); + var (mu, afterLength) = Dimension(inner, 0); + return (NonBlank(inner, afterLength) == null ? mu : null, afterBraced); + } + var (length, next) = Dimension(tex, i); + if (length == null) + return (null, i); + // the stretch and the shrink of glue, \hskip 1em plus 1fil minus 2pt + while (true) + { + int word = next; + while (tex[word] == " ") + word++; + var glue = tex.Sub(word, word + 4) == "plus" ? 4 : tex.Sub(word, word + 5) == "minus" ? 5 : 0; + if (glue == 0 || Dimension(tex, word + glue) is not (not null, var afterGlue)) + return (length, next); + next = afterGlue; + } + } + + /// A number with its unit (-1.5pt, 10 mu, 1fil) in mu, and the index after it; null if none. + private static (double? Mu, int Next) Dimension(Text tex, int i) + { + while (tex[i] == " ") + i++; + int start = i; + if (tex[i] is "-" or "+") + i++; + int digits = i; + while (tex[i] is { Length: 1 } d && (d[0] is >= '0' and <= '9' or '.')) + i++; + if (i == digits || !double.TryParse(tex.Sub(start, i), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var number)) + return (null, start); + while (tex[i] == " ") + i++; + // a unit is two letters (1ptx is 1pt and x), but for the infinite ones of glue (fil, fill, filll) + int unit = i; + i += 2; + if (tex.Sub(unit, i) == "fi") + { + while (i < unit + 5 && tex[i] == "l") + i++; + } + double? mu = tex.Sub(unit, i) switch + { + "em" => 18, + "ex" => 7.75, + "mu" => 1, + "pt" => 1.8, + "bp" => 1.81, + "dd" => 1.93, + "px" => 1.35, + "mm" => 5.12, + "pc" => 21.6, + "cc" => 23.1, + "cm" => 51.2, + "in" => 130.1, + "sp" => 1.8 / 65536, + "fil" or "fill" or "filll" => 0, + _ => null, + }; + return mu == null ? (null, start) : (number * mu, i); + } } } diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs index d01c194..41b1b11 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSerializer.cs @@ -568,6 +568,11 @@ private string TeX(XElement value, bool noAddParens = false) ret = "\\$"; break; } + // a bar alone before another bar parted from it by \!, a zero space: the reader pairs bars with + // nothing between them, which UnicodeMath reads as one double bar + if (val == "|" && Attr(value, "intent") == ":text" && + value.ElementsAfterSelf().FirstOrDefault()?.DescendantsAndSelf().FirstOrDefault(d => !d.HasElements) is { } nextLeaf && IsBar(nextLeaf) && Text(nextLeaf) == "|") + return "|\\!"; if (Attr(value, "intent") == ":text" && !standard) { // a bracket or a bar that pairs with nothing: the reader escapes the brackets that close nothing and diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs index 2d4e38e..ab769e4 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathGrammar.parlot.cs @@ -608,7 +608,7 @@ private static Parser Build() L("ⓐ(").SkipAnd(OneOrMany(alphaAscii)).AndSkip(Opt(__)).And(exp).AndSkip(L(")")) .Then(t => (object)O("intend", O(("op", "ⓐ"), ("intent", O("text", Join(t.Item1.ToList()))), ("content", t.Item2))))); entity.Parser = Always(null).When((context, x) => UnicodeMathGrammarWork.Step()).SkipAnd(OneOf( - expBracket.And(OneOf(Not(OneOf(L("\u00A0"), diacritic)).Then(x => (object)true), Always(false))) + expBracket.And(OneOf(Not(OneOf(L("\u00A0").SkipAnd(diacritic), diacritic)).Then(x => (object)true), Always(false))) .When((context, t) => (bool)t.Item2 || t.Item1 is DiacriticizedParen) .Then(t => t.Item1 is DiacriticizedParen d ? d.Atoms : t.Item1), absoluteValue, diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index cd4614c..1d1d227 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -157,6 +157,11 @@ public async Task ReadsLessCommonCommands(string latex, string expectedLaTeX, st [Arguments(@"(a+b)/2", @"(a+b)/2", @"(𝑎+𝑏)\/2")] [Arguments(@"\left( a \middle/ b \right)", @"(a/b)", @"(𝑎\/𝑏)")] [Arguments(@"\left( a \middle\| b \right)", @"(a\|b)", @"(𝑎\‖𝑏)")] + [Arguments(@"\left\{\middle|\right\}", @"\{{}|{}\}", @"{⬚│⬚}")] + [Arguments(@"\left( a \middle| \right)", @"(a|{})", @"(𝑎│⬚)")] + [Arguments(@"|a|\\|b|", @"|a||b|", @"|𝑎||𝑏|")] + [Arguments(@"|\,|\\|\!|\,|", @"|\,||\!|\,|", "|\u2009|\\||\u2009|")] + [Arguments(@"|a|~|b|", @"|a|~|b|", "|𝑎|\u00A0|𝑏|")] [Arguments(@"\langle a | b \rangle | c", @"\langle a|b\rangle|c", @"⟨𝑎│𝑏⟩\|𝑐")] [Arguments(@"a|b", @"a|b", @"𝑎\|𝑏")] [Arguments(@"(a\|b)", @"(a\|b)", @"(𝑎\‖𝑏)")] @@ -228,6 +233,15 @@ public async Task ReadsTextWithMathInside() [Arguments(@"a\!b", "ab")] [Arguments(@"a\>b", @"a\:b")] [Arguments(@"\hspace{1em}x", "x")] + [Arguments(@"a\hspace1em b", @"a\quad b")] + [Arguments(@"a\hspace{2cm}b", @"a\quad\quad b")] + [Arguments(@"a\kern-1.5pt b", "ab")] + [Arguments(@"a\mkern10mu b", @"a\enspace b")] + [Arguments(@"a\mspace{18mu}b", @"a\quad b")] + [Arguments(@"a\hskip 1em plus 1fil b", @"a\quad b")] + [Arguments(@"a\thinspace b\negthinspace c\nobreakspace d\space e", @"a\,bc~d~e")] + [Arguments(@"a\\b", "ab")] + [Arguments(@"(a)~b", @"(a)~b")] public async Task ReadsSpaces(string latex, string expected) { await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX).ToString(EquationFormat.LaTeX)).IsEqualTo(expected); @@ -309,7 +323,6 @@ public async Task KeepsKnownControlWordsWhenStrict() } [Test] - [Arguments(@"a\\b")] [Arguments(@"\frac{a}")] [Arguments(@"x_\frac{1}")] [Arguments(@"\text{unclosed")] diff --git a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs index 98efa05..6175b7e 100644 --- a/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs @@ -76,7 +76,8 @@ private static bool IsSpace(string text) => text.Length > 0 && /// The equation as MathML, with what latex2mathml and SharpEquations write differently for the same equation /// made the same: attributes that say the default or set the style, spaces (formatting, which an equation does not /// keep), invisible function application and times, the middle dot of \cdot (· for latex2mathml, ⋅ for - /// SharpEquations), names of operators and primes (mo for latex2mathml, mi and mo for SharpEquations), spacing + /// SharpEquations), the bar of \middle (| for latex2mathml, the separator │ of UnicodeMath for SharpEquations), + /// names of operators and primes (mo for latex2mathml, mi and mo for SharpEquations), spacing /// accents (˙ for latex2mathml, the combining dot above for SharpEquations), bars (mover with ¯ for latex2mathml, /// menclose for SharpEquations), capital Greek letters (upright in TeX, which latex2mathml writes as mi without /// mathvariant), symbols that are neither letters nor digits (mi for latex2mathml, %, °, mo for SharpEquations, @@ -115,6 +116,9 @@ private static string Canonical(Equation equation) case "mo" when text == "·": e.Value = "⋅"; break; + case "mo" when text == "│": + e.Value = "|"; + break; case "mo" when text != null && Accents.TryGetValue(text, out var mark) && e.Parent!.Name.LocalName is "mover" && e.ElementsBeforeSelf().Any(): e.Value = mark; break; @@ -198,13 +202,12 @@ 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 (|\,|); a font over \text and escaped braces (\mathrm{\{b\}}, + // words (lim inf); \limits after \mathop; both scripts of \overbrace and \underbrace; a font over \text and escaped braces (\mathrm{\{b\}}, // \mathsf{\text{}}, \boldsymbol{1}); \newenvironment and ## in a macro 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", "mathrm", "mathsf", "boldsymbol", + "issue-125-4-limits", "overbrace-c", "underbrace-c", "mathrm", "mathsf", "boldsymbol", "newenvironment-multi-node", "newcommand-escaped-hash", }; From 9c542f1bf9e1e8be38b0b9654bad2c1a7609705b Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:22:12 +0100 Subject: [PATCH 29/45] Unknown LaTeX control words end at their letters [no-new-version] An unknown control word swallowed the space after it and the reader re-emitted it bare, so UnicodeMath read the next word as part of it: \sgn r became the text \sgnr, a \backslash b lost the b, and strict mode named \sgnr. The LaTeX reader now ends such a word with a space where a letter or digit follows, and text read from LaTeX quotes the word without that space (UnicodeMath itself keeps it, as UnicodeMathML does). Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 4 ++++ .../Internal/UnicodeMath/UnicodeMathInput.cs | 6 +++-- .../UnicodeMath/UnicodeMathTranslator.cs | 2 +- .../LaTeX/LaTeXReaderTests.cs | 23 ++++++++++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 8275a90..da66882 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -1038,6 +1038,10 @@ private string Convert(string latex) } if (symbol.Length > 0 && Js.Ch(symbol, 0) != "\"") uniTeX.Append(symbol); + // a control word not known ends where its letters do: a space ends it for UnicodeMath too, which + // would otherwise read the letters or digits after it as part of it (\sgn r, \sgn2) + else if (symbol.Length > 0) + uniTeX.Append('\\').Append(cw).Append(tex[i] != null && tex[i].Length == 1 && IsAsciiLetterOrDigit(tex[i][0]) ? " " : ""); else if (cw.Length > 0) uniTeX.Append('\\').Append(cw); break; diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathInput.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathInput.cs index c024c4e..17090da 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathInput.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathInput.cs @@ -128,7 +128,9 @@ private static int ClosingQuote(List chars, int open) /// resolveCW: replaces control words (\alpha) with the characters they stand for. /// Called with each control word that is not one (as \foo), which is kept as text. - public static string ResolveControlWords(string unicodemath, Action unknown = null) + /// Whether the text was read from LaTeX, where the space after a control word only ends it and + /// is not kept in the text of one not known. + public static string ResolveControlWords(string unicodemath, Action unknown = null, bool tex = false) { string previous = ""; return ControlWord().Replace(unicodemath, match => @@ -215,7 +217,7 @@ public static string ResolveControlWords(string unicodemath, Action unkn return GetUnicodeFraction(Js.Ch(cw, 4), Js.Ch(cw, 5)); // not a control word: upright text unknown?.Invoke("\\" + cw); - return "\"" + match.Value + "\""; + return "\"" + (tex ? "\\" + cw : match.Value) + "\""; }); } diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs index e60a170..5db738b 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTranslator.cs @@ -79,7 +79,7 @@ private static string ToMathML(string unicodemath, bool display, bool alignmentM unicodemath = unicodemath.Trim(); var resolved = UnicodeMathInput.ResolveControlWords(unicodemath, strict ? unknown => throw new FormatException("Unknown control word " + unknown + (tex ? " in the LaTeX." : ".")) - : null); + : null, tex); // macros (\def, \newcommand: ⓜ) apply in the equation that defines them if (!tex && LaTeXReader.HasMacros(resolved)) resolved = LaTeXReader.ExpandMacros(resolved); diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 47a3aae..65d56ee 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -311,14 +311,35 @@ public async Task ExpandsMacrosOfTheOptions() } [Test] - [Arguments(@"\foo x", @"Unknown control word \foox in the LaTeX.")] + [Arguments(@"\foo x", @"Unknown control word \foo in the LaTeX.")] [Arguments(@"a+\undefinedcommand", @"Unknown control word \undefinedcommand in the LaTeX.")] + [Arguments(@"\sgn r", @"Unknown control word \sgn in the LaTeX.")] + [Arguments(@"\lg d", @"Unknown control word \lg in the LaTeX.")] + [Arguments(@"\liminf v", @"Unknown control word \liminf in the LaTeX.")] + [Arguments(@"a \backslash b", @"Unknown control word \backslash in the LaTeX.")] + [Arguments(@"\sgn2", @"Unknown control word \sgn in the LaTeX.")] public async Task RejectsUnknownControlWordsWhenStrict(string latex, string message) { await Assert.That(() => Equation.Parse(latex, EquationFormat.LaTeX, Strict)).Throws().WithMessage(message); await Assert.That(Equation.Parse(latex, EquationFormat.LaTeX).ToMathML().Descendants(MathMLNamespaces.MathML + "mtext")).IsNotEmpty(); } + [Test] + [Arguments(@"\sgn r", @"\sgn", "r")] + [Arguments(@"\lg d", @"\lg", "d")] + [Arguments(@"\liminf v", @"\liminf", "v")] + [Arguments(@"a \backslash b", @"\backslash", "b")] + [Arguments(@"\sgn r", @"\sgn", "r")] + [Arguments(@"\sgn{r}", @"\sgn", "r")] + [Arguments(@"\text{\sgn} r", @"\sgn", "r")] + [Arguments(@"\sgn2", @"\sgn", "2")] + public async Task EndsUnknownControlWordsAtTheirLetters(string latex, string text, string after) + { + var math = Equation.Parse(latex, EquationFormat.LaTeX).ToMathML(); + await Assert.That(math.Descendants(MathMLNamespaces.MathML + "mtext").Select(e => e.Value).ToList()).IsEquivalentTo(new[] { text }); + await Assert.That(math.Descendants().Where(e => e.Name.LocalName is "mi" or "mn").Select(e => e.Value).Last()).IsEqualTo(after); + } + [Test] public async Task KeepsKnownControlWordsWhenStrict() { From af7a315ef9611e02d79f427567dff4ceb9ae2d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:17:02 +0000 Subject: [PATCH 30/45] [no-new-version] Automatic line breaks of long display equations A display equation wider than its line (MathLayoutOptions.LineWidth, the width of the control) breaks before its binary operators and relations, the last one that fits each line, as Word does, with the lines after the first indented (BreakLines = false keeps one line). The row stays one row for the editor: the geometry has a line for each caret position, so that the caret, the selection (a rectangle a line), clicks and Up and Down (IMathGeometry.GetPositionOnAdjacentLine) follow the lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- docs/EDITOR.md | 10 +- src/SharpEquations.Editing/MathEditor.cs | 5 + src/SharpEquations.Editing/MathPosition.cs | 6 + .../PublicAPI.Unshipped.txt | 1 + src/SharpEquations.Rendering/Layout/Box.cs | 28 ++++ src/SharpEquations.Rendering/Layout/Engine.cs | 88 +++++++++++ src/SharpEquations.Rendering/MathLayout.cs | 141 +++++++++++++----- .../MathLayoutEngine.cs | 31 +++- .../PublicAPI.Unshipped.txt | 3 + .../Controls/ScreenshotTests.cs | 1 + .../Rendering/LineBreakTests.cs | 112 ++++++++++++++ 11 files changed, 387 insertions(+), 39 deletions(-) create mode 100644 tests/SharpEquations.Avalonia.Tests/Rendering/LineBreakTests.cs diff --git a/docs/EDITOR.md b/docs/EDITOR.md index 1240168..51ceb35 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -6,7 +6,7 @@ as it is typed, and the content is read and written as MathML (`GetMath`/`SetMat SharpEquations is the core under it: it reads what is typed, gives the MathML, and writes the linear form back when a part of the equation is built down. The editor edits one equation (math only), not math zones in a text document. -Phases 1 to 5 of the plan below are done; the three projects are published as NuGet packages with +Phases 1 to 6 of the plan below are done; the three projects are published as NuGet packages with the version of the core. ## What the user sees @@ -50,6 +50,11 @@ The behaviour is the one of the equations of Word and of `RichEditBox` in math m line more; the lines line up at their first `&` (`malignmark`), and keep the display style. Backspace at the start of a line and Delete at its end join it to its neighbour; Up and Down move between lines. A line is built up as the line of an array, where `&` is an alignment mark. +- **Automatic line breaks**: a display equation wider than its line (`MathLayoutOptions.LineWidth`, the width of the + control) breaks before its binary operators and relations, the last one that fits each line, as Word does; the lines + after the first are indented by 2 em, and stay one row for the editor: the caret, the selection (a rectangle a line), + clicks and Up and Down (`IMathGeometry.GetPositionOnAdjacentLine`) follow the lines. `BreakLines = false` keeps one + line. - **Tables and matrices**: `InsertTableRow`, `InsertTableColumn` (above or below, left or right), `DeleteTableRow`, `DeleteTableColumn` on the table of the caret (the last row or column deleted takes the table); Tab and Shift+Tab go through the cells. @@ -230,7 +235,8 @@ public class MathView : Control // read-only: Equati | 3 | `MathEditBox`: input, caret, hit testing, selection, linear view, clipboard, demo | done: headless tests of typing, keys, mouse, MathML in and out | | 4 | Accessibility (`MathEditBoxAutomationPeer`: an edit control whose value is the equation in UnicodeMath), Tab in tables, equation numbers, copy in any format, structures from code, NuGet packages | done | | 5 | The commands of the core in its public API (`EquationCommands`) and their completion, the floating bar, lines (equation arrays aligned at `&`), rows and columns of tables, the `ssty` variants of scripts | done | -| 6 | Automatic line breaks of long equations (at the relations and the operators, as Word), templates of the floating bar chosen by the application | to do | +| 6 | Automatic line breaks of long display equations, before the operators and relations, as Word | done | +| 7 | Templates of the floating bar chosen by the application, breaks inside brackets and fractions too wide for a line | to do | ## Open points diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs index 1ee86fa..42b2402 100644 --- a/src/SharpEquations.Editing/MathEditor.cs +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -1169,7 +1169,12 @@ private void MoveVertical(int direction, bool extend, IMathGeometry? geometry) while (target == null) { if (MathTree.Owner(Root, current) is not { } owner) + { + // no slot above or below: the line above or below, in an equation broken into lines + if (geometry?.GetPositionOnAdjacentLine(caret, direction) is { } onLine && IsValid(onLine)) + Move(Canonical(onLine), extend); return; + } var relative = current.Skip(owner.Item.Length).ToArray(); if (MathSchema.Vertical(MathTree.Get(Root, owner.Item), relative, direction) is { } slot) { diff --git a/src/SharpEquations.Editing/MathPosition.cs b/src/SharpEquations.Editing/MathPosition.cs index 816b5a8..ef43c0d 100644 --- a/src/SharpEquations.Editing/MathPosition.cs +++ b/src/SharpEquations.Editing/MathPosition.cs @@ -96,5 +96,11 @@ public interface IMathGeometry /// The position in the row (or in a run of it) nearest to the horizontal coordinate. MathPosition? GetNearestPosition(IReadOnlyList rowPath, double x); + + /// + /// The position on the line above (direction -1) or below (+1) in the row broken into lines that holds the + /// position, nearest to its horizontal coordinate; null when that row is on one line or has no such line. + /// + MathPosition? GetPositionOnAdjacentLine(MathPosition position, int direction); } } diff --git a/src/SharpEquations.Editing/PublicAPI.Unshipped.txt b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt index 655c46f..79a165f 100644 --- a/src/SharpEquations.Editing/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Editing/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ SharpEquations.Editing.IMathGeometry SharpEquations.Editing.IMathGeometry.GetCaretX(SharpEquations.Editing.MathPosition! position) -> double? SharpEquations.Editing.IMathGeometry.GetNearestPosition(System.Collections.Generic.IReadOnlyList! rowPath, double x) -> SharpEquations.Editing.MathPosition? +SharpEquations.Editing.IMathGeometry.GetPositionOnAdjacentLine(SharpEquations.Editing.MathPosition! position, int direction) -> SharpEquations.Editing.MathPosition? SharpEquations.Editing.MathEditor SharpEquations.Editing.MathEditor.Anchor.get -> SharpEquations.Editing.MathPosition! SharpEquations.Editing.MathEditor.Backspace() -> void diff --git a/src/SharpEquations.Rendering/Layout/Box.cs b/src/SharpEquations.Rendering/Layout/Box.cs index 36488b2..e264003 100644 --- a/src/SharpEquations.Rendering/Layout/Box.cs +++ b/src/SharpEquations.Rendering/Layout/Box.cs @@ -52,6 +52,12 @@ internal sealed class Box public double CaretDepth { get; set; } + /// + /// The lines of a row broken to the width of the line, or null for a row on one line: for each caret position, + /// its line; for each line, the shift of its baseline (y up), its height, its depth and the x of its end. + /// + public RowLines? Lines { get; set; } + public void Add(Box box, double x, double shift) => Children.Add((box, x, shift)); /// A box of one child, shifted. @@ -73,6 +79,28 @@ public static Box Wrap(Box child, double x = 0, double shift = 0) } } + internal sealed class RowLines + { + public RowLines(int[] caretLine, double[] shift, double[] height, double[] depth, double[] right) + { + CaretLine = caretLine; + Shift = shift; + Height = height; + Depth = depth; + Right = right; + } + + public int[] CaretLine { get; } + + public double[] Shift { get; } + + public double[] Height { get; } + + public double[] Depth { get; } + + public double[] Right { get; } + } + internal readonly struct GlyphItem { public GlyphItem(IMathFont font, ushort glyph, double x, double shift, double size) diff --git a/src/SharpEquations.Rendering/Layout/Engine.cs b/src/SharpEquations.Rendering/Layout/Engine.cs index 6e0e116..e7e6503 100644 --- a/src/SharpEquations.Rendering/Layout/Engine.cs +++ b/src/SharpEquations.Rendering/Layout/Engine.cs @@ -271,6 +271,8 @@ private Box LayoutRow(MathNode row, int[]? path, Style style, Stretch stretch) box.Width = x; box.CaretX = caret; SetCaretExtent(box, style); + if (options.LineWidth is { } lineWidth && options.BreakLines && style.Display && x > lineWidth && path is { Length: 1 } && path[0] == 0 && n > 1) + BreakLines(row, box, boxes, lineWidth, style); if (n == 1) { box.Italic = boxes[0].Italic; @@ -286,6 +288,92 @@ private Box LayoutRow(MathNode row, int[]? path, Style style, Stretch stretch) return box; } + /// + /// Breaks the row of the equation into lines no wider than the line, as Word does: before a binary operator or a + /// relation, the last one that fits, with the lines after the first indented. A row with no such operator where + /// it should break stays too wide there. + /// + private void BreakLines(MathNode row, Box box, Box[] boxes, double lineWidth, Style style) + { + var n = boxes.Length; + var caret = box.CaretX!; + var placed = box.Children.ToList(); + var indent = Math.Min(style.Size * 2, lineWidth / 3); + + var starts = new List { 0 }; + var lastBreak = -1; + for (var i = 1; i < n; i++) + { + if (IsBreakPoint(row, i)) + lastBreak = i; + var start = starts[starts.Count - 1]; + var available = starts.Count == 1 ? lineWidth : lineWidth - indent; + if (caret[i + 1] - caret[start] > available && lastBreak > start) + { + starts.Add(lastBreak); + lastBreak = -1; + } + } + if (starts.Count == 1) + return; + + var lines = starts.Count; + var caretLine = new int[n + 1]; + var height = new double[lines]; + var depth = new double[lines]; + var right = new double[lines]; + var offset = new double[lines]; + for (var k = 0; k < lines; k++) + { + var from = starts[k]; + var to = k + 1 < lines ? starts[k + 1] : n; + offset[k] = (k == 0 ? 0 : indent) - caret[from]; + for (var i = from; i < to; i++) + { + caretLine[i] = k; + height[k] = Math.Max(height[k], boxes[i].Height); + depth[k] = Math.Max(depth[k], boxes[i].Depth); + } + right[k] = caret[to] + offset[k]; + height[k] = Math.Max(height[k], style.Size * 0.75); + depth[k] = Math.Max(depth[k], style.Size * 0.22); + } + caretLine[n] = lines - 1; + + var gap = Math.Max(C(MathConstant.MathLeading, style), style.Size * 0.25); + var shift = new double[lines]; + for (var k = 1; k < lines; k++) + shift[k] = shift[k - 1] - depth[k - 1] - gap - height[k]; + + box.Children.Clear(); + for (var i = 0; i < n; i++) + { + var k = caretLine[i]; + box.Add(placed[i].Box, placed[i].X + offset[k], shift[k]); + } + var newCaret = new double[n + 1]; + for (var i = 0; i <= n; i++) + newCaret[i] = caret[i] + offset[caretLine[i]]; + box.CaretX = newCaret; + box.Width = right.Max(); + box.Height = height[0]; + box.Depth = depth[lines - 1] - shift[lines - 1]; + box.CaretHeight = height[0]; + box.CaretDepth = box.Depth; + box.Lines = new RowLines(caretLine, shift, height, depth, right); + } + + /// A place to break the row: before a binary operator or a relation that is not the first of the row. + private static bool IsBreakPoint(MathNode row, int i) + { + if (i == 0 || CoreOperator(row.Children[i]) is not { } op || op.Text.Length == 0) + return false; + if (op.Text is "\u2061" or "\u2062" or "\u2063" or "\u2064") + return false; + var entry = OperatorDictionary.Lookup(op.Text, FormOf(row, i, op)); + return entry.LSpace >= 4 && !entry.Has(OperatorFlags.Fence) && !entry.Has(OperatorFlags.LargeOp); + } + private static void SetCaretExtent(Box box, Style style) { box.CaretHeight = Math.Max(box.Height, style.Size * 0.75); diff --git a/src/SharpEquations.Rendering/MathLayout.cs b/src/SharpEquations.Rendering/MathLayout.cs index c35121d..149a9f5 100644 --- a/src/SharpEquations.Rendering/MathLayout.cs +++ b/src/SharpEquations.Rendering/MathLayout.cs @@ -192,33 +192,44 @@ internal MathLayout(double width, double height, double baseline, IReadOnlyList< { if (!byPath.TryGetValue(Key(position.Path), out var row) || position.Offset >= row.CaretX.Length) return null; - return new MathRect(row.CaretX[position.Offset], row.Top, 0, row.Bottom - row.Top); + var i = position.Offset; + return new MathRect(row.CaretX[i], row.CaretTop[i], 0, row.CaretBottom[i] - row.CaretTop[i]); } - /// The rectangles to highlight for a selection. + /// The rectangles to highlight for a selection: one for each line it is on. public IReadOnlyList GetSelectionRects(MathSelection selection) { if (!byPath.TryGetValue(Key(selection.Path), out var row) || selection.End >= row.CaretX.Length) return Array.Empty(); - var x0 = row.CaretX[selection.Start]; - var x1 = row.CaretX[selection.End]; - if (!row.IsRun) + + var rects = new List(); + var first = row.CaretLine[selection.Start]; + var last = row.CaretLine[selection.End]; + for (var line = first; line <= last; line++) { - // the extent of the children selected, which may stand out of the row's own box - var top = row.Top; - var bottom = row.Bottom; - foreach (var other in rows) + var from = line == first ? selection.Start : Array.IndexOf(row.CaretLine, line); + var x0 = row.CaretX[from]; + var x1 = line == last ? row.CaretX[selection.End] : row.LineRight[line]; + var top = row.CaretTop[from]; + var bottom = row.CaretBottom[from]; + if (!row.IsRun) { - if (other.Path.Length > row.Path.Length && other.Path.Take(row.Path.Length).SequenceEqual(row.Path) - && other.Path[row.Path.Length] >= selection.Start && other.Path[row.Path.Length] < selection.End) + // the extent of the children selected on the line, which may stand out of the line of the row + var to = line == last ? selection.End : Array.LastIndexOf(row.CaretLine, line) + 1; + foreach (var other in rows) { - top = Math.Min(top, other.Top); - bottom = Math.Max(bottom, other.Bottom); + if (other.Path.Length > row.Path.Length && other.Path.Take(row.Path.Length).SequenceEqual(row.Path) + && other.Path[row.Path.Length] >= from && other.Path[row.Path.Length] < to) + { + top = Math.Min(top, other.Top); + bottom = Math.Max(bottom, other.Bottom); + } } } - return new[] { new MathRect(Math.Min(x0, x1), top, Math.Abs(x1 - x0), bottom - top) }; + if (x1 > x0) + rects.Add(new MathRect(x0, top, x1 - x0, bottom - top)); } - return new[] { new MathRect(Math.Min(x0, x1), row.Top, Math.Abs(x1 - x0), row.Bottom - row.Top) }; + return rects; } /// The position of the caret nearest to a point. @@ -240,7 +251,10 @@ public IReadOnlyList GetSelectionRects(MathSelection selection) } if (best == null) return null; - return best.IsRun ? new MathPosition(best.Path, Nearest(best.CaretX, x)) : GetNearestPosition(best.Path, x); + if (best.IsRun) + return new MathPosition(best.Path, best.Nearest(x, y)); + var index = best.Nearest(x, y); + return RunAt(best, best.CaretLine[index], x) ?? new MathPosition(best.Path, index); } /// @@ -252,43 +266,62 @@ public IReadOnlyList GetSelectionRects(MathSelection selection) { if (!byPath.TryGetValue(Key(rowPath), out var row)) return null; - foreach (var run in rows) + return RunAt(row, 0, x) ?? new MathPosition(rowPath, row.NearestOnLine(x, 0)); + } + + /// + public MathPosition? GetPositionOnAdjacentLine(MathPosition position, int direction) + { + // the innermost row broken into lines that holds the position, and the line of the position in it + for (var length = position.Path.Count; length >= 0; length--) { - if (run.IsRun && run.Path.Length == rowPath.Count + 1 && run.Path.Take(rowPath.Count).SequenceEqual(rowPath) - && x > run.Left && x < run.Right) - { - return new MathPosition(run.Path, Nearest(run.CaretX, x)); - } + var prefix = position.Path.Take(length).ToArray(); + if (!byPath.TryGetValue(Key(prefix), out var row) || row.Lines < 2 || row.IsRun) + continue; + var index = length == position.Path.Count ? position.Offset : position.Path[length]; + if (index >= row.CaretX.Length || GetCaretX(position) is not { } x) + return null; + var line = row.CaretLine[index] + direction; + if (line < 0 || line >= row.Lines) + return null; + return RunAt(row, line, x) ?? new MathPosition(prefix, row.NearestOnLine(x, line)); } - return new MathPosition(rowPath, Nearest(row.CaretX, x)); + return null; } - private static int Nearest(double[] xs, double x) + /// The position in a run of the row, on the line, under the x; null when no run is there. + private MathPosition? RunAt(RowGeometry row, int line, double x) { - var best = 0; - for (var i = 1; i < xs.Length; i++) + foreach (var run in rows) { - if (Math.Abs(xs[i] - x) < Math.Abs(xs[best] - x)) - best = i; + if (run.IsRun && run.Path.Length == row.Path.Length + 1 && run.Path.Take(row.Path.Length).SequenceEqual(row.Path) + && x > run.Left && x < run.Right && run.Path[row.Path.Length] < row.CaretLine.Length && row.CaretLine[run.Path[row.Path.Length]] == line) + { + return new MathPosition(run.Path, run.NearestOnLine(x, 0)); + } } - return best; + return null; } private static string Key(IEnumerable path) => string.Join("/", path); } - /// The geometry of a row or a run, in the coordinates of the layout. + /// The geometry of a row or a run, in the coordinates of the layout; a row broken into lines has several. internal sealed class RowGeometry { - public RowGeometry(int[] path, bool isRun, double[] caretX, double top, double bottom, double left, double right) + public RowGeometry(int[] path, bool isRun, double[] caretX, double[] caretTop, double[] caretBottom, int[] caretLine, double[] lineRight, double left, double top, double bottom) { Path = path; IsRun = isRun; CaretX = caretX; - Top = top; - Bottom = bottom; + CaretTop = caretTop; + CaretBottom = caretBottom; + CaretLine = caretLine; + LineRight = lineRight; Left = left; - Right = right; + Top = Math.Min(top, caretTop.DefaultIfEmpty(top).Min()); + Bottom = Math.Max(bottom, caretBottom.DefaultIfEmpty(bottom).Max()); + Right = Math.Max(left + 1, lineRight.Max()); } public int[] Path { get; } @@ -297,6 +330,18 @@ public RowGeometry(int[] path, bool isRun, double[] caretX, double top, double b public double[] CaretX { get; } + public double[] CaretTop { get; } + + public double[] CaretBottom { get; } + + /// The line of each caret position. + public int[] CaretLine { get; } + + /// The x of the end of each line. + public double[] LineRight { get; } + + public int Lines => LineRight.Length; + public double Top { get; } public double Bottom { get; } @@ -306,5 +351,33 @@ public RowGeometry(int[] path, bool isRun, double[] caretX, double top, double b public double Right { get; } public double Area => (Right - Left) * (Bottom - Top); + + /// The caret position nearest to a point: on the nearest line, the nearest x. + public int Nearest(double x, double y) + { + var bestLine = 0; + var bestDistance = double.MaxValue; + for (var i = 0; i < CaretX.Length; i++) + { + var d = y < CaretTop[i] ? CaretTop[i] - y : y > CaretBottom[i] ? y - CaretBottom[i] : 0; + if (d < bestDistance) + { + bestDistance = d; + bestLine = CaretLine[i]; + } + } + return NearestOnLine(x, bestLine); + } + + public int NearestOnLine(double x, int line) + { + var best = -1; + for (var i = 0; i < CaretX.Length; i++) + { + if (CaretLine[i] == line && (best < 0 || Math.Abs(CaretX[i] - x) < Math.Abs(CaretX[best] - x))) + best = i; + } + return Math.Max(best, 0); + } } } diff --git a/src/SharpEquations.Rendering/MathLayoutEngine.cs b/src/SharpEquations.Rendering/MathLayoutEngine.cs index 15c4ddb..cc6b6e4 100644 --- a/src/SharpEquations.Rendering/MathLayoutEngine.cs +++ b/src/SharpEquations.Rendering/MathLayoutEngine.cs @@ -2,6 +2,7 @@ namespace SharpEquations.Rendering { using System; using System.Collections.Generic; + using System.Linq; using System.Xml.Linq; using SharpEquations.Editing; using SharpEquations.Rendering.Layout; @@ -21,6 +22,12 @@ public sealed class MathLayoutOptions /// public double? LineWidth { get; set; } + /// + /// Breaks a display equation wider than into lines, before its binary operators and + /// relations, as Word does; true by default. + /// + public bool BreakLines { get; set; } = true; + /// Draws a box in the empty slots, for an editor. public bool ShowPlaceholders { get; set; } } @@ -85,10 +92,28 @@ private static void Flatten(Box box, double x, double baseline, string? color, b if (box.Path != null && box.CaretX != null) { - var caret = new double[box.CaretX.Length]; - for (var i = 0; i < caret.Length; i++) + var count = box.CaretX.Length; + var caret = new double[count]; + var top = new double[count]; + var bottom = new double[count]; + var line = new int[count]; + for (var i = 0; i < count; i++) + { caret[i] = x + box.CaretX[i]; - rows.Add(new RowGeometry(box.Path, box.IsRun, caret, baseline - box.CaretHeight, baseline + box.CaretDepth, x, x + Math.Max(box.Width, 1))); + if (box.Lines is { } lines) + { + var k = line[i] = lines.CaretLine[i]; + top[i] = baseline - lines.Shift[k] - lines.Height[k]; + bottom[i] = baseline - lines.Shift[k] + lines.Depth[k]; + } + else + { + top[i] = baseline - box.CaretHeight; + bottom[i] = baseline + box.CaretDepth; + } + } + var right = box.Lines?.Right.Select(r => x + r).ToArray() ?? new[] { x + Math.Max(box.Width, 1) }; + rows.Add(new RowGeometry(box.Path, box.IsRun, caret, top, bottom, line, right, x, baseline - box.Height, baseline + box.Depth)); } foreach (var (child, cx, shift) in box.Children) diff --git a/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt index e38832d..4876ef1 100644 --- a/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Rendering/PublicAPI.Unshipped.txt @@ -27,6 +27,7 @@ SharpEquations.Rendering.MathLayout.Baseline.get -> double SharpEquations.Rendering.MathLayout.GetCaretRect(SharpEquations.Editing.MathPosition! position) -> SharpEquations.Rendering.MathRect? SharpEquations.Rendering.MathLayout.GetCaretX(SharpEquations.Editing.MathPosition! position) -> double? SharpEquations.Rendering.MathLayout.GetNearestPosition(System.Collections.Generic.IReadOnlyList! rowPath, double x) -> SharpEquations.Editing.MathPosition? +SharpEquations.Rendering.MathLayout.GetPositionOnAdjacentLine(SharpEquations.Editing.MathPosition! position, int direction) -> SharpEquations.Editing.MathPosition? SharpEquations.Rendering.MathLayout.GetSelectionRects(SharpEquations.Editing.MathSelection! selection) -> System.Collections.Generic.IReadOnlyList! SharpEquations.Rendering.MathLayout.Glyphs.get -> System.Collections.Generic.IReadOnlyList! SharpEquations.Rendering.MathLayout.Height.get -> double @@ -35,6 +36,8 @@ SharpEquations.Rendering.MathLayout.Shapes.get -> System.Collections.Generic.IRe SharpEquations.Rendering.MathLayout.Width.get -> double SharpEquations.Rendering.MathLayoutEngine SharpEquations.Rendering.MathLayoutOptions +SharpEquations.Rendering.MathLayoutOptions.BreakLines.get -> bool +SharpEquations.Rendering.MathLayoutOptions.BreakLines.set -> void SharpEquations.Rendering.MathLayoutOptions.Display.get -> bool SharpEquations.Rendering.MathLayoutOptions.Display.set -> void SharpEquations.Rendering.MathLayoutOptions.FontSize.get -> double diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs index 354b94a..72a6070 100644 --- a/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Controls/ScreenshotTests.cs @@ -30,6 +30,7 @@ public class ScreenshotTests "|x|+‖y‖+⌊z⌋+⟨ψ|φ⟩", "█(f(x)&=(x+1)^2@&=x^2+2x+1)", "E=mc^2#(1)", + "f(x)=a_0+a_1 x+a_2 x^2+a_3 x^3+a_4 x^4+a_5 x^5+a_6 x^6+a_7 x^7+a_8 x^8+a_9 x^9+a_10 x^10+a_11 x^11+a_12 x^12", }; [Test] diff --git a/tests/SharpEquations.Avalonia.Tests/Rendering/LineBreakTests.cs b/tests/SharpEquations.Avalonia.Tests/Rendering/LineBreakTests.cs new file mode 100644 index 0000000..2e90f70 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Rendering/LineBreakTests.cs @@ -0,0 +1,112 @@ +namespace SharpEquations.Avalonia.Tests.Rendering +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Editing; + using SharpEquations.Rendering; + + /// A display equation wider than its line breaks before its operators, as in Word. + [NotInParallel("headless")] + public class LineBreakTests + { + private const string Long = "f(x)=a_0+a_1 x+a_2 x^2+a_3 x^3+a_4 x^4+a_5 x^5+a_6 x^6+a_7 x^7+a_8 x^8+a_9 x^9"; + + private static MathLayoutOptions Options(double width) => new MathLayoutOptions { FontSize = 20, LineWidth = width, ShowPlaceholders = true }; + + private static MathEditor Editor() => new MathEditor(Equation.Parse(Long, EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + + [Test] + public async Task BreaksBeforeOperatorsToTheWidth() + { + var (one, broken, baselines, starts) = await Headless.Run(() => + { + var editor = Editor(); + var wide = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, Options(5000)); + var narrow = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, Options(300)); + var lines = narrow.Glyphs.Select(g => Math.Round(g.Y)).Distinct().Count(y => narrow.Glyphs.Count(g => Math.Round(g.Y) == y) > 3); + // the caret positions at the start of each line are before an operator + var row = editor.Root.Children[0]; + var startsWithOperator = new List(); + for (var i = 1; i < row.Children.Count; i++) + { + var here = narrow.GetCaretRect(new MathPosition(new[] { 0 }, i))!.Value; + var before = narrow.GetCaretRect(new MathPosition(new[] { 0 }, i - 1))!.Value; + if (here.Y > before.Y + 1) + startsWithOperator.Add(row.Children[i].Name == "mo"); + } + return ((wide.Width, wide.Height), (narrow.Width, narrow.Height), lines, startsWithOperator); + }); + await Assert.That(one.Width).IsGreaterThan(300); + await Assert.That(broken.Width).IsLessThanOrEqualTo(300); + await Assert.That(broken.Height).IsGreaterThan(one.Height * 2); + await Assert.That(starts.Count).IsGreaterThanOrEqualTo(2); + await Assert.That(starts.All(s => s)).IsTrue(); + } + + [Test] + public async Task InlineOrUnlimitedDoesNotBreak() + { + var (inline, off) = await Headless.Run(() => + { + var inlineEditor = new MathEditor(Equation.Parse(Long, EquationFormat.UnicodeMath)); + var a = MathLayoutEngine.Layout(inlineEditor.Root, MathFonts.Default, Options(300)).Width; + var b = MathLayoutEngine.Layout(Editor().Root, MathFonts.Default, new MathLayoutOptions { FontSize = 20, LineWidth = 300, BreakLines = false }).Width; + return (a, b); + }); + await Assert.That(inline).IsGreaterThan(300); + await Assert.That(off).IsGreaterThan(300); + } + + /// Every caret position of the broken equation is found again by a click on it, and Down goes to the next line. + [Test] + public async Task CaretOnTheLines() + { + var (failures, movedDown, movedUp) = await Headless.Run(() => + { + var editor = Editor(); + var layout = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, Options(300)); + var failed = new List(); + editor.MoveHome(); + for (var step = 0; step < 300; step++) + { + var caret = editor.Caret; + var rect = layout.GetCaretRect(caret)!.Value; + var hit = layout.HitTest(rect.X, rect.Y + rect.Height / 2); + if (hit == null || layout.GetCaretRect(hit) is not { } h || Math.Abs(h.X - rect.X) > 0.5 || h.Y + h.Height / 2 < rect.Y || h.Y + h.Height / 2 > rect.Bottom) + failed.Add(caret + " → " + hit); + editor.MoveRight(); + if (editor.Caret == caret) + break; + } + + editor.MoveHome(); + editor.MoveHome(); + var top = layout.GetCaretRect(editor.Caret)!.Value.Y; + editor.MoveDown(geometry: layout); + var down = layout.GetCaretRect(editor.Caret)!.Value.Y; + editor.MoveUp(geometry: layout); + var up = layout.GetCaretRect(editor.Caret)!.Value.Y; + return (failed, down > top + 1, Math.Abs(up - top) < 0.5); + }); + await Assert.That(failures).IsEmpty(); + await Assert.That(movedDown).IsTrue(); + await Assert.That(movedUp).IsTrue(); + } + + [Test] + public async Task SelectionOnSeveralLines() + { + var rects = await Headless.Run(() => + { + var editor = Editor(); + var layout = MathLayoutEngine.Layout(editor.Root, MathFonts.Default, Options(300)); + editor.SelectAll(); + return layout.GetSelectionRects(editor.Selection!).Count; + }); + await Assert.That(rects).IsGreaterThanOrEqualTo(2); + } + } +} From 98cb283150f827ccccb4a564c21d01529b6261c9 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Mon, 9 Nov 2026 19:19:42 +0100 Subject: [PATCH 31/45] mhchem: \ce and \pu in the LaTeX reader \ce{...} and \pu{...} are rewritten as plain LaTeX before the LaTeX is read (Internal/LaTeX/Mhchem.cs): upright elements with counts and charges as scripts, mass and atomic numbers as prescripts, reaction arrows with their labels, bonds (\bond, - = #), states, hydrate dots, precipitates and gases. The rules of \ce follow mhchem.go of treeblood (MIT, Copyright (c) 2024 Wyatt Sheffield), rewritten to write LaTeX instead of MathML. Fixes the UnicodeMath preprocessing of a prescripted base with scripts of its own (^0_(-1) n^-): its scripts were preprocessed twice, and an operator in them came out as "[object Object]". Test data: the 76 chemistry cases of treeblood's test/TESTCASES.yaml (tests/data/latex/treeblood-mhchem.json), all read with Strict. Co-Authored-By: Claude Opus 5.5 --- NOTICE | 30 + .../Internal/LaTeX/LaTeXReader.cs | 4 +- src/SharpEquations/Internal/LaTeX/Mhchem.cs | 872 ++++++++++++++++++ .../UnicodeMath/UnicodeMathPreprocess.cs | 8 +- .../SharpEquations.Tests/LaTeX/MhchemTests.cs | 78 ++ tests/data/README.md | 5 + tests/data/latex/treeblood-mhchem.json | 78 ++ 7 files changed, 1070 insertions(+), 5 deletions(-) create mode 100644 src/SharpEquations/Internal/LaTeX/Mhchem.cs create mode 100644 tests/SharpEquations.Tests/LaTeX/MhchemTests.cs create mode 100644 tests/data/latex/treeblood-mhchem.json diff --git a/NOTICE b/NOTICE index d29320f..47c6701 100644 --- a/NOTICE +++ b/NOTICE @@ -270,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/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 8275a90..8940ee5 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -39,10 +39,10 @@ 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 /// 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 = diff --git a/src/SharpEquations/Internal/LaTeX/Mhchem.cs b/src/SharpEquations/Internal/LaTeX/Mhchem.cs new file mode 100644 index 0000000..8c4ab5f --- /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 = "{" + (Mass != null ? "^{" + Mass + "}" : "") + (Z != null ? "_{" + 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/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/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/data/README.md b/tests/data/README.md index c90c6e8..ece8f1e 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -80,3 +80,8 @@ corrected where latex2mathml writes MathML that is not what the LaTeX means: the (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/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}" +] From b49e59282897e0849570101d5e8ad08dab1f2bd8 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Sun, 8 Nov 2026 15:22:01 +0100 Subject: [PATCH 32/45] [no-new-version] LaTeX reader reads every command the writer writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard LaTeX writer wrote \geqslant, \leqslant, \therefore, \coloneqq, \dblcolon, \ngtr and \lozenge, which the reader did not know. The reader now falls back to LaTeXSymbols for the commands UnicodeMath has no control word for, or another one (\succeq is ⪰, ≽ in UnicodeMath), so the two tables cannot drift. \oslash is escaped (⊘ builds a fraction in UnicodeMath) and \mid outside brackets too (∣ is a separator only in brackets). A test writes every character of LaTeXSymbols to LaTeX and reads it back. Co-Authored-By: Claude Opus 5.5 --- .../Internal/LaTeX/LaTeXReader.cs | 30 ++++++++++++++-- .../Internal/LaTeX/LaTeXSymbols.cs | 11 ++++++ .../LaTeX/LaTeXReaderTests.cs | 12 ++++++- .../LaTeX/LaTeXSymbolsTests.cs | 34 +++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/SharpEquations.Tests/LaTeX/LaTeXSymbolsTests.cs diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 8275a90..22b8b4e 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -371,9 +371,10 @@ 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", - // the symbols of LaTeX, amsmath, amssymb and plain TeX that UnicodeMath has no control word for, and \square, - // \Box, \blacksquare and \surd, which are symbols in LaTeX and build a box or a root in UnicodeMath - ["yen"] = "¥", ["circledR"] = "®", ["S"] = "§", ["P"] = "¶", ["mho"] = "℧", ["rightsquigarrow"] = "↝", + // 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"] = "…", @@ -385,6 +386,11 @@ private static (string Text, int Next) ScriptArgument(Text tex, int i) ["And"] = "\\&", }; + 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) { @@ -421,6 +427,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) @@ -431,6 +439,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] == "/") { @@ -466,6 +476,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] == "|") { @@ -487,6 +499,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 @@ -1024,6 +1044,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 cd1381f..99f4ecd 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXSymbols.cs @@ -126,6 +126,17 @@ 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); diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index 47a3aae..e834ba4 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", @"⇳∑₁²∑₃⁴")] @@ -351,6 +351,16 @@ public async Task RejectsLaTeXThatIsNotAnEquation(string latex) [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(); 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("a<" + element + ">" + SecurityElement.Escape(character) + "b", 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); + } + } +} From 5fbb743046f49ebd4837a8b1a552e4942aacf140 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:26:38 +0000 Subject: [PATCH 33/45] [no-new-version] Floating bar and error background in the dark theme The floating bar took a light background of its own and the foreground of the theme, light in the dark theme: its text could not be read. It now takes the colours of the flyouts of the application's theme (Fluent, Simple), or a light or a dark set by the theme variant, and follows a change of variant; the background of text that does not parse (merror) is dark in the dark theme. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- src/SharpEquations.Avalonia/MathDrawing.cs | 6 +++ src/SharpEquations.Avalonia/MathEditBox.cs | 2 +- src/SharpEquations.Avalonia/MathQuickBar.cs | 53 +++++++++++++++++-- src/SharpEquations.Avalonia/MathView.cs | 2 +- .../Controls/QuickBarTests.cs | 49 +++++++++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/SharpEquations.Avalonia/MathDrawing.cs b/src/SharpEquations.Avalonia/MathDrawing.cs index 1d54644..a31f3cf 100644 --- a/src/SharpEquations.Avalonia/MathDrawing.cs +++ b/src/SharpEquations.Avalonia/MathDrawing.cs @@ -50,6 +50,12 @@ public MathDrawing(MathLayout layout) public MathLayout Layout { get; } + private static readonly IBrush LightError = new ImmutableSolidColorBrush(Colors.MistyRose); + private static readonly IBrush DarkError = new ImmutableSolidColorBrush(Color.FromArgb(0xFF, 0x5C, 0x24, 0x24)); + + /// The background of merror, of text that does not parse: light or dark with the theme. + public static IBrush ErrorBrush(global::Avalonia.Styling.ThemeVariant? theme) => theme == global::Avalonia.Styling.ThemeVariant.Dark ? DarkError : LightError; + public void Draw(DrawingContext context, Point origin, MathBrushes brushes) { using (context.PushTransform(Matrix.CreateTranslation(origin.X, origin.Y))) diff --git a/src/SharpEquations.Avalonia/MathEditBox.cs b/src/SharpEquations.Avalonia/MathEditBox.cs index 9319579..55be9c7 100644 --- a/src/SharpEquations.Avalonia/MathEditBox.cs +++ b/src/SharpEquations.Avalonia/MathEditBox.cs @@ -473,7 +473,7 @@ public override void Render(DrawingContext context) context.FillRectangle(SelectionBrush, new Rect(rect.X + origin.X, rect.Y + origin.Y, rect.Width, rect.Height)); } - drawing.Draw(context, origin, new MathBrushes(Foreground, PlaceholderBrush, Brushes.MistyRose)); + drawing.Draw(context, origin, new MathBrushes(Foreground, PlaceholderBrush, MathDrawing.ErrorBrush(ActualThemeVariant))); if (IsFocused && caretVisible && !IsReadOnly && layout.GetCaretRect(Editor.Caret) is { } caret) { diff --git a/src/SharpEquations.Avalonia/MathQuickBar.cs b/src/SharpEquations.Avalonia/MathQuickBar.cs index 141d898..ea2d42e 100644 --- a/src/SharpEquations.Avalonia/MathQuickBar.cs +++ b/src/SharpEquations.Avalonia/MathQuickBar.cs @@ -5,8 +5,10 @@ namespace SharpEquations.Avalonia using System.Linq; using global::Avalonia; using global::Avalonia.Controls; + using global::Avalonia.Controls.Documents; using global::Avalonia.Layout; using global::Avalonia.Media; + using global::Avalonia.Styling; using SharpEquations.Editing; /// What shows. @@ -47,9 +49,9 @@ internal MathQuickBar(MathEditBox owner) Padding = new Thickness(4); CornerRadius = new CornerRadius(6); BorderThickness = new Thickness(1); - BorderBrush = new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)); - Background = new SolidColorBrush(Color.FromArgb(0xF8, 0xFB, 0xFB, 0xFD)); BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 8, Color = Color.FromArgb(0x40, 0, 0, 0) }); + ActualThemeVariantChanged += (_, _) => ApplyTheme(); + ApplyTheme(); } /// What the bar shows. @@ -148,12 +150,57 @@ private static Button Button(string content, string tip, Action action, double m return button; } + private IBrush highlight = new SolidColorBrush(Color.FromArgb(0x40, 0x33, 0x99, 0xFF)); + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ApplyTheme(); + } + + /// + /// The colours of the theme: those of the flyouts of the theme of the application (Fluent, Simple...), or a light or + /// a dark set by the variant of the theme. + /// + private void ApplyTheme() + { + var dark = ActualThemeVariant == ThemeVariant.Dark; + Background = Resource(dark ? Color.FromArgb(0xF8, 0x2B, 0x2B, 0x2B) : Color.FromArgb(0xF8, 0xFB, 0xFB, 0xFD), + "FlyoutPresenterBackground", "SystemControlBackgroundChromeMediumLowBrush", "ThemeBackgroundBrush"); + BorderBrush = Resource(dark ? Color.FromArgb(0x80, 0x70, 0x70, 0x70) : Color.FromArgb(0x60, 0x80, 0x80, 0x80), + "FlyoutBorderThemeBrush", "SystemControlTransientBorderBrush", "ThemeBorderLowBrush"); + TextElement.SetForeground(this, Resource(dark ? Colors.White : Colors.Black, + "SystemControlForegroundBaseHighBrush", "ThemeForegroundBrush")); + highlight = Resource(dark ? Color.FromArgb(0x60, 0x4C, 0xA6, 0xFF) : Color.FromArgb(0x40, 0x33, 0x99, 0xFF), + "SystemControlHighlightListAccentLowBrush"); + Highlight(); + } + + private IBrush Resource(Color fallback, params string[] keys) + { + foreach (var key in keys) + { + if (this.TryFindResource(key, ActualThemeVariant, out var value)) + { + switch (value) + { + case IBrush brush: + return brush; + case Color color: + return new SolidColorBrush(color); + } + } + } + return new SolidColorBrush(fallback); + } + private void Highlight() { for (var i = 0; i < panel.Children.Count; i++) { if (panel.Children[i] is Button button) - button.Background = i == selectedIndex && Mode == MathQuickBarMode.Completions ? new SolidColorBrush(Color.FromArgb(0x40, 0x33, 0x99, 0xFF)) : Brushes.Transparent; + button.Background = i == selectedIndex && Mode == MathQuickBarMode.Completions ? highlight : Brushes.Transparent; } } } diff --git a/src/SharpEquations.Avalonia/MathView.cs b/src/SharpEquations.Avalonia/MathView.cs index 702d1d0..31c7909 100644 --- a/src/SharpEquations.Avalonia/MathView.cs +++ b/src/SharpEquations.Avalonia/MathView.cs @@ -108,7 +108,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e /// public override void Render(DrawingContext context) { - drawing?.Draw(context, default, new MathBrushes(Foreground, null, Brushes.MistyRose)); + drawing?.Draw(context, default, new MathBrushes(Foreground, null, MathDrawing.ErrorBrush(ActualThemeVariant))); } } } diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs index 293ab5d..4f2979e 100644 --- a/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Controls/QuickBarTests.cs @@ -126,6 +126,55 @@ public async Task PictureOfTheCompletions() await Assert.That(open).IsTrue(); } + private static double Luminance(IBrush? brush) => brush is ISolidColorBrush { Color: var c } ? (0.2126 * c.R + 0.7152 * c.G + 0.0722 * c.B) / 255 : -1; + + /// In the dark theme the bar is dark, with light text; in the light theme the other way. + [Test] + [Arguments("Dark")] + [Arguments("Light")] + public async Task FollowsTheTheme(string variant) + { + var (background, foreground) = await Headless.Run(() => + { + var editor = new MathEditBox { FontSize = 28 }; + var dark = variant == "Dark"; + var window = new Window + { + Content = new StackPanel { Children = { editor } }, + Width = 700, + Height = 300, + RequestedThemeVariant = dark ? global::Avalonia.Styling.ThemeVariant.Dark : global::Avalonia.Styling.ThemeVariant.Light, + }; + window.Show(); + editor.Focus(); + try + { + window.KeyTextInput("x+\\al"); + Settle(window); + if (Environment.GetEnvironmentVariable("SHARPEQUATIONS_SCREENSHOTS") is { } directory && window.CaptureRenderedFrame() is { } frame) + { + Directory.CreateDirectory(directory); + frame.Save(Path.Combine(directory, "quickbar-" + variant.ToLowerInvariant() + ".png"), global::Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); + } + return (Luminance(editor.QuickBar.Background), Luminance(global::Avalonia.Controls.Documents.TextElement.GetForeground(editor.QuickBar))); + } + finally + { + window.Close(); + } + }); + if (variant == "Dark") + { + await Assert.That(background).IsBetween(0, 0.4); + await Assert.That(foreground).IsGreaterThan(0.6); + } + else + { + await Assert.That(background).IsGreaterThan(0.6); + await Assert.That(foreground).IsBetween(0, 0.4); + } + } + [Test] public async Task DisabledBarStaysClosed() { From 3f2d99b8a00f95eeb753221f0a040c203b4920c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:29:16 +0000 Subject: [PATCH 34/45] [no-new-version] Paste the MathML of Word: the clipboard format as UTF-8 bytes Word writes the "MathML" clipboard format as UTF-8 bytes; read as a string of Windows, as UTF-16, each pair of bytes became a CJK character, and the MathML that did not parse was inserted as text, before the plain text of the clipboard. The format is now read and written as bytes (MathClipboard: UTF-8, or UTF-16 with its byte order mark), and MathML or OMML that does not parse inserts nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H --- docs/EDITOR.md | 5 ++- src/SharpEquations.Avalonia/MathClipboard.cs | 38 +++++++++++++++++++ src/SharpEquations.Avalonia/MathEditBox.cs | 15 +++++--- .../PublicAPI.Unshipped.txt | 5 ++- src/SharpEquations.Editing/MathEditor.cs | 9 ++++- .../Controls/ClipboardTests.cs | 38 +++++++++++++++++++ .../Editing/EditorTests.cs | 23 +++++++++++ 7 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 src/SharpEquations.Avalonia/MathClipboard.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/Controls/ClipboardTests.cs diff --git a/docs/EDITOR.md b/docs/EDITOR.md index 51ceb35..70ca930 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -35,10 +35,11 @@ The behaviour is the one of the equations of Word and of `RichEditBox` in math m paste, undo... are those of the platform (`PlatformHotkeyConfiguration`). - **Mouse**: a click places the caret at the nearest position of the deepest row under the pointer; a drag selects. - **Clipboard**: copy puts the selection as text in `CopyTextFormat` (the input format by default; MathML pastes as an - equation into Word) and as MathML (`MathML` on Windows, `application/mathml+xml` elsewhere); a handler of `Copying` + equation into Word) and as MathML (`MathML` on Windows, `application/mathml+xml` elsewhere), as UTF-8 bytes, as Word writes and reads + that format (`MathClipboard` decodes UTF-8, or UTF-16 with its byte order mark); a handler of `Copying` can add formats or cancel; `GetSelectedText(format)` gives the selection in any format (UnicodeMath, LaTeX, MathML, OMML) without the clipboard. Paste reads MathML, then the text in the input format or as MathML, and inserts plain - text as a run when it does not parse. + text as a run when it does not parse; MathML that does not parse inserts nothing. - **Input**: one format at a time, UnicodeMath or LaTeX, switched at any moment (what is built up stays). UnicodeMath already reads the TeX control words of Word (`\alpha`, `\sqrt`, `\sum`); a LaTeX control word followed by a space waits for its argument when it has one (`\mathring a`), and is built up when it is complete (`\alpha␣`). diff --git a/src/SharpEquations.Avalonia/MathClipboard.cs b/src/SharpEquations.Avalonia/MathClipboard.cs new file mode 100644 index 0000000..de08d1e --- /dev/null +++ b/src/SharpEquations.Avalonia/MathClipboard.cs @@ -0,0 +1,38 @@ +namespace SharpEquations.Avalonia +{ + using System; + using System.Text; + + /// + /// The MathML of the clipboard as bytes: Word writes and reads the "MathML" format as UTF-8 (and other programs the + /// text of application/mathml+xml), not as the UTF-16 of the text formats of Windows. + /// + public static class MathClipboard + { + /// The bytes of MathML for the clipboard: UTF-8, without a byte order mark. + public static byte[] Encode(string mathML) => new UTF8Encoding(false).GetBytes(mathML ?? throw new ArgumentNullException(nameof(mathML))); + + /// + /// The MathML of bytes of the clipboard: UTF-8, or UTF-16 with its byte order mark or as the zeros between its + /// ASCII characters show it, without the zeros that end the data. + /// + public static string Decode(byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF) + return Trim(Encoding.UTF8.GetString(data, 3, data.Length - 3)); + if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0xFE) + return Trim(Encoding.Unicode.GetString(data, 2, data.Length - 2)); + if (data.Length >= 2 && data[0] == 0xFE && data[1] == 0xFF) + return Trim(Encoding.BigEndianUnicode.GetString(data, 2, data.Length - 2)); + if (data.Length >= 4 && data[0] != 0 && data[1] == 0 && data[2] != 0 && data[3] == 0) + return Trim(Encoding.Unicode.GetString(data)); + if (data.Length >= 4 && data[0] == 0 && data[1] != 0 && data[2] == 0 && data[3] != 0) + return Trim(Encoding.BigEndianUnicode.GetString(data)); + return Trim(Encoding.UTF8.GetString(data)); + } + + private static string Trim(string text) => text.TrimEnd('\0').Trim(); + } +} diff --git a/src/SharpEquations.Avalonia/MathEditBox.cs b/src/SharpEquations.Avalonia/MathEditBox.cs index 55be9c7..e9e309f 100644 --- a/src/SharpEquations.Avalonia/MathEditBox.cs +++ b/src/SharpEquations.Avalonia/MathEditBox.cs @@ -355,7 +355,7 @@ public async Task CopyAsync() var item = new DataTransferItem(); var format = CopyTextFormat ?? Editor.InputFormat; item.SetText(format is EquationFormat.UnicodeMath or EquationFormat.LaTeX ? Editor.GetSelectedText(format)! : equation.ToString(format)); - item.Set(MathMLClipboardFormat, equation.ToString(EquationFormat.MathML)); + item.Set(MathMLClipboardFormat, MathClipboard.Encode(equation.ToString(EquationFormat.MathML))); var args = new MathCopyingEventArgs(equation, item); Copying?.Invoke(this, args); if (args.Cancel) @@ -380,8 +380,8 @@ public async Task PasteAsync() if (IsReadOnly || TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; var formats = await clipboard.GetDataFormatsAsync().ConfigureAwait(true); - if (formats.Contains(MathMLClipboardFormat) && await clipboard.TryGetValueAsync(MathMLClipboardFormat).ConfigureAwait(true) is { Length: > 0 } mathML - && Editor.Paste(mathML, EquationFormat.MathML)) + if (formats.Contains(MathMLClipboardFormat) && await clipboard.TryGetValueAsync(MathMLClipboardFormat).ConfigureAwait(true) is { Length: > 0 } bytes + && Editor.Paste(MathClipboard.Decode(bytes), EquationFormat.MathML)) { return; } @@ -394,9 +394,12 @@ public async Task PasteAsync() Editor.Paste(text, Editor.InputFormat); } - /// The format of MathML on the clipboard: MathML on Windows (which Word reads), application/mathml+xml elsewhere. - public static DataFormat MathMLClipboardFormat { get; } = - DataFormat.CreateStringPlatformFormat(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MathML" : "application/mathml+xml"); + /// + /// The format of MathML on the clipboard, as bytes (): MathML on Windows, which + /// Word reads and writes as UTF-8, application/mathml+xml elsewhere. + /// + public static DataFormat MathMLClipboardFormat { get; } = + DataFormat.CreateBytesPlatformFormat(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MathML" : "application/mathml+xml"); /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) diff --git a/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt index 0f3e132..dcc32f4 100644 --- a/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt +++ b/src/SharpEquations.Avalonia/PublicAPI.Unshipped.txt @@ -13,6 +13,7 @@ SharpEquations.Avalonia.AvaloniaMathFont.UnitsPerEm.get -> double SharpEquations.Avalonia.EquationChangedEventArgs SharpEquations.Avalonia.EquationChangedEventArgs.Equation.get -> SharpEquations.Equation? SharpEquations.Avalonia.EquationChangedEventArgs.EquationChangedEventArgs(SharpEquations.Equation? equation) -> void +SharpEquations.Avalonia.MathClipboard SharpEquations.Avalonia.MathCopyingEventArgs SharpEquations.Avalonia.MathCopyingEventArgs.Cancel.get -> bool SharpEquations.Avalonia.MathCopyingEventArgs.Cancel.set -> void @@ -128,7 +129,9 @@ override SharpEquations.Avalonia.MathEditBoxAutomationPeer.GetNameCore() -> stri override SharpEquations.Avalonia.MathView.MeasureOverride(Avalonia.Size availableSize) -> Avalonia.Size override SharpEquations.Avalonia.MathView.OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs! e) -> void override SharpEquations.Avalonia.MathView.Render(Avalonia.Media.DrawingContext! context) -> void -static SharpEquations.Avalonia.MathEditBox.MathMLClipboardFormat.get -> Avalonia.Input.DataFormat! +static SharpEquations.Avalonia.MathClipboard.Decode(byte[]! data) -> string! +static SharpEquations.Avalonia.MathClipboard.Encode(string! mathML) -> byte[]! +static SharpEquations.Avalonia.MathEditBox.MathMLClipboardFormat.get -> Avalonia.Input.DataFormat! static SharpEquations.Avalonia.MathFonts.Default.get -> SharpEquations.Avalonia.AvaloniaMathFont! static SharpEquations.Avalonia.MathFonts.Get(Avalonia.Media.FontFamily? family) -> SharpEquations.Avalonia.AvaloniaMathFont! static SharpEquations.Avalonia.MathFonts.LibertinusMath.get -> Avalonia.Media.FontFamily! diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs index 42b2402..ac3ead0 100644 --- a/src/SharpEquations.Editing/MathEditor.cs +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -244,8 +244,9 @@ public void InsertNodes(IEnumerable nodes) } /// - /// Pastes text: an equation in the format inserted as nodes, or the text as a run if it does not parse. Returns - /// whether the text was read as an equation. + /// Pastes text: an equation in the format inserted as nodes, or, for UnicodeMath and LaTeX, the text as a run if it + /// does not parse; MathML or OMML that does not parse inserts nothing. Returns whether the text was read as an + /// equation. /// public bool Paste(string text, EquationFormat format) { @@ -257,6 +258,10 @@ public bool Paste(string text, EquationFormat format) return true; } + // markup that does not read is not text to type + if (format is not (EquationFormat.UnicodeMath or EquationFormat.LaTeX)) + return false; + PushUndo(); if (Selection != null) RemoveSelection(); diff --git a/tests/SharpEquations.Avalonia.Tests/Controls/ClipboardTests.cs b/tests/SharpEquations.Avalonia.Tests/Controls/ClipboardTests.cs new file mode 100644 index 0000000..2b0ea59 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/Controls/ClipboardTests.cs @@ -0,0 +1,38 @@ +namespace SharpEquations.Avalonia.Tests.Controls +{ + using System.Text; + using System.Threading.Tasks; + + /// The bytes of the MathML of the clipboard, as Word and others write them. + public class ClipboardTests + { + private const string MathML = "A=πr2"; + + [Test] + public async Task Utf8AsWordWritesIt() + { + await Assert.That(MathClipboard.Decode(Encoding.UTF8.GetBytes(MathML + "\0"))).IsEqualTo(MathML); + await Assert.That(MathClipboard.Decode(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(MathML)))).IsEqualTo(MathML); + await Assert.That(MathClipboard.Encode(MathML)).IsEquivalentTo(Encoding.UTF8.GetBytes(MathML)); + } + + [Test] + public async Task Utf16WithOrWithoutItsMark() + { + await Assert.That(MathClipboard.Decode(Encoding.Unicode.GetBytes(MathML + "\0"))).IsEqualTo(MathML); + await Assert.That(MathClipboard.Decode(Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes(MathML)))).IsEqualTo(MathML); + await Assert.That(MathClipboard.Decode(Encoding.BigEndianUnicode.GetBytes(MathML))).IsEqualTo(MathML); + } + } + + internal static class ByteArrays + { + public static byte[] Concat(this byte[] first, byte[] second) + { + var result = new byte[first.Length + second.Length]; + first.CopyTo(result, 0); + second.CopyTo(result, first.Length); + return result; + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs index 3a5eb06..f570ac8 100644 --- a/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Editing/EditorTests.cs @@ -125,5 +125,28 @@ public async Task EmptyEquation() editor.MoveUp(); await Assert.That(editor.Caret.ToString()).IsEqualTo("/0:0"); } + + // the MathML Word puts on the clipboard: an XML declaration, the mml prefix, and the namespace of OMML + private const string WordMathML = "A=" + + "πr2"; + + [Test] + public async Task PasteTheMathMLOfWord() + { + var editor = new MathEditor(); + await Assert.That(editor.Paste(WordMathML, EquationFormat.MathML)).IsTrue(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse("A=πr^2", EquationFormat.UnicodeMath)); + } + + /// MathML that does not read (the UTF-8 of Word read as UTF-16) inserts nothing, rather than its text. + [Test] + public async Task MathMLThatDoesNotReadInsertsNothing() + { + var editor = new MathEditor(); + var garbled = System.Text.Encoding.Unicode.GetString(System.Text.Encoding.UTF8.GetBytes(WordMathML + " ")); + await Assert.That(editor.Paste(garbled, EquationFormat.MathML)).IsFalse(); + await Assert.That(editor.IsEmpty).IsTrue(); + } } } From 4513d530ff8113bd63a0d2b712430cbba4e29ed9 Mon Sep 17 00:00:00 2001 From: Simone Bortolin Date: Mon, 9 Nov 2026 19:39:46 +0100 Subject: [PATCH 35/45] Fix slnx demo name --- SharpEquations.slnx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/SharpEquations.slnx b/SharpEquations.slnx index 4a96c46..79c3858 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -1,4 +1,7 @@ + + + @@ -9,20 +12,17 @@ - - - + - - - + + - + From 688e13e650ea665925ae96da64b98dd4faf22edf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:41:45 +0000 Subject: [PATCH 36/45] mhchem: prescripts written as \prescript, which the LaTeX reader now reads Since main reads a script without a base as one on an empty base ({^3H} is {}^3 H, as in TeX), the prescripts mhchem wrote at the start of a group were read as scripts of an empty base. The reader reads \prescript of mathtools as UnicodeMath prescripts, and mhchem writes them with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn --- src/SharpEquations/Internal/LaTeX/LaTeXReader.cs | 13 +++++++++++++ src/SharpEquations/Internal/LaTeX/Mhchem.cs | 2 +- .../SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs index 9af47f8..30118c1 100644 --- a/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs +++ b/src/SharpEquations/Internal/LaTeX/LaTeXReader.cs @@ -718,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(')'); diff --git a/src/SharpEquations/Internal/LaTeX/Mhchem.cs b/src/SharpEquations/Internal/LaTeX/Mhchem.cs index 8c4ab5f..e447a7b 100644 --- a/src/SharpEquations/Internal/LaTeX/Mhchem.cs +++ b/src/SharpEquations/Internal/LaTeX/Mhchem.cs @@ -76,7 +76,7 @@ public string ToLaTeX() if (Charge != null) latex += "^{" + Charge + "}"; if (Mass != null || Z != null) - latex = "{" + (Mass != null ? "^{" + Mass + "}" : "") + (Z != null ? "_{" + Z + "}" : "") + latex + "}"; + latex = "\\prescript{" + Mass + "}{" + Z + "}{" + latex + "}"; return latex; } } diff --git a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs index f39ec2b..bf94513 100644 --- a/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs +++ b/tests/SharpEquations.Tests/LaTeX/LaTeXReaderTests.cs @@ -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", @"⬚³+⬚′𝑥")] From 10e953a00476d17bcfe584868872ae1d1c8b1cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 19:10:47 +0000 Subject: [PATCH 37/45] [no-new-version] The equation editor demo on WebAssembly samples/SharpEquations.Avalonia.Browser.Demo runs the demo in the browser with Avalonia.Browser: Inter for the text and the embedded Libertinus Math as its fallback, System.Private.Xml kept whole for the OMML stylesheets, a splash with the download progress. The desktop window and the page share DemoView, now redesigned: a header, cards, a ribbon of structures drawn as formulas, symbols by group, a gallery of examples rendered with MathView, the outputs with a copy button, the light and the dark theme, and a layout for narrow screens. The project needs the wasm-tools workload, so it is not in the solution: .github/workflows/demo.yml builds it and publishes it to GitHub Pages from main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA --- .github/workflows/demo.yml | 68 ++ AGENTS.md | 3 +- Directory.Packages.props | 2 + README.md | 4 +- .../Program.cs | 36 + .../Properties.json | 0 ...harpEquations.Avalonia.Browser.Demo.csproj | 42 ++ .../wwwroot/app.css | 123 ++++ .../wwwroot/favicon.svg | 1 + .../wwwroot/index.html | 29 + .../wwwroot/main.js | 28 + .../SharpEquations.Avalonia.Demo/DemoTheme.cs | 23 + .../SharpEquations.Avalonia.Demo/DemoView.cs | 624 ++++++++++++++++++ .../MainWindow.cs | 298 +-------- .../SharpEquations.Avalonia.Demo/Program.cs | 4 +- 15 files changed, 986 insertions(+), 299 deletions(-) create mode 100644 .github/workflows/demo.yml create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/Program.cs create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/Properties.json create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html create mode 100644 samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js create mode 100644 samples/SharpEquations.Avalonia.Demo/DemoTheme.cs create mode 100644 samples/SharpEquations.Avalonia.Demo/DemoView.cs diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 0000000..3495af2 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,68 @@ +name: Web demo + +# The equation editor on WebAssembly (samples/SharpEquations.Avalonia.Browser.Demo): built on every pull request that +# touches it, published to GitHub Pages from main (Settings → Pages → Source: GitHub Actions). +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: demo-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + PROJECT: samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj + +jobs: + build: + name: Build the web demo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Install the WebAssembly workload + run: dotnet workload install wasm-tools + + - name: Publish + run: dotnet publish "$PROJECT" -c Release -o publish + + # GitHub Pages serves the site from a folder: no Jekyll, and the index for any path that is not a file + - name: Prepare the site + run: | + touch publish/wwwroot/.nojekyll + cp publish/wwwroot/index.html publish/wwwroot/404.html + + - uses: actions/upload-pages-artifact@v4 + with: + path: publish/wwwroot + + deploy: + name: Deploy to GitHub Pages + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/AGENTS.md b/AGENTS.md index 3ac781a..1b744b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations.Editing/` | The equation editor's model, no user interface: `MathNode` tree, caret, selection, undo, build up and build down | | `src/SharpEquations.Rendering/` | The layout of MathML Core over the OpenType MATH table, no user interface: `MathLayoutEngine`, `MathLayout` | | `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | -| `samples/SharpEquations.Avalonia.Demo/` | A window with the editor | +| `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | +| `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | | `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia) | | `tests/data/` | Test data and its provenance (`tests/data/README.md`) | diff --git a/Directory.Packages.props b/Directory.Packages.props index fb1e0f2..9832a40 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,9 @@ + + diff --git a/README.md b/README.md index 0e950e2..9e5ba3d 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,9 @@ a math font: Cambria Math on Windows, and Libertinus Math (SIL Open Font License three projects, so that the core keeps no dependency: `SharpEquations.Editing` (the editing model, no user interface), `SharpEquations.Rendering` (the layout, no user interface) and `SharpEquations.Avalonia` (the controls). The design is in [docs/EDITOR.md](docs/EDITOR.md); `samples/SharpEquations.Avalonia.Demo` is a window with the editor and its MathML, -UnicodeMath and LaTeX. +UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the same demo in the browser, on WebAssembly +(published to GitHub Pages from `main`; to run it locally: `dotnet workload install wasm-tools`, then +`dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). ## Architecture diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs b/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs new file mode 100644 index 0000000..fa16be3 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs @@ -0,0 +1,36 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System.Runtime.Versioning; + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Browser; + using global::Avalonia.Controls.ApplicationLifetimes; + using global::Avalonia.Media; + + [SupportedOSPlatform("browser")] + public static class Program + { + public static Task Main() => BuildAvaloniaApp().StartBrowserAppAsync("out"); + + // A page has no fonts of its own: Inter for the text, and the embedded Libertinus Math for the symbols it lacks. + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .WithInterFont() + .With(new FontManagerOptions + { + DefaultFamilyName = "fonts:Inter#Inter", + FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("fonts:SharpEquations#Libertinus Math") } }, + }); + } + + public sealed class App : Application + { + public override void Initialize() => DemoTheme.Apply(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is ISingleViewApplicationLifetime view) + view.MainView = new DemoView(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/Properties.json b/samples/SharpEquations.Avalonia.Browser.Demo/Properties.json new file mode 100644 index 0000000..e69de29 diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj b/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj new file mode 100644 index 0000000..92a9aa3 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj @@ -0,0 +1,42 @@ + + + + + net10.0-browser + Exe + false + enable + SharpEquations.Avalonia.Demo + true + + false + false + true + + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css new file mode 100644 index 0000000..7173715 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css @@ -0,0 +1,123 @@ +:root { + --page: #f3f4fa; + --ink: #1f2333; + --muted: #6b7280; + --track: rgba(79, 70, 229, 0.15); +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #0d0f16; + --ink: #e7e9f2; + --muted: #9aa3b5; + --track: rgba(165, 180, 252, 0.18); + } +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + background: var(--page); + color: var(--ink); + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +#out { + position: fixed; + inset: 0; +} + +#splash { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: + radial-gradient(60rem 40rem at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%), + radial-gradient(50rem 40rem at 110% 110%, rgba(192, 38, 211, 0.28), transparent 60%), + var(--page); + transition: opacity 0.45s ease, visibility 0.45s; + z-index: 10; +} + +#splash.done { + opacity: 0; + visibility: hidden; +} + +#splash .card { + text-align: center; + padding: 2.5rem 3rem; + max-width: 26rem; +} + +#splash .formula { + display: flex; + justify-content: center; + gap: 0.9rem; + font-size: 2.6rem; + font-family: "Cambria Math", "STIX Two Math", "Latin Modern Math", serif; + background: linear-gradient(120deg, #4338ca, #7c3aed 55%, #c026d3); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +#splash .formula span { + display: inline-block; + animation: float 1.8s ease-in-out infinite; +} + +#splash .formula span:nth-child(2) { animation-delay: 0.15s; } +#splash .formula span:nth-child(3) { animation-delay: 0.3s; } +#splash .formula span:nth-child(4) { animation-delay: 0.45s; } + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.4rem); } +} + +#splash h1 { + margin: 0.8rem 0 0.3rem; + font-size: 1.7rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +#splash p { + margin: 0 0 1.4rem; + color: var(--muted); +} + +#splash .bar { + height: 6px; + border-radius: 999px; + background: var(--track); + overflow: hidden; +} + +#splash #progress { + width: 4%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #4338ca, #7c3aed, #c026d3); + transition: width 0.25s ease; +} + +#splash small { + display: block; + margin-top: 1rem; + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.02em; +} + +#splash.failed .bar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + #splash .formula span { animation: none; } +} diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg new file mode 100644 index 0000000..afdf5ff --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg @@ -0,0 +1 @@ +∑ diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html new file mode 100644 index 0000000..9c1d9e8 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html @@ -0,0 +1,29 @@ + + + + + + SharpEquations — equation editor + + + + + + + + +
+
+
+
+ ∑∫√π +
+

SharpEquations

+

Loading the equation editor…

+
+ .NET on WebAssembly · Avalonia · OpenType MATH +
+
+ + + diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js new file mode 100644 index 0000000..45b77b5 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js @@ -0,0 +1,28 @@ +import { dotnet } from './_framework/dotnet.js'; + +const splash = document.getElementById('splash'); +const progress = document.getElementById('progress'); + +try { + const runtime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .withResourceLoader((type, name, url) => url) + .withModuleConfig({ + onDownloadResourceProgress: (loaded, total) => { + if (total > 0) progress.style.width = `${Math.round(100 * loaded / total)}%`; + }, + }) + .create(); + const config = runtime.getConfig(); + const started = runtime.runMain(config.mainAssemblyName, [globalThis.location.href]); + const hide = () => splash.classList.add('done'); + new MutationObserver((_, observer) => { + if (document.querySelector('#out canvas')) { hide(); observer.disconnect(); } + }).observe(document.getElementById('out'), { childList: true, subtree: true }); + await started; +} catch (error) { + splash.classList.add('failed'); + document.getElementById('message').textContent = `The demo could not start: ${error}`; + throw error; +} diff --git a/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs b/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs new file mode 100644 index 0000000..e31072b --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs @@ -0,0 +1,23 @@ +namespace SharpEquations.Avalonia.Demo +{ + using global::Avalonia; + using global::Avalonia.Media; + using global::Avalonia.Styling; + using global::Avalonia.Themes.Fluent; + + /// The Fluent theme of the demos, with an indigo accent in the light and the dark variant. + public static class DemoTheme + { + public static void Apply(Application application) + { + application.Styles.Add(new FluentTheme + { + Palettes = + { + [ThemeVariant.Light] = new ColorPaletteResources { Accent = Color.FromRgb(0x4F, 0x46, 0xE5) }, + [ThemeVariant.Dark] = new ColorPaletteResources { Accent = Color.FromRgb(0x81, 0x8C, 0xF8) }, + }, + }); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Demo/DemoView.cs b/samples/SharpEquations.Avalonia.Demo/DemoView.cs new file mode 100644 index 0000000..7b1424d --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/DemoView.cs @@ -0,0 +1,624 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System; + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Primitives; + using global::Avalonia.Input; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using global::Avalonia.Media.Immutable; + using global::Avalonia.Styling; + using SharpEquations; + using SharpEquations.Editing; + + /// + /// The equation editor with the tools of the equations of Word: structures and symbols to insert, the input format + /// (UnicodeMath or LaTeX), the linear and built up views, the number of the equation, copy in every format, a gallery + /// of examples, and the equation as MathML, UnicodeMath, LaTeX and OMML as it is typed. The desktop window and the + /// WebAssembly page show the same view. + /// + public sealed class DemoView : UserControl + { + private const string RepositoryUrl = "https://github.com/simonebortolin/SharpEquations"; + + private static readonly (string Name, string UnicodeMath)[] Examples = + { + ("Quadratic formula", "x=(-b±√(b^2-4ac))/2a"), + ("Euler's identity", "e^(iπ)+1=0"), + ("Gaussian integral", "∫_-∞^∞ e^(-x^2) ⅆx=√π"), + ("Sum of squares", "∑_(k=1)^n k^2=(n(n+1)(2n+1))/6"), + ("Binomial theorem", "(a+b)^n=∑_(k=0)^n (n¦k) a^k b^(n-k)"), + ("Matrix inverse", "(■(a&b@c&d))^(-1)=1/(ad-bc) (■(d&-b@-c&a))"), + ("Absolute value", "|x|={■(x&x≥0@-x&x<0)┤"), + ("A limit", "lim┬(x→0) sin⁡x/x=1"), + ("Ampère–Maxwell", "∇×𝐁=μ_0 (𝐉+ε_0 ∂𝐄/∂t)"), + ("Fourier transform", "f̂(ξ)=∫_-∞^∞ f(x) e^(-2πixξ) ⅆx"), + ("Numbered", "E=mc^2#(1)"), + ("Aligned lines", "█(f(x)&=(x+1)^2@&=x^2+2x+1)"), + }; + + private readonly MathEditBox editor; + private readonly MathView preview; + private readonly (string Name, Func Write, TextBox Box)[] outputs; + private readonly TabControl outputTabs = new TabControl(); + private readonly TextBlock status = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + private readonly ToggleSwitch linear = new ToggleSwitch { OnContent = "Linear", OffContent = "Built up" }; + private readonly TextBox number = new TextBox { PlaceholderText = "(1)", Width = 64 }; + private readonly Grid columns = new Grid(); + private readonly Control outputCard; + private readonly Control examplesCard; + + public DemoView() + { + Resources.ThemeDictionaries[ThemeVariant.Light] = new ResourceDictionary + { + ["PageBrush"] = Solid(0xF3F4FA), + ["CardBrush"] = Solid(0xFFFFFF), + ["CardBorderBrush"] = Solid(0xE3E5F0), + ["MutedBrush"] = Solid(0x6B7280), + ["EditorBrush"] = Solid(0xFCFCFF), + ["CodeBrush"] = Solid(0xF6F7FB), + ["ChipBrush"] = Solid(0xEEF0FB), + ["EditorBorderBrush"] = Solid(0x818CF8), + }; + Resources.ThemeDictionaries[ThemeVariant.Dark] = new ResourceDictionary + { + ["PageBrush"] = Solid(0x0D0F16), + ["CardBrush"] = Solid(0x161923), + ["CardBorderBrush"] = Solid(0x262A38), + ["MutedBrush"] = Solid(0x9AA3B5), + ["EditorBrush"] = Solid(0x10131B), + ["CodeBrush"] = Solid(0x0F121A), + ["ChipBrush"] = Solid(0x1E2230), + ["EditorBorderBrush"] = Solid(0x6366F1), + }; + Styles.Add(new Style(x => x.OfType

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 font of Avalonia for the layout of equations: its glyphs, metrics and OpenType MATH table. + public sealed class AvaloniaMathFont : IMathFont + { + private readonly Dictionary advances = new Dictionary(); + private readonly Dictionary fallbacks = new Dictionary(StringComparer.Ordinal); + private readonly Lazy math; + private readonly Lazy outlines; + private readonly Lazy file; + + ///