diff --git a/src/SharpEquations.Avalonia/AvaloniaMathFont.cs b/src/SharpEquations.Avalonia/AvaloniaMathFont.cs index 1538414..58bc98a 100644 --- a/src/SharpEquations.Avalonia/AvaloniaMathFont.cs +++ b/src/SharpEquations.Avalonia/AvaloniaMathFont.cs @@ -60,7 +60,20 @@ public AvaloniaMathFont(Typeface typeface) public double GetAdvance(ushort glyph) => file.Advance(glyph); /// - public GlyphBounds GetBounds(ushort glyph) => outlines.Value?.Bounds(glyph) ?? default; + public GlyphBounds GetBounds(ushort glyph) => outlines.Value?.Bounds(glyph) ?? LineBounds(glyph); + + /// + /// The box of a glyph of a font without outlines (the bitmaps of a color emoji font, CBDT), which has no + /// ink box to read: its advance across, and the ascent and descent of the font. + /// + private GlyphBounds LineBounds(ushort glyph) + { + if (file.Table("hhea") is not { Length: >= 8 } hhea) + return default; + var ascent = (short)(hhea[4] << 8 | hhea[5]); + var descent = (short)(hhea[6] << 8 | hhea[7]); + return new GlyphBounds(0, descent, file.Advance(glyph), ascent); + } /// public ushort GetScriptVariant(ushort glyph, int scriptLevel) => scriptStyles.Value?.Variant(glyph, scriptLevel) ?? glyph; diff --git a/src/SharpEquations.Editing/BuildUp.cs b/src/SharpEquations.Editing/BuildUp.cs index e9977a1..a1ec447 100644 --- a/src/SharpEquations.Editing/BuildUp.cs +++ b/src/SharpEquations.Editing/BuildUp.cs @@ -251,6 +251,26 @@ public static bool IsBalanced(string text, EquationFormat format) return used.All(u => u) ? nodes : null; } + /// + /// Where the nodes a run at an index of a row builds up with start: after the opening fence when the row is a pair + /// of fences and the run is between them, since the opening fence alone does not always parse (|x). + /// + public static int OperandStart(IReadOnlyList row, int index) + { + if (index <= 0 || index >= row.Count - 1) + return 0; + var open = row[0]; + var close = row[row.Count - 1]; + if (open.Name != "mo" || close.Name != "mo" || !IsFence(open.Text) || !IsFence(close.Text)) + return 0; + for (var i = 1; i < row.Count - 1; i++) + { + if (row[i].Name == "mo" && (row[i].Text == open.Text || row[i].Text == close.Text)) + return 0; + } + return 1; + } + /// The nodes of the row with every run built up where it parses. public static MathNode BuildAll(MathNode node, EquationFormat format, bool display) { @@ -264,12 +284,13 @@ public static MathNode BuildAll(MathNode node, EquationFormat format, bool displ { if (!children[j].IsRun) continue; - var built = Parse(children.Take(j).ToList(), children[j].Text, format, display); + var start = OperandStart(children, j); + var built = Parse(children.Skip(start).Take(j - start).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; + children = children.Take(start).Concat(built).Concat(after).ToList(); + j = start + built.Count - 1; } } return node.WithChildren(children); diff --git a/src/SharpEquations.Editing/MathEditor.cs b/src/SharpEquations.Editing/MathEditor.cs index 405ec6a..ea2edd4 100644 --- a/src/SharpEquations.Editing/MathEditor.cs +++ b/src/SharpEquations.Editing/MathEditor.cs @@ -1008,7 +1008,8 @@ private bool TryBuildUpAtCaret(bool space, int held = 0, bool recordUndo = true) if (!BuildUp.IsBalanced(text, InputFormat) || InputFormat == EquationFormat.LaTeX && BuildUp.AwaitsArguments(text)) return false; - var nodes = BuildUp.Parse(row.Children.Take(j).ToList(), text, InputFormat, IsDisplay); + var start = BuildUp.OperandStart(row.Children, j); + var nodes = BuildUp.Parse(row.Children.Skip(start).Take(j - start).ToList(), text, InputFormat, IsDisplay); if (nodes == null || space && BuildUp.EndsWithPendingCommand(text, nodes)) return false; @@ -1017,14 +1018,14 @@ private bool TryBuildUpAtCaret(bool space, int held = 0, bool recordUndo = true) undo.Push(Snapshot()); redo.Clear(); } - var children = nodes.ToList(); + var children = row.Children.Take(start).Concat(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 = held > 0 - ? new MathPosition(MathTree.Append(rowPath, nodes.Count), held) - : Canonical(new MathPosition(rowPath, nodes.Count)); + ? new MathPosition(MathTree.Append(rowPath, start + nodes.Count), held) + : Canonical(new MathPosition(rowPath, start + nodes.Count)); return true; } diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index 42bcb67..c4b0d88 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -507,8 +507,9 @@ private string Binary(XElement node, string op) if (retd.Length == 0) retd = "γ€–γ€—"; - // a scripted base (π‘Ž_𝑏 under ^𝑐) is grouped, as π‘Ž_𝑏^𝑐 is one subsup - if ((op == "^" || op == "_" || IsPrime(Text(Last(node)))) && IsScripted(First(node)) && functionName == null) + // a scripted base (π‘Ž_𝑏 under ^𝑐) is grouped, as π‘Ž_𝑏^𝑐 is one subsup, and so is a matrix in brackets (β’¨(…)), + // which a script after it would not apply to + if ((op == "^" || op == "_" || IsPrime(Text(Last(node)))) && (IsScripted(First(node)) || IsBracketedMatrix(First(node))) && functionName == null) ret = "γ€–" + ret.TrimEnd(' ') + "γ€—"; if (op == "^" && Name(Last(node)) == "msub" || op == "_" && Name(Last(node)) == "msup") return ret + op + "(" + retd + ")"; @@ -522,6 +523,14 @@ private string Binary(XElement node, string op) // a script that applies a function (log_(π‘β‘π‘Ž)): the space before the function name would end the script if ((op == "^" || op == "_") && Name(Last(node)) == "mrow" && Role(Last(node)) == ":function") retd = "(" + retd.Trim(' ') + ")"; + // a denominator that applies a function (π‘Ž/sin⁑𝛼): a space after the fraction bar would end the fraction + if (Name(node) == "mfrac" && Name(Last(node)) == "mrow" && Role(Last(node)) == ":function") + retd = retd.TrimStart(' '); + // a denominator that ends with a script, before an operand that follows the fraction (1/(4πœ‹πœ€β‚€) π‘žβ‚π‘žβ‚‚/π‘ŸΒ²): the + // space after it would end the script, not the denominator (an operator after it ends both) + if (Name(node) == "mfrac" && op == "/" && EndsWithSpacedScript(Last(node)) && node.ElementsAfterSelf().FirstOrDefault() is { } next && Name(next) != "mo" && + !(Ch(retd, 0) == "(" && RemoveOuterParens(retd) != retd)) + retd = "(" + retd.Trim(' ') + ")"; if (IsMathMLObject(node, false) && ChildCount(node) > 0) { var attr = Role(Last(node)); @@ -757,10 +766,20 @@ private static bool EndsWithScript(XElement node) return Name(node) is "msub" or "msup" or "msubsup" or "mover" or "munder" or "munderover"; } + private static bool IsBracketedMatrix(XElement node) => Name(node) == "mrow" && MatrixIntents.Any(m => m.Intent == Attr(node, "intent")); + + // a script that a space after it ends, but not what is before it: all but the superscript digits (π‘₯Β²) + private static bool EndsWithSpacedScript(XElement node) + { + while (Name(node) == "mrow" && ChildCount(node) > 0 && !HasAttr(node, "intent")) + node = Last(node); + return IsScripted(node) && !(Name(node) == "msup" && IsDigitArg(node)); + } + private string ScriptBase(XElement node) { var ret = Dump(node); - if (IsScripted(node) || Name(node) == "mrow" && ChildCount(node) > 1 && !HasAttr(node, "intent")) + if (IsScripted(node) || Name(node) == "mrow" && ChildCount(node) > 1 && !HasAttr(node, "intent") || IsBracketedMatrix(node)) ret = "γ€–" + ret.TrimEnd(' ') + "γ€—"; return ret; } diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs index 89391c8..4045c88 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs @@ -634,6 +634,13 @@ private static JsObject TransformSpaces(object value) foreach (var space in spaceString) spaces += "&#x" + ((int)space).ToString("x", CultureInfo.InvariantCulture) + ";"; } + // a space of a width in eighteenths of an em (the Β½ em of ", "), as the character of that width; none for + // the width 0 of an empty cell + else if (value is JsObject width && Get(width, "space") is { } eighteenths && System.Convert.ToInt32(eighteenths, CultureInfo.InvariantCulture) > 0) + { + foreach (var space in UnicodeMathSerializer.UniSpaces[System.Convert.ToInt32(eighteenths, CultureInfo.InvariantCulture)]) + spaces += "&#x" + ((int)space).ToString("x", CultureInfo.InvariantCulture) + ";"; + } return O("mo", NoAttr(spaces)); } diff --git a/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs index ad07a88..5a45e46 100644 --- a/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/Editing/CommandTests.cs @@ -51,6 +51,24 @@ public async Task FencesAroundTheSelection() await Assert.That(editor.ToEquation()).IsEqualTo(U("[a+b]")); } + /// What is typed between fences builds up without the opening fence, which alone does not parse (|x). + [Test] + [Arguments("|", "|", "|x+1/2|")] + [Arguments("β€–", "β€–", "β€–x+1/2β€–")] + [Arguments("(", ")", "(x+1/2)")] + [Arguments("[", "]", "[x+1/2]")] + [Arguments("⌊", "βŒ‹", "⌊x+1/2βŒ‹")] + public async Task TypedBetweenFencesBuildsUp(string open, string close, string expected) + { + var editor = new MathEditor(); + editor.InsertFences(open, close); + editor.Insert("x+1/2 "); + await Assert.That(editor.ToEquation()).IsEqualTo(U(expected)); + editor.Insert("-y"); + editor.BuildUpAll(); + await Assert.That(editor.ToEquation()).IsEqualTo(U(expected.Substring(0, expected.Length - close.Length) + "-y" + close)); + } + [Test] public async Task SelectedTextInEveryFormat() { diff --git a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs index 458b869..c71470a 100644 --- a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs +++ b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs @@ -35,6 +35,36 @@ public async Task UnicodeMathWritesWhatItReads(string mathML, string unicodeMath await Assert.That(RoundTrip(back, EquationFormat.UnicodeMath)).IsEqualTo(back); } + /// + /// UnicodeMath that reads to an equation the writer wrote as text that read back otherwise: a script of a matrix + /// in brackets (β’¨(…)Β²), a function in a denominator (π‘Ž/ sin⁑𝛼), a denominator that ends with a script before + /// what follows the fraction (1/4πœ‹πœ€β‚€ π‘žβ‚π‘žβ‚‚/π‘ŸΒ²), and the space after a comma that two spaces type (π‘Ž, 𝑏). + /// + [Test] + [Arguments("(β– (a&b@c&d))^2")] + [Arguments("(β– (a&b@c&d))^(-1)=1/(ad-bc) (β– (d&-b@-c&a))")] + [Arguments("[β– (a&b@c&d)]_ij")] + [Arguments("|β– (a&b@c&d)|^2")] + [Arguments("a/sin⁑α")] + [Arguments("a/sin⁑α=b/sin⁑β=c/sin⁑γ")] + [Arguments("(sin⁑α)/a")] + [Arguments("1/(4πΡ_0) (q_1 q_2)/r^2")] + [Arguments("1/(x_1) y")] + [Arguments("1/(ab^2) c/d")] + [Arguments("A=√s, s=2")] + [Arguments("f(x, y)")] + public async Task UnicodeMathReadsBackWhatItWrites(string unicodeMath) + { + var equation = Equation.Parse(unicodeMath, EquationFormat.UnicodeMath); + await Assert.That(RoundTrip(equation, EquationFormat.UnicodeMath)).IsEqualTo(equation).Because(equation.ToString(EquationFormat.UnicodeMath)); + } + + [Test] + public async Task UnicodeMathReadsTwoSpacesAfterACommaAsASpace() + { + await Assert.That(Equation.Parse("a, b", EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("a,\u2002b", EquationFormat.UnicodeMath)); + } + [Test] [Arguments("[a|b]")] [Arguments("[aβ€–b]")]