diff --git a/AGENTS.md b/AGENTS.md
index 6e79901..cbdd5ab 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other
| `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math |
| `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) |
-| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter, the solver of triangles) |
+| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter, the solver of triangles; the calculator computes with AngouriMath, through `MathReader`, which reads what the normalized presentation MathML means) |
| `samples/SharpEquations.Avalonia.Browser.MathTools.Demo/` | The math tools on WebAssembly (as the web demo, not in the solution build; `demo.yml` publishes them under `math/`) |
| `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora |
| `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) |
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9832a40..1655a66 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/README.md b/README.md
index d177249..71c3c0d 100644
--- a/README.md
+++ b/README.md
@@ -88,8 +88,9 @@ three projects, so that the core keeps no dependency: `SharpEquations.Editing` (
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`). `samples/SharpEquations.Avalonia.MathTools.Demo` is a
-second app around the editor, a set of math tools (a scientific keypad that types into the editor, a library of
-formulas, a unit converter, a solver of triangles), with
+second app around the editor, a set of math tools (a scientific keypad that types into the editor and computes what is
+typed with [AngouriMath](https://github.com/asc-community/AngouriMath), a library of formulas, a unit converter, a
+solver of triangles), with
`samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site).
## Architecture
diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj
index 384028e..71816c7 100644
--- a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj
+++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj
@@ -25,6 +25,7 @@
+
diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs
new file mode 100644
index 0000000..814a8c9
--- /dev/null
+++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs
@@ -0,0 +1,118 @@
+namespace SharpEquations.Avalonia.MathTools
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.Linq;
+ using AngouriMath;
+ using SharpEquations;
+
+ /// A result of : what was done, and the result as an equation (null if it cannot be drawn) and as text.
+ public sealed record AlgebraResult(string Action, Equation? Math, string Text);
+
+ ///
+ /// What the calculator computes with AngouriMath: the value of an expression (exact, and as a decimal number),
+ /// the solutions of an equation, simplification, expansion, factoring, derivatives and integrals. The results come
+ /// back through LaTeX, which AngouriMath writes and SharpEquations reads.
+ ///
+ public static class Algebra
+ {
+ public const string Evaluate = "Evaluate";
+ public const string Solve = "Solve";
+ public const string Simplify = "Simplify";
+ public const string Expand = "Expand";
+ public const string Factor = "Factor";
+ public const string Differentiate = "Differentiate";
+ public const string Integrate = "Integrate";
+
+ /// The actions, in the order of the buttons.
+ public static readonly IReadOnlyList Actions = new[] { Evaluate, Solve, Simplify, Expand, Factor, Differentiate, Integrate };
+
+ /// The letters of an expression, x first when there is one.
+ public static IReadOnlyList Variables(Entity expression) =>
+ expression.Vars.Select(v => v.Name).Distinct().OrderBy(v => v == "x" ? 0 : 1).ThenBy(v => v, StringComparer.Ordinal).ToList();
+
+ /// Does an action to an expression read by ; is the letter to solve for, or to differentiate and integrate by.
+ public static AlgebraResult Run(string action, Entity expression, string? variable)
+ {
+ var letters = Variables(expression);
+ var x = MathS.Var(variable ?? (letters.Count > 0 ? letters[0] : "x"));
+ switch (action)
+ {
+ case Evaluate:
+ return Value(expression);
+ case Solve:
+ var equation = expression is Entity.Equalsf ? expression : expression.Equalizes(0);
+ var solutions = equation.Solve(x).Simplify();
+ return Result(Solve, Latex(x) + @"\in " + Latex(solutions), x.ToString() + " ∈ " + solutions.ToString());
+ case Simplify:
+ return Result(Simplify, expression.Simplify());
+ case Expand:
+ return Result(Expand, expression.Expand());
+ case Factor:
+ return Result(Factor, expression.Factorize());
+ case Differentiate:
+ return Result(Differentiate, expression.Differentiate(x).Simplify());
+ case Integrate:
+ return Result(Integrate, expression.Integrate(x).Simplify());
+ default:
+ throw new ArgumentOutOfRangeException(nameof(action), action, "Not an action of the calculator.");
+ }
+ }
+
+ // the exact value, and the decimal one when it differs: 1/3 ≈ 0.333333, √2 ≈ 1.41421
+ private static AlgebraResult Value(Entity expression)
+ {
+ if (expression is Entity.Equalsf || expression.Vars.Any())
+ return Result(Evaluate, expression.Simplify());
+
+ var exact = expression.Simplify();
+ var approximate = Decimal(exact.EvalNumerical());
+ var exactLatex = Latex(exact);
+ var exactText = exact.ToString();
+ if (approximate == null || approximate == exactText)
+ return Result(Evaluate, exactLatex, exactText);
+ return Result(Evaluate, exactLatex + @"\approx " + approximate, exactText + " ≈ " + approximate);
+ }
+
+ /// A number of AngouriMath as the calculator shows it: ten significant digits, a+bi for a complex one; null for what is not a number.
+ public static string? Decimal(Entity value)
+ {
+ switch (value)
+ {
+ case Entity.Number.Real real:
+ return Format(real.EDecimal.ToDouble());
+ case Entity.Number.Complex complex:
+ var re = complex.RealPart.EDecimal.ToDouble();
+ var im = complex.ImaginaryPart.EDecimal.ToDouble();
+ if (Math.Abs(im) < 1e-12 * Math.Max(1, Math.Abs(re)))
+ return Format(re);
+ var imaginary = (Math.Abs(im) == 1 ? "" : Format(Math.Abs(im))) + "i";
+ return Math.Abs(re) < 1e-12 * Math.Abs(im) ? (im < 0 ? "-" : "") + imaginary : Format(re) + (im < 0 ? "-" : "+") + imaginary;
+ default:
+ return null;
+ }
+ }
+
+ private static string Format(double value) =>
+ double.IsInfinity(value) ? (value > 0 ? "∞" : "-∞") : value.ToString("G10", CultureInfo.InvariantCulture);
+
+ private static AlgebraResult Result(string action, Entity value) => Result(action, Latex(value), value.ToString());
+
+ private static AlgebraResult Result(string action, string latex, string text)
+ {
+ var options = new EquationOptions { Display = true };
+ var math = Equation.TryParse(latex, EquationFormat.LaTeX, options, out var parsed)
+ && !parsed!.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror")
+ ? parsed
+ : null;
+ return new AlgebraResult(action, math, text);
+ }
+
+ // the LaTeX of AngouriMath, with its constants as the LaTeX reader knows them
+ private static string Latex(Entity value) => value.Latexize()
+ .Replace(@"\mathrm{\pi}", @"\pi", StringComparison.Ordinal)
+ .Replace(@"\mathrm{e}", "e", StringComparison.Ordinal)
+ .Replace(@"\mathrm{i}", "i", StringComparison.Ordinal);
+ }
+}
diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs
new file mode 100644
index 0000000..ccb63c2
--- /dev/null
+++ b/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs
@@ -0,0 +1,233 @@
+namespace SharpEquations.Avalonia.MathTools
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using AngouriMath;
+ using global::Avalonia;
+ using global::Avalonia.Controls;
+ using global::Avalonia.Layout;
+ using global::Avalonia.Threading;
+ using SharpEquations;
+
+ ///
+ /// What the calculator computes from the equation of the editor, with AngouriMath: its value as it is typed, and on a
+ /// click its solutions, its simplified, expanded or factored form, its derivative and its integral, each result
+ /// drawn and ready to go back to the editor.
+ ///
+ public sealed class AlgebraPanel : UserControl
+ {
+ private static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(300);
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ private static readonly (string Action, string Face, string Tip)[] Buttons =
+ {
+ (Algebra.Solve, "Solve", "The solutions of the equation, or where the expression is 0"),
+ (Algebra.Simplify, "Simplify", "The expression in a simpler form"),
+ (Algebra.Expand, "Expand", "Products and powers multiplied out"),
+ (Algebra.Factor, "Factor", "The expression as a product"),
+ (Algebra.Differentiate, "d/dx", "The derivative by the letter chosen"),
+ (Algebra.Integrate, "∫ dx", "An antiderivative by the letter chosen"),
+ };
+
+ private readonly MathEditBox editor;
+ private readonly MathView answer = new MathView { FontSize = 26, HorizontalAlignment = HorizontalAlignment.Left };
+ private readonly TextBlock answerNote = Ui.Muted("", 12.5);
+ private readonly StackPanel results = new StackPanel { Spacing = 12 };
+ private readonly DispatcherTimer timer;
+ private int version;
+
+ public AlgebraPanel(MathEditBox editor)
+ {
+ this.editor = editor;
+ Letter = new ComboBox { MinWidth = 70 };
+ timer = new DispatcherTimer { Interval = Delay };
+ timer.Tick += async (_, _) =>
+ {
+ timer.Stop();
+ await RefreshAnswer().ConfigureAwait(true);
+ };
+ editor.EquationChanged += (_, _) =>
+ {
+ timer.Stop();
+ timer.Start();
+ };
+
+ var buttons = new List();
+ foreach (var (action, face, tip) in Buttons)
+ {
+ var button = new Button { Content = face, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 5) };
+ ToolTip.SetTip(button, tip);
+ button.Click += async (_, _) => await RunAsync(action).ConfigureAwait(true);
+ buttons.Add(button);
+ }
+ var forLabel = Ui.Muted("by", 12.5);
+ buttons.Add(forLabel);
+ buttons.Add(Letter);
+
+ var answerFrame = new Border
+ {
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(18, 12),
+ MinHeight = 60,
+ Child = new StackPanel { Spacing = 4, Children = { answer, answerNote } },
+ };
+ answerFrame.Bind(Border.BackgroundProperty, answerFrame.GetResourceObservable("CodeBrush"));
+
+ Content = new StackPanel { Spacing = 14, Children = { answerFrame, Ui.Row(buttons.ToArray()), results } };
+ ShowAnswer(null, "The value of what is typed shows here.");
+ }
+
+ /// The letter to solve for, and to differentiate and integrate by.
+ public ComboBox Letter { get; }
+
+ /// The value of the equation of the editor, as it was last computed.
+ public AlgebraResult? Answer { get; private set; }
+
+ /// The results of the actions, the last first.
+ public IReadOnlyList Results => history;
+
+ private readonly List history = new List();
+
+ /// Computes the value of the equation of the editor now, as typing does after a pause.
+ public async Task RefreshAnswer()
+ {
+ timer.Stop();
+ var current = ++version;
+ if (!TryRead(out var expression, out var error))
+ {
+ UpdateLetters(null);
+ ShowAnswer(null, error);
+ return;
+ }
+ UpdateLetters(expression);
+ var (result, message) = await Compute(Algebra.Evaluate, expression!, null).ConfigureAwait(true);
+ if (current == version)
+ ShowAnswer(result, message);
+ }
+
+ /// Does an action to the equation of the editor, as its button does, and adds the result to the list.
+ public async Task RunAsync(string action)
+ {
+ if (!TryRead(out var expression, out var error))
+ {
+ AddMessage(error!);
+ return null;
+ }
+ var letter = Letter.SelectedItem as string;
+ var (result, message) = await Compute(action, expression!, letter).ConfigureAwait(true);
+ if (result == null)
+ {
+ AddMessage(message!);
+ return null;
+ }
+ history.Insert(0, result);
+ results.Children.Insert(0, ResultCard(result, Label(action, letter ?? "x")));
+ while (results.Children.Count > 8)
+ results.Children.RemoveAt(results.Children.Count - 1);
+ return result;
+ }
+
+ private bool TryRead(out Entity? expression, out string? error)
+ {
+ expression = null;
+ error = null;
+ var equation = editor.Equation;
+ if (equation == null)
+ {
+ error = "The value of what is typed shows here.";
+ return false;
+ }
+ if (equation.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror"))
+ {
+ error = "Not built up yet: finish the argument, or press Build up (Enter).";
+ return false;
+ }
+ try
+ {
+ expression = MathReader.Read(equation);
+ return true;
+ }
+ catch (NotSupportedException e)
+ {
+ error = e.Message;
+ return false;
+ }
+ }
+
+ // on another thread, since AngouriMath can take long (it cannot be stopped: a result too late is dropped)
+ private static async Task<(AlgebraResult? Result, string? Message)> Compute(string action, Entity expression, string? letter)
+ {
+ var work = Task.Run(() => Algebra.Run(action, expression, letter));
+ if (await Task.WhenAny(work, Task.Delay(Timeout)).ConfigureAwait(true) != work)
+ return (null, "It takes too long: AngouriMath found no result in " + Timeout.TotalSeconds + " seconds.");
+ try
+ {
+ return (await work.ConfigureAwait(true), null);
+ }
+#pragma warning disable CA1031 // what AngouriMath cannot do, it says with exceptions of many kinds
+ catch (Exception e)
+#pragma warning restore CA1031
+ {
+ return (null, "AngouriMath cannot do it: " + e.Message);
+ }
+ }
+
+ private void UpdateLetters(Entity? expression)
+ {
+ var letters = expression == null ? Array.Empty() : Algebra.Variables(expression);
+ var selected = Letter.SelectedItem as string;
+ if (Letter.ItemsSource is IReadOnlyList old && old.SequenceEqual(letters))
+ return;
+ Letter.ItemsSource = letters;
+ Letter.SelectedItem = selected != null && letters.Contains(selected) ? selected : letters.Count > 0 ? letters[0] : null;
+ }
+
+ private void ShowAnswer(AlgebraResult? result, string? message)
+ {
+ Answer = result;
+ answer.Equation = result?.Math;
+ answer.IsVisible = result?.Math != null;
+ answerNote.Text = result == null ? message : result.Math == null ? result.Text : null;
+ answerNote.IsVisible = !string.IsNullOrEmpty(answerNote.Text);
+ }
+
+ private void AddMessage(string message)
+ {
+ results.Children.Insert(0, Ui.Muted(message, 13));
+ while (results.Children.Count > 8)
+ results.Children.RemoveAt(results.Children.Count - 1);
+ }
+
+ private Border ResultCard(AlgebraResult result, string label)
+ {
+ Control math = result.Math != null
+ ? new MathView { FontSize = 22, Equation = result.Math, HorizontalAlignment = HorizontalAlignment.Left }
+ : new SelectableTextBlock { Text = result.Text, FontSize = 14, TextWrapping = global::Avalonia.Media.TextWrapping.Wrap };
+ var use = new Button { Content = "To the editor", CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 3), IsEnabled = result.Math != null };
+ use.Click += (_, _) =>
+ {
+ editor.Equation = result.Math;
+ editor.Focus();
+ };
+ var top = new DockPanel { Children = { Ui.Docked(use, Dock.Right), Ui.Small(label) } };
+ var card = new Border
+ {
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(16, 10),
+ Child = new StackPanel { Spacing = 8, Children = { top, math } },
+ };
+ card.Bind(Border.BackgroundProperty, card.GetResourceObservable("CodeBrush"));
+ return card;
+ }
+
+ private static string Label(string action, string letter) => action switch
+ {
+ Algebra.Solve => "Solve for " + letter,
+ Algebra.Differentiate => "Derivative by " + letter,
+ Algebra.Integrate => "Antiderivative by " + letter,
+ _ => action,
+ };
+ }
+}
diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs
index 64cbbc7..8faeb4d 100644
--- a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs
+++ b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs
@@ -10,8 +10,8 @@ namespace SharpEquations.Avalonia.MathTools
using SharpEquations;
///
- /// The scientific keypad: the keys type into the equation editor, which the keyboard types into too, and the equation
- /// is shown as UnicodeMath and LaTeX as it is typed.
+ /// The scientific keypad: the keys type into the equation editor, which the keyboard types into too; what the
+ /// equation means is computed as it is typed, and the equation is shown as UnicodeMath and LaTeX.
///
public sealed class CalculatorPage : UserControl
{
@@ -54,6 +54,8 @@ public CalculatorPage()
foreach (var key in CalculatorKeys.Editing)
editing.Children.Add(Key(key, "KeyBrush"));
+ Solver = new AlgebraPanel(Editor);
+
var formats = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,16,*"),
@@ -70,6 +72,7 @@ public CalculatorPage()
Spacing = 16,
Children = { frame, keypad, editing },
}),
+ Ui.Card("Compute", "With AngouriMath: the value of what is typed, and its solutions, simpler forms, derivative and antiderivative", Solver),
Ui.Card("As text", "The equation in the linear formats, as it is typed", formats),
},
};
@@ -78,6 +81,9 @@ public CalculatorPage()
/// The equation editor the keys type into.
public MathEditBox Editor { get; }
+ /// What is computed from the equation of the editor.
+ public AlgebraPanel Solver { get; }
+
/// Presses a key, as a click on it does.
public void Press(CalculatorKey key)
{
diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs
new file mode 100644
index 0000000..a006aba
--- /dev/null
+++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs
@@ -0,0 +1,378 @@
+namespace SharpEquations.Avalonia.MathTools
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.Linq;
+ using System.Xml.Linq;
+ using AngouriMath;
+ using SharpEquations;
+
+ ///
+ /// Reads what an equation means from its normalized presentation MathML, as an expression of AngouriMath: numbers,
+ /// letters and constants (π, e, i), the operators, implicit multiplication (2x), functions applied with the
+ /// invisible function application of the parsers (sin x, sin²x, log_2 x), fractions, roots,
+ /// powers, absolute values, factorials, sums and integrals with their limits, and d/dx.
+ ///
+ public static class MathReader
+ {
+ /// Reads an equation; a relation (a=b) is an equality of AngouriMath, and a trailing = is dropped.
+ /// The equation has something that has no meaning here, as the message says.
+ public static Entity Read(Equation equation) => new Reader().Relation(Items(equation.ToMathML()));
+
+ private static IEnumerable Items(XElement row) =>
+ row.Elements().Where(e => e.Name.LocalName is not ("mspace" or "annotation" or "annotation-xml"));
+
+ private static string Name(XElement e) => e.Name.LocalName;
+
+ private static bool IsOperator(XElement e, params string[] texts) => Name(e) == "mo" && texts.Contains(e.Value.Trim());
+
+ private sealed class Reader
+ {
+ private static readonly Dictionary> Functions = new Dictionary>(StringComparer.Ordinal)
+ {
+ ["sin"] = MathS.Sin,
+ ["cos"] = MathS.Cos,
+ ["tan"] = MathS.Tan,
+ ["cot"] = MathS.Cotan,
+ ["sec"] = MathS.Sec,
+ ["csc"] = MathS.Cosec,
+ ["arcsin"] = MathS.Arcsin,
+ ["arccos"] = MathS.Arccos,
+ ["arctan"] = MathS.Arctan,
+ ["sinh"] = MathS.Hyperbolic.Sinh,
+ ["cosh"] = MathS.Hyperbolic.Cosh,
+ ["tanh"] = MathS.Hyperbolic.Tanh,
+ ["ln"] = MathS.Ln,
+ ["log"] = x => MathS.Log(10, x),
+ ["exp"] = x => MathS.Pow(MathS.e, x),
+ ["sgn"] = MathS.Signum,
+ };
+
+ private List items = new List();
+ private int at;
+
+ private XElement? Peek => at < items.Count ? items[at] : null;
+
+ public Entity Relation(IEnumerable row)
+ {
+ var parts = Split(row.ToList(), e => IsOperator(e, "="));
+ if (parts.Count > 1 && parts[^1].Count == 0)
+ parts.RemoveAt(parts.Count - 1);
+ if (parts.Count > 2)
+ throw new NotSupportedException("More than one = in the equation.");
+ var left = Expression(parts[0]);
+ return parts.Count == 2 ? left.Equalizes(Expression(parts[1])) : left;
+ }
+
+ private Entity Expression(IEnumerable row)
+ {
+ var saved = (items, at);
+ items = Flatten(row).ToList();
+ at = 0;
+ if (items.Count == 0)
+ throw new NotSupportedException("Something is missing: an empty place.");
+ var result = Sum();
+ if (Peek is { } rest)
+ throw new NotSupportedException("Cannot read “" + Text(rest) + "” here.");
+ (items, at) = saved;
+ return result;
+ }
+
+ private Entity Sum()
+ {
+ var result = Product();
+ while (Peek is { } op && IsOperator(op, "+", "-", "−"))
+ {
+ at++;
+ var right = Product();
+ result = op.Value.Trim() == "+" ? result + right : result - right;
+ }
+ return result;
+ }
+
+ private Entity Product()
+ {
+ var result = Signed();
+ while (Peek is { } next && !IsOperator(next, "+", "-", "−", "=", ",") && !IsDifferential())
+ {
+ if (IsOperator(next, "×", "·", "⋅", "*", "∗", ""))
+ {
+ at++;
+ result *= Signed();
+ }
+ else if (IsOperator(next, "/", "÷", "∕"))
+ {
+ at++;
+ result /= Signed();
+ }
+ else
+ {
+ result *= Signed();
+ }
+ }
+ return result;
+ }
+
+ private Entity Signed()
+ {
+ if (Peek is { } op && IsOperator(op, "-", "−", "+"))
+ {
+ at++;
+ var operand = Signed();
+ return op.Value.Trim() == "+" ? operand : -operand;
+ }
+ return Postfix();
+ }
+
+ private Entity Postfix()
+ {
+ var result = Primary();
+ while (Peek is { } op && IsOperator(op, "!"))
+ {
+ at++;
+ result = MathS.Factorial(result);
+ }
+ return result;
+ }
+
+ private Entity Primary()
+ {
+ if (at >= items.Count)
+ throw new NotSupportedException("Something is missing after “" + Text(items[^1]) + "”.");
+ var item = items[at++];
+
+ // a function applied: sin x, sin²x, log_2 x
+ if (Peek is { } apply && IsOperator(apply, ""))
+ {
+ at++;
+ var argument = Postfix();
+ return Apply(item, argument);
+ }
+
+ if (BigOperator(item) is { } big)
+ return Big(big.Symbol, big.Lower, big.Upper);
+
+ if (Derivative(item) is { } variable)
+ return MathS.Derivative(Postfix(), variable);
+
+ return Node(item);
+ }
+
+ private Entity Apply(XElement function, Entity argument)
+ {
+ switch (Name(function))
+ {
+ case "mi" when Functions.TryGetValue(function.Value.Trim(), out var f):
+ return f(argument);
+ case "msup" when function.Elements().First() is { } name && Functions.TryGetValue(name.Value.Trim(), out var f):
+ var power = function.Elements().ElementAt(1);
+ // sin^(-1) x is the arcsine, not the cosecant
+ if (IsMinusOne(power) && Functions.TryGetValue("arc" + name.Value.Trim(), out var inverse))
+ return inverse(argument);
+ return MathS.Pow(f(argument), Node(power));
+ case "msub" when function.Elements().First().Value.Trim() == "log":
+ return MathS.Log(Node(function.Elements().ElementAt(1)), argument);
+ default:
+ throw new NotSupportedException("Unknown function “" + Text(function) + "”.");
+ }
+ }
+
+ // ∑_(k=1)^n k², ∫_0^1 x dx: the operand of a sum is the next item (the parsers group it); the operand of an
+ // integral goes on to its differential, dx or ⅆx
+ private Entity Big(string symbol, XElement? lower, XElement? upper)
+ {
+ switch (symbol)
+ {
+ case "∑" or "∏":
+ if (lower == null || upper == null)
+ throw new NotSupportedException(symbol + " needs its limits: ∑_(k=1)^n.");
+ var bound = Split(Items(Row(lower)).ToList(), e => IsOperator(e, "="));
+ if (bound.Count != 2 || bound[0].Count != 1 || Name(bound[0][0]) != "mi")
+ throw new NotSupportedException("The lower limit of " + symbol + " is a variable and its start: k=1.");
+ var index = MathS.Var(Identifier(bound[0][0].Value));
+ var from = Expression(bound[1]);
+ var to = Expression(Items(Row(upper)));
+ var body = Postfix();
+ return symbol == "∑" ? MathS.Sum(body, index, from, to) : MathS.Product(body, index, from, to);
+ case "∫":
+ // the operand grouped with its differential (∫_0^1 x² dx): its items go on in the row
+ if (Peek is { } group && Name(group) == "mrow" && !(group.Elements().FirstOrDefault() is { } open && Name(open) == "mo"))
+ {
+ items.RemoveAt(at);
+ items.InsertRange(at, Items(group));
+ }
+ var start = at;
+ while (at < items.Count && !IsDifferential())
+ at++;
+ if (at >= items.Count)
+ throw new NotSupportedException("The integral needs its differential: ∫ x dx.");
+ var integrand = Expression(items.Skip(start).Take(at - start));
+ at++;
+ var variable = MathS.Var(Identifier(items[at++].Value));
+ return lower != null && upper != null
+ ? MathS.Integral(integrand, variable, Node(lower), Node(upper))
+ : MathS.Integral(integrand, variable);
+ default:
+ throw new NotSupportedException("Cannot compute “" + symbol + "”.");
+ }
+ }
+
+ // the parsers group an integral with the operand before its differential (∫_0^1 x, then dx): the row goes on
+ // with what the integral holds, so that the integral reaches its differential
+ private static IEnumerable Flatten(IEnumerable row)
+ {
+ foreach (var item in row)
+ {
+ if (Name(item) == "mrow" && item.Elements().FirstOrDefault() is { } first && BigOperator(first) is { Symbol: "∫" })
+ {
+ foreach (var inner in Flatten(Items(item)))
+ yield return inner;
+ }
+ else
+ {
+ yield return item;
+ }
+ }
+ }
+
+ // d or ⅆ, then a letter: the differential that ends an integral
+ private bool IsDifferential() =>
+ at + 1 < items.Count && Name(items[at]) == "mi" && items[at].Value.Trim() is "d" or "ⅆ" && Name(items[at + 1]) == "mi";
+
+ private static (string Symbol, XElement? Lower, XElement? Upper)? BigOperator(XElement item)
+ {
+ var name = Name(item);
+ if (name == "mo" && item.Value.Trim() is "∑" or "∏" or "∫")
+ return (item.Value.Trim(), null, null);
+
+ if (name is "msubsup" or "munderover" or "msub" or "munder" && item.Elements().First() is { } symbol && IsOperator(symbol, "∑", "∏", "∫"))
+ {
+ var limits = item.Elements().Skip(1).ToList();
+ return (symbol.Value.Trim(), limits[0], limits.Count > 1 ? limits[1] : null);
+ }
+ return null;
+ }
+
+ // d/dx, ⅆ/ⅆx: a fraction of d over d and a letter
+ private static Entity.Variable? Derivative(XElement item)
+ {
+ if (Name(item) != "mfrac")
+ return null;
+ var top = Items(Row(item.Elements().First())).ToList();
+ var bottom = Items(Row(item.Elements().ElementAt(1))).ToList();
+ if (top.Count == 1 && top[0].Value.Trim() is "d" or "ⅆ" && bottom.Count == 2 && bottom[0].Value.Trim() is "d" or "ⅆ" && Name(bottom[1]) == "mi")
+ return MathS.Var(Identifier(bottom[1].Value));
+ return null;
+ }
+
+ private Entity Node(XElement e)
+ {
+ switch (Name(e))
+ {
+ case "mn":
+ return Number(e.Value.Trim());
+ case "mi":
+ return Letter(e.Value.Trim());
+ case "mrow" or "mstyle" or "mpadded" or "menclose" or "math":
+ return Fenced(Items(e).ToList());
+ case "mfrac" when e.Attribute("linethickness")?.Value is "0" or "0pt":
+ return MathS.Binomial(Node(e.Elements().First()), Node(e.Elements().ElementAt(1)));
+ case "mfrac":
+ return Node(e.Elements().First()) / Node(e.Elements().ElementAt(1));
+ case "msqrt":
+ return MathS.Sqrt(Expression(Items(e)));
+ case "mroot":
+ return MathS.Pow(Node(e.Elements().First()), 1 / Node(e.Elements().ElementAt(1)));
+ case "msup":
+ var exponent = e.Elements().ElementAt(1);
+ if (exponent.Value.Trim() is "′" or "″")
+ throw new NotSupportedException("Primes are not read: write d/dx.");
+ return MathS.Pow(Node(e.Elements().First()), Node(exponent));
+ case "msub" when e.Elements().All(c => Name(c) is "mi" or "mn"):
+ // x₁ is a letter of its own
+ return MathS.Var(Identifier(e.Elements().First().Value) + "_" + Identifier(e.Elements().ElementAt(1).Value));
+ default:
+ throw new NotSupportedException("Cannot compute “" + Text(e) + "”.");
+ }
+ }
+
+ // (…), [ ], |x|, ‖x‖, ⌊x⌋, ⌈x⌉, or a row without fences
+ private Entity Fenced(List row)
+ {
+ if (row.Count >= 2 && Name(row[0]) == "mo" && Name(row[^1]) == "mo")
+ {
+ var inner = row.Skip(1).Take(row.Count - 2);
+ switch (row[0].Value.Trim() + row[^1].Value.Trim())
+ {
+ case "()" or "[]" or "{}":
+ return Expression(inner);
+ case "||" or "‖‖":
+ return MathS.Abs(Expression(inner));
+ case "⌊⌋":
+ return MathS.Floor(Expression(inner));
+ case "⌈⌉":
+ return -MathS.Floor(-Expression(inner));
+ }
+ }
+ return Expression(row);
+ }
+
+ private static Entity Number(string text)
+ {
+ if (!decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ throw new NotSupportedException("“" + text + "” is not a number.");
+ return MathS.FromString(text);
+ }
+
+ private static Entity Letter(string text) => text switch
+ {
+ "π" => MathS.pi,
+ "e" or "ⅇ" => MathS.e,
+ "i" or "ⅈ" => MathS.i,
+ "∞" => MathS.oo,
+ _ => MathS.Var(Identifier(text)),
+ };
+
+ private static string Identifier(string text)
+ {
+ var name = text.Trim() switch
+ {
+ "α" => "alpha",
+ "β" => "beta",
+ "γ" => "gamma",
+ "δ" => "delta",
+ "θ" => "theta",
+ "λ" => "lambda",
+ "μ" => "mu",
+ "σ" => "sigma",
+ "φ" or "ϕ" => "phi",
+ "ω" => "omega",
+ var other => other,
+ };
+ if (name.Length == 0 || !name.All(c => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'))
+ throw new NotSupportedException("“" + text + "” is not a letter that can be computed.");
+ return name;
+ }
+
+ private static bool IsMinusOne(XElement e) => string.Concat(Items(Row(e)).Select(c => c.Value.Trim())) is "−1" or "-1";
+
+ private static XElement Row(XElement e) => Name(e) == "mrow" ? e : new XElement(e.Name.Namespace + "mrow", e);
+ }
+
+ private static List> Split(List row, Func separator)
+ {
+ var parts = new List> { new List() };
+ foreach (var item in row)
+ {
+ if (separator(item))
+ parts.Add(new List());
+ else
+ parts[^1].Add(item);
+ }
+ return parts;
+ }
+
+ private static string Text(XElement e) => string.Concat(e.DescendantsAndSelf().Where(d => !d.HasElements).Select(d => d.Value));
+ }
+}
diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj
index 2158bb3..a54a8bb 100644
--- a/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj
+++ b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj
@@ -12,6 +12,7 @@
+
diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs
new file mode 100644
index 0000000..f0f4d1e
--- /dev/null
+++ b/tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs
@@ -0,0 +1,109 @@
+#if NET10_0_OR_GREATER
+namespace SharpEquations.Avalonia.Tests.MathTools
+{
+ using System;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using AngouriMath;
+ using SharpEquations;
+ using SharpEquations.Avalonia.MathTools;
+
+ /// What an equation means, read from its presentation MathML, and what the calculator computes from it with AngouriMath.
+ public class AlgebraTests
+ {
+ private static Entity Read(string unicodeMath) => MathReader.Read(Equation.Parse(unicodeMath, EquationFormat.UnicodeMath));
+
+ /// The value of an expression read from UnicodeMath, as a decimal number.
+ [Test]
+ [Arguments("2+3×4", "14")]
+ [Arguments("2(3+4)", "14")]
+ [Arguments("(1+2)(3+4)", "21")]
+ [Arguments("-2^2", "-4")]
+ [Arguments("2^10", "1024")]
+ [Arguments("1/2+1/3", "0.8333333333")]
+ [Arguments("√2", "1.414213562")]
+ [Arguments("∛27", "3")]
+ [Arguments("√(9+16)", "5")]
+ [Arguments("5!", "120")]
+ [Arguments("|-3|+|2-5|", "6")]
+ [Arguments("⌊2.7⌋", "2")]
+ [Arguments("sin(π/2)", "1")]
+ [Arguments("sin^2 x+cos^2 x", "1")]
+ [Arguments("2 sin(π/6)", "1")]
+ [Arguments("sin π/6", "0")]
+ [Arguments("cos π", "-1")]
+ [Arguments("ln e^2", "2")]
+ [Arguments("log 1000", "3")]
+ [Arguments("log_2 8", "3")]
+ [Arguments("e^(iπ)", "-1")]
+ [Arguments("i^2", "-1")]
+ [Arguments("∑_(k=1)^10 k^2", "385")]
+ [Arguments("∏_(k=1)^5 k", "120")]
+ [Arguments("∫_0^1 x^2 dx", "0.3333333333")]
+ [Arguments("∫_0^π sin x dx", "2")]
+ [Arguments("1.5×4", "6")]
+ [Arguments("2+3=", "5")]
+ public async Task Evaluates(string unicodeMath, string expected)
+ {
+ var value = Read(unicodeMath);
+ if (value.Vars.Any())
+ value = value.Simplify();
+ await Assert.That(Algebra.Decimal(value.EvalNumerical())).IsEqualTo(expected);
+ }
+
+ [Test]
+ [Arguments("x^2-5x+6=0", "x", new[] { "2", "3" })]
+ [Arguments("2x+1=7", "x", new[] { "3" })]
+ [Arguments("x^2=2", "x", new[] { "1.414213562", "-1.414213562" })]
+ [Arguments("y^3-8", "y", new[] { "2" })]
+ public async Task Solves(string unicodeMath, string variable, string[] real)
+ {
+ var expression = Read(unicodeMath);
+ var equation = expression is Entity.Equalsf ? expression : expression.Equalizes(0);
+ var solutions = ((Entity.Set.FiniteSet)equation.Solve(variable).Simplify())
+ .Select(s => Algebra.Decimal(s.EvalNumerical())).ToList();
+ foreach (var solution in real)
+ await Assert.That(solutions).Contains(solution);
+ }
+
+ /// The result of each action, drawn: it reads back from the LaTeX of AngouriMath without errors.
+ [Test]
+ [Arguments("Evaluate", "1/3+1/6", "1/2 ≈ 0.5")]
+ [Arguments("Evaluate", "√8", "2 * sqrt(2) ≈ 2.828427125")]
+ [Arguments("Solve", "x^2-5x+6=0", null)]
+ [Arguments("Simplify", "(x^2-1)/(x-1)", null)]
+ [Arguments("Expand", "(x+1)^3", null)]
+ [Arguments("Factor", "x^2-1", null)]
+ [Arguments("Differentiate", "x^3 sin x", null)]
+ [Arguments("Integrate", "x e^x", null)]
+ [Arguments("Evaluate", "d/dx x^3", null)]
+ public async Task ResultIsDrawn(string action, string unicodeMath, string? text)
+ {
+ var result = Algebra.Run(action, Read(unicodeMath), null);
+ await Assert.That(result.Math).IsNotNull().Because(result.Text);
+ if (text != null)
+ await Assert.That(result.Text).IsEqualTo(text);
+ }
+
+ [Test]
+ public async Task SolvesForTheLetterAsked()
+ {
+ var expression = Read("a x+b=0");
+ await Assert.That(Algebra.Variables(expression)).IsEquivalentTo(new[] { "x", "a", "b" });
+ var result = Algebra.Run(Algebra.Solve, expression, "x");
+ await Assert.That(result.Text).Contains("b").And.Contains("a");
+ }
+
+ [Test]
+ [Arguments("x+")]
+ [Arguments("∫ x")]
+ [Arguments("\"text\"")]
+ [Arguments("a=b=c")]
+ public async Task SaysWhatItCannotRead(string unicodeMath)
+ {
+ var exception = Assert.Throws(() => Read(unicodeMath));
+ await Assert.That(exception.Message).IsNotEmpty();
+ }
+ }
+}
+#endif
diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs
index 4d05ea3..e0c489d 100644
--- a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs
+++ b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs
@@ -130,6 +130,40 @@ public async Task TrianglesSolve()
await Assert.That(two.Triangles.Count).IsEqualTo(2);
await Assert.That(error!.Error).IsEqualTo("“x” is not a number.");
}
+
+ [Test]
+ public async Task CalculatorComputes()
+ {
+ var (value, solved, derivative, unreadable) = await Headless.RunAsync(async () =>
+ {
+ var window = new MainWindow();
+ window.Show();
+ var page = window.GetVisualDescendants().OfType().Single();
+ var display = new EquationOptions { Display = true };
+ page.Editor.Equation = Equation.Parse("√2+1/3", EquationFormat.UnicodeMath, display);
+ await page.Solver.RefreshAnswer();
+ var first = page.Solver.Answer;
+ page.Editor.Equation = Equation.Parse("x^2-5x+6=0", EquationFormat.UnicodeMath, display);
+ await page.Solver.RefreshAnswer();
+ var second = await page.Solver.RunAsync(Algebra.Solve);
+ page.Editor.Equation = Equation.Parse("x^3 sin x", EquationFormat.UnicodeMath, display);
+ await page.Solver.RefreshAnswer();
+ var third = await page.Solver.RunAsync(Algebra.Differentiate);
+ window.Height = 1500;
+ Save(window, "math-tools-compute");
+ page.Editor.Equation = Equation.Parse("\"text\"", EquationFormat.UnicodeMath, display);
+ await page.Solver.RefreshAnswer();
+ var fourth = page.Solver.Answer;
+ window.Close();
+ return (first, second, third, fourth);
+ });
+ await Assert.That(value!.Text).EndsWith("≈ 1.747546896");
+ await Assert.That(value.Math).IsNotNull();
+ await Assert.That(solved!.Text).IsEqualTo("x ∈ { 2, 3 }");
+ await Assert.That(solved.Math).IsNotNull();
+ await Assert.That(derivative!.Math).IsNotNull();
+ await Assert.That(unreadable).IsNull();
+ }
}
}
#endif