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