From 98d21de787222853d366b153b92fc0077b742b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:47:06 +0000 Subject: [PATCH 1/3] [no-new-version] Editor: what is typed between bars builds up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run between a pair of fences was read with the opening fence before it, and an opening bar alone does not parse (|x): what was typed in the |x| and ‖x‖ of the fence buttons never built up, and stayed an merror. The run is now read after the opening fence (BuildUp.OperandStart), when the typing builds up and in BuildUpAll. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio8afJsGwHW9i5v5HDs --- src/SharpEquations.Editing/BuildUp.cs | 27 ++++++++++++++++--- src/SharpEquations.Editing/MathEditor.cs | 9 ++++--- .../Editing/CommandTests.cs | 18 +++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) 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/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() { From 89003c56dbf3566984e953d7cf9f84dfade11e49 Mon Sep 17 00:00:00 2001 From: Simone <26844016+simonebortolin@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:13:06 +0100 Subject: [PATCH 2/3] [no-new-version] UnicodeMath: write what reads back, and read two spaces after a comma (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four equations that read from UnicodeMath and were written as text that read back to another equation: - a script of a matrix in brackets: ⒨(𝑎&𝑏@𝑐&𝑑)² read the ² apart from the matrix (and ^−1 did not parse); the matrix is grouped, 〖⒨(…)〗², as the other bases that a script would not apply to whole - a function in a denominator: 𝑎/ sin⁡𝛼 read as a slash, the space after the bar ending the fraction; the space is not written, 𝑎/sin⁡𝛼 - a denominator that ends with a script before an operand: 1/4𝜋𝜀₀ 𝑞₁𝑞₂/𝑟² took 𝑞₁𝑞₂/𝑟² into the denominator, the space ending the script and not the denominator; it is in parentheses, 1/(4𝜋𝜀₀) (but not after superscript digits, 𝜕²/𝜕𝑥² 𝜓, which a space ends, nor before an operator) - ", " (a comma and two spaces, a space of ½ em after the comma in the grammar) was read as an empty , which nothing writes: it is the space of that width, U+2002, as ", " reads Claude-Session: https://claude.ai/code/session_01SyVio8afJsGwHW9i5v5HDs Co-authored-by: Claude --- .../UnicodeMath/UnicodeMathSerializer.cs | 25 ++++++++++++++-- .../UnicodeMath/UnicodeMathTransform.cs | 7 +++++ .../Regression/WriterRoundTripTests.cs | 30 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) 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.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]")] From 362eec76bc880473bc57dacfa77fcd67d17a932b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:17:57 +0000 Subject: [PATCH 3/3] [no-new-version] Avalonia: a glyph of a font without outlines (color emoji bitmaps) has the line's box The layout read the ink box of a glyph from the outlines of its font; a font with bitmaps alone (Noto Color Emoji as CBDT, the one of the Linux runners) has none, so an emoji was 0 high and the corpus test failed there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio8afJsGwHW9i5v5HDs --- src/SharpEquations.Avalonia/AvaloniaMathFont.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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;