From f073813c97ad6f0ba9a84a202fb9c0ad3b2ece40 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 9 Aug 2026 18:24:02 +0000 Subject: [PATCH 1/2] Move to AngouriMath 2.0, which renamed Latexise to Latexize AngouriMath 2.0 renames ILatexiseable.Latexise to ILatexizeable.Latexize. MathItem implements the new interface explicitly, so CSharpMath's own public surface does not change: MathItem.Latexise stays the name callers know, and PublicAPI.Unshipped.txt is untouched. The public-API analyzer confirms it -- no RS warnings. Only two call sites are on AngouriMath's side of the boundary and move: Content.Latexize() on an AngouriMath.Entity, and the ILatexizeable parameter in Interpret. One test expectation changes. 2.0 splits a radical over a positive factor, so 1+\sqrt{2x} expands to 1+\sqrt{2}\sqrt{x}. sqrt(2x) = sqrt(2)sqrt(x) holds for all complex x when the factored constant is positive, so the new output is sound; it is a rendering change rather than a correctness one. 955 of 955 tests pass. --- CSharpMath.Evaluation.Tests/InterpretTests.cs | 2 +- CSharpMath.Evaluation/CSharpMath.Evaluation.csproj | 2 +- CSharpMath.Evaluation/Evaluation.cs | 8 ++++++-- CSharpMath.Evaluation/Interpret.cs | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CSharpMath.Evaluation.Tests/InterpretTests.cs b/CSharpMath.Evaluation.Tests/InterpretTests.cs index f4675fce..a30bd493 100644 --- a/CSharpMath.Evaluation.Tests/InterpretTests.cs +++ b/CSharpMath.Evaluation.Tests/InterpretTests.cs @@ -10,7 +10,7 @@ public class InterpretTests { [InlineData(@"1+2", @"\underline\mathrm{Input}\\1+2\\\\\underline\mathrm{Simplified}\\3\\\\\underline\mathrm{Value\ (100\ digits)}\\3")] [InlineData(@"1+\sqrt", @"\color{red}\text{Missing radicand}")] [InlineData(@"1+\sqrt2", @"\underline\mathrm{Input}\\1+\sqrt{2}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2}\\\\\underline\mathrm{Value\ (100\ digits)}\\2.414213562373095048801688724209698078569671875376948073176679737990732478462107038850387534327641573")] - [InlineData(@"1+\sqrt{2x}", @"\underline\mathrm{Input}\\1+\sqrt{2 x}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2 x}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x}")] + [InlineData(@"1+\sqrt{2x}", @"\underline\mathrm{Input}\\1+\sqrt{2 x}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2} \sqrt{x}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x}")] [InlineData(@"1+\sqrt{2xy}", @"\underline\mathrm{Input}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x y}")] [InlineData(@"=1+\sqrt{2xy}", @"\color{red}\text{Missing left side of equation}")] [InlineData(@"1+\sqrt{2xy}=", @"\color{red}\text{Missing right side of equation}")] diff --git a/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj b/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj index 942028b7..8f16b6e7 100644 --- a/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj +++ b/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj @@ -13,7 +13,7 @@ - + diff --git a/CSharpMath.Evaluation/Evaluation.cs b/CSharpMath.Evaluation/Evaluation.cs index ec6d1f16..58285cf1 100644 --- a/CSharpMath.Evaluation/Evaluation.cs +++ b/CSharpMath.Evaluation/Evaluation.cs @@ -42,16 +42,20 @@ enum Precedence { Postfix // Highest } - public abstract record MathItem : ILatexiseable { + public abstract record MathItem : ILatexizeable { private protected MathItem() { } public abstract string Latexise(); + // AngouriMath 2.0 renamed ILatexiseable.Latexise to ILatexizeable.Latexize. Implementing + // it explicitly keeps MathItem.Latexise as the public name here, so this package's own + // surface is unchanged by their rename. + string ILatexizeable.Latexize() => Latexise(); public static implicit operator MathItem(AngouriMath.Entity content) => new Entity(content); public static explicit operator AngouriMath.Entity(MathItem item) => ((Entity)item).Content; /// A real number, complex number, variable, function call, vector, matrix, higher-dimensional tensor, or set public sealed record Entity : MathItem { public Entity(AngouriMath.Entity content) => Content = content; public AngouriMath.Entity Content { get; } - public override string Latexise() => Content.Latexise(); + public override string Latexise() => Content.Latexize(); } /// A linked list of comma-delimited items public sealed record Comma : MathItem, IEnumerable { diff --git a/CSharpMath.Evaluation/Interpret.cs b/CSharpMath.Evaluation/Interpret.cs index 1f61f10d..8ad7bd15 100644 --- a/CSharpMath.Evaluation/Interpret.cs +++ b/CSharpMath.Evaluation/Interpret.cs @@ -3,8 +3,8 @@ namespace CSharpMath { static partial class Evaluation { - static StringBuilder AppendLaTeX(this StringBuilder sb, AngouriMath.Core.ILatexiseable latex) => - sb.Append(latex.Latexise()); + static StringBuilder AppendLaTeX(this StringBuilder sb, AngouriMath.Core.ILatexizeable latex) => + sb.Append(latex.Latexize()); static StringBuilder AppendLaTeXHeader(this StringBuilder sb, string header, bool includeNewlineBefore = true) { if (includeNewlineBefore) sb.Append(@"\\\\"); return sb.Append(@"\underline\mathrm{").Append(header).Append(@"}\\"); From ea62b800e61b772bf209accd32b80df4e0ca7d8c Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 16 Aug 2026 15:16:29 +0000 Subject: [PATCH 2/2] Read the LaTeX AngouriMath 2.2.0 emits, and pin it with a sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluation.Visualize throws InvalidCodePathException on any LaTeX it cannot read, and says why in its own source -- "CSharpMath must handle all LaTeX coming from AngouriMath or a bug is present!" -- but nothing checked it. AngouriMathLatexSweepTests now does: every concrete Entity type AngouriMath can print, Latexized and fed back through LaTeXParser. On 2.2.0 that is 76 nodes, of which two forms had no reading here. \bmod, AngouriMath's modulo node. Added as a BinaryOperator whose nucleus is the word "mod": binary spacing, and upright because only Variable and Number go through the italicising font changer. It binds like multiplication and division, matching AngouriMath's Priority.Mul, so x+y \bmod z is x+(y mod z) on both sides. \operatorname{\varphi}, Euler's totient. The name was read with ReadString, which takes ASCII letters only, so both the command and -- since the name is written back out as the letter -- this method's own output \operatorname{φ} were rejected. It now reads char.IsLetter and resolves commands that stand for a letter, refusing the rest; \operatorname{a|} stays the error it was. Both directions are covered: parsing, serialising back, evaluating to an Entity, and laying out. Layout is measured rather than compared against a baseline image, since the new shapes there are a word-length BinaryOperator and a non-ASCII operator name, and glyph coverage is already asserted by TestCommandDisplay. Four recorded answers changed with the version and are updated, each because AngouriMath became more careful rather than less: sgn(|x|) was 1; it is 1 away from zero but 0 at zero arccos(cos x) was x; that holds only on [0, pi] x <= x was T; now T for x in RR, the domain <= needs cos(-x) now folds to cos(x), by evenness Core 1515, Evaluation 962, Rendering 1226, Rendering.Text 237 all pass. --- CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs | 32 ++++++ .../AngouriMathLatexSweepTests.cs | 102 ++++++++++++++++++ .../EvaluationTests.cs | 29 +++-- .../CSharpMath.Evaluation.csproj | 2 +- CSharpMath.Evaluation/Evaluation.cs | 12 +++ .../TestAngouriMathForms.cs | 37 +++++++ CSharpMath/Atom/LaTeXSettings.cs | 30 +++++- 7 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 CSharpMath.Evaluation.Tests/AngouriMathLatexSweepTests.cs create mode 100644 CSharpMath.Rendering.Tests/TestAngouriMathForms.cs diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 30e2cb41..bb6a4b32 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -1300,6 +1300,35 @@ public void TestOperatorName(string operatorname, string output) { Assert.Equal(output, LaTeXParser.MathListToLaTeX(list).ToString()); } + /// + /// A letter in an operator name may be written as a command. AngouriMath emits + /// \operatorname{\varphi} for Euler's totient, which is the reason this exists. + /// + [Theory] + [InlineData(@"\varphi", "φ")] + [InlineData(@"\Gamma", "Γ")] + [InlineData(@"ma\chi ", "maχ")] + public void TestOperatorNameWithCommands(string operatorname, string name) { + var list = ParseLaTeX(@$"\operatorname{{{operatorname}}}"); + Assert.Collection(list, CheckAtom(name)); + var output = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(@$"\operatorname{{{name}}} ", output); + // The name comes back out as the letter rather than as the command, so reading that is the + // round trip that matters -- and it is why the name is read with char.IsLetter. + Assert.Collection(ParseLaTeX(output), CheckAtom(name)); + } + + /// \bmod is a binary operator whose nucleus is the word "mod". + [Fact] + public void TestModulo() { + var list = ParseLaTeX(@"x\bmod y"); + Assert.Collection(list, + CheckAtom("x"), + CheckAtom("mod"), + CheckAtom("y")); + Assert.Equal(@"x\bmod y", LaTeXParser.MathListToLaTeX(list).ToString()); + } + [Theory] [InlineData(@"\TeX")] [InlineData(@"\left.\mathrm{T\! \raisebox{-4.5mu}{E}\mkern-2.25muX}\right.")] @@ -1561,6 +1590,9 @@ public void TestHelpfulErrorMessage(string input, int index, string expected) { InlineData(@"\operatorname {a|}", @"Error: Expected } \operatorname {a|} ↑ (pos 16)"), + InlineData(@"\operatorname{\pm}", @"Error: Invalid command \pm in an operator name +\operatorname{\pm} + ↑ (pos 17)"), ] public void TestErrors(string badInput, string expected) { var (list, actual) = LaTeXParser.MathListFromLaTeX(badInput); diff --git a/CSharpMath.Evaluation.Tests/AngouriMathLatexSweepTests.cs b/CSharpMath.Evaluation.Tests/AngouriMathLatexSweepTests.cs new file mode 100644 index 00000000..a4ead6f8 --- /dev/null +++ b/CSharpMath.Evaluation.Tests/AngouriMathLatexSweepTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace CSharpMath.EvaluationTests { + using Atom; + + /// + /// throws InvalidCodePathException on any LaTeX it + /// cannot read, and says why in its own source: "CSharpMath must handle all LaTeX coming from + /// AngouriMath or a bug is present!". Nothing checked that, on either side — AngouriMath's own + /// Docs/Usage/Syntax.md says the LaTeX round trip "is checked in someone else's + /// repository", meaning this one. So this sweeps every node AngouriMath can print and asserts + /// the LaTeX comes back through the parser. Each failure here is a crash waiting for a user. + /// + public class AngouriMathLatexSweepTests { + /// + /// False for nodes built by reflection: filling every argument with x can produce a node + /// that is not well-formed, so a throw out of Latexize is not evidence of a defect. The + /// hand-built shapes below are strict, because those are known-good expressions. + /// + record Case(string Name, Entity Node, bool Strict); + + static IEnumerable Nodes() { + var x = MathS.Var("x"); + var y = MathS.Var("y"); + foreach (var t in typeof(Entity).Assembly.GetTypes() + .Where(t => typeof(Entity).IsAssignableFrom(t) && !t.IsAbstract && !t.IsGenericTypeDefinition) + .OrderBy(t => t.Name, StringComparer.Ordinal)) { + Entity? built = null; + foreach (var ctor in t.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .OrderBy(c => c.GetParameters().Length)) { + var ps = ctor.GetParameters(); + if (ps.Length is 0 or > 4) continue; + if (!ps.All(p => typeof(Entity).IsAssignableFrom(p.ParameterType))) continue; + try { built = (Entity)ctor.Invoke(ps.Select(_ => (object)x).ToArray()); break; } catch { } + } + if (built is not null) yield return new(t.Name, built, false); + } + // Shapes reflection cannot reach, listed by hand because they are exactly the ones whose + // LaTeX is unusual. + Case Strict(string name, Entity node) => new(name, node, true); + yield return Strict("Matrix", MathS.Vector(1, 2, 3)); + yield return Strict("Matrix2x2", MathS.Matrix(new Entity[,] { { 1, 2 }, { 3, 4 } })); + yield return Strict("Piecewise", MathS.Piecewise((x, x > 0), (y, y > 0))); + yield return Strict("Integral", MathS.Integral(x, x)); + yield return Strict("IntegralRanged", MathS.Integral(x, x, 0, 1)); + yield return Strict("Derivative", MathS.Derivative(x, x)); + yield return Strict("Limit", MathS.Limit(x, x, 0)); + yield return Strict("Interval", new Entity.Set.Interval(0, true, 1, true)); + yield return Strict("FiniteSet", new Entity.Set.FiniteSet(1, 2, 3)); + yield return Strict("ConditionalSet", "{ x : x > 0 }".ToEntity()); + yield return Strict("Integers", "ZZ".ToEntity()); + yield return Strict("Reals", "RR".ToEntity()); + yield return Strict("Complexes", "CC".ToEntity()); + yield return Strict("Rationals", "QQ".ToEntity()); + yield return Strict("Booleans", "BB".ToEntity()); + yield return Strict("Rational", "3/2".ToEntity()); + yield return Strict("ComplexNumber", MathS.Numbers.Create(1, 2)); + yield return Strict("Factorial", MathS.Factorial(x)); + yield return Strict("Union", MathS.Union("A".ToEntity(), "B".ToEntity())); + yield return Strict("Intersection", MathS.Intersection("A".ToEntity(), "B".ToEntity())); + yield return Strict("SetMinus", MathS.SetSubtraction("A".ToEntity(), "B".ToEntity())); + yield return Strict("In", "x in RR".ToEntity()); + yield return Strict("Provided", "x provided x > 0".ToEntity()); + yield return Strict("Apply", "apply(f, x)".ToEntity()); + yield return Strict("Lambda", "lambda(x, x^2)".ToEntity()); + yield return Strict("Domain", "domain(x, RR)".ToEntity()); + yield return Strict("Signum", MathS.Signum(x)); + yield return Strict("Abs", MathS.Abs(x)); + yield return Strict("Modulo", MathS.Mod(x, y)); + yield return Strict("EulerTotient", MathS.NumberTheory.Phi(x)); + } + + [Fact] + public void EveryAngouriMathNodeLatexizesIntoSomethingCSharpMathCanParse() { + var failures = new List(); + var checkedCount = 0; + foreach (var (name, node, strict) in Nodes()) { + string latex; + try { + latex = node.Latexize(); + } catch (Exception e) { + if (strict) failures.Add($"{name}: Latexize threw {e.GetType().Name}: {e.Message}"); + continue; + } + checkedCount++; + LaTeXParser.MathListFromLaTeX(latex) + .Match(_ => { }, err => failures.Add($"{name}: {latex}{Environment.NewLine} -> {err}")); + } + // A guard that checks nothing would also report no failures. + Assert.True(checkedCount > 50, $"only {checkedCount} nodes reached the parser"); + Assert.True(failures.Count == 0, + $"checked {checkedCount} nodes, {failures.Count} unparseable:{Environment.NewLine} " + + string.Join(Environment.NewLine + " ", failures)); + } + } +} diff --git a/CSharpMath.Evaluation.Tests/EvaluationTests.cs b/CSharpMath.Evaluation.Tests/EvaluationTests.cs index 254cd57c..687e0d77 100644 --- a/CSharpMath.Evaluation.Tests/EvaluationTests.cs +++ b/CSharpMath.Evaluation.Tests/EvaluationTests.cs @@ -179,6 +179,20 @@ public void Numbers(string input, string converted, string output) => [InlineData(@"a / bc / d", @"\frac{\frac{a}{bc}}{d}", @"\frac{a}{bcd}")] [InlineData(@"-2/\sin x/y", @"\frac{\frac{-2}{\sin \left( x\right) }}{y}", @"\frac{-2}{\sin \left( x\right) \cdot y}")] public void BinaryOperators(string latex, string converted, string result) => Test(latex, converted, result); + /// + /// The two forms AngouriMath emits that had no reading here: \bmod for its modulo node + /// and \operatorname{\varphi} for Euler's totient. \bmod binds like multiplication + /// and division, which is AngouriMath's Priority.Mul, so the grouping cases below are + /// the point rather than decoration. + /// + [Theory] + [InlineData(@"x\bmod y", @"x\bmod y", @"x\bmod y")] + [InlineData(@"7\bmod 3", @"7\bmod 3", @"1")] + [InlineData(@"x+y\bmod z", @"x+y\bmod z", @"x+y\bmod z")] + [InlineData(@"x\bmod y+z", @"x\bmod y+z", @"x\bmod y+z")] + [InlineData(@"\operatorname{\varphi}(10)", @"\operatorname{φ} \left( 10\right) ", @"4")] + [InlineData(@"\operatorname{\varphi}(x)", @"\operatorname{φ} \left( x\right) ", @"\operatorname{φ} \left( x\right) ")] + public void AngouriMathOnlyForms(string latex, string converted, string result) => Test(latex, converted, result); [Theory] [InlineData(@"+i", @"\mathrm{i}", @"\mathrm{i}")] [InlineData(@"-i", @"-\mathrm{i}", @"-\mathrm{i}")] @@ -341,7 +355,7 @@ public void Numbers(string input, string converted, string output) => [InlineData(@"\sin \frac\pi2", @"\sin \left( \frac{\mathrm{\pi }}{2}\right) ", @"1")] [InlineData(@"\sin \frac\pi2+1", @"\sin \left( \frac{\mathrm{\pi }}{2}\right) +1", @"2")] [InlineData(@"\cos +x", @"\cos \left( x\right) ", @"\cos \left( x\right) ")] - [InlineData(@"\cos -x", @"\cos \left( -x\right) ", @"\cos \left( -x\right) ")] + [InlineData(@"\cos -x", @"\cos \left( -x\right) ", @"\cos \left( x\right) ")] // 2.2.0 uses evenness [InlineData(@"\tan x\%", @"\tan \left( \frac{x}{100}\right) ", @"\tan \left( \frac{x}{100}\right) ")] [InlineData(@"\tan x\%^2", @"\tan \left( \left( \frac{x}{100}\right) ^2\right) ", @"\tan \left( \left( \frac{x}{100}\right) ^2\right) ")] [InlineData(@"\cot x\times y", @"\cot \left( x\right) \cdot y", @"\cot \left( x\right) \cdot y")] @@ -391,7 +405,8 @@ public void Numbers(string input, string converted, string output) => [InlineData(@"\tan^{-1} x\%^2", @"\arctan \left( \left( \frac{x}{100}\right) ^2\right) ", @"\arctan \left( \left( \frac{x}{100}\right) ^2\right) ")] [InlineData(@"\cot^{-1} x\times y", @"\arccot \left( x\right) \cdot y", @"\arccot \left( x\right) \cdot y")] [InlineData(@"\cot^{-1} x/y", @"\frac{\arccot \left( x\right) }{y}", @"\frac{\arccot \left( x\right) }{y}")] - [InlineData(@"\cos^{-1} \arccos^{-1} x", @"\arccos \left( \cos \left( x\right) \right) ", @"x")] + // arccos(cos x) is x only on [0, pi]; AngouriMath 2.2.0 no longer claims it in general. + [InlineData(@"\cos^{-1} \arccos^{-1} x", @"\arccos \left( \cos \left( x\right) \right) ", @"\arccos \left( \cos \left( x\right) \right) ")] [InlineData(@"\sin^1 x", @"\sin \left( x\right) ^1", @"\sin \left( x\right) ")] [InlineData(@"\sin^{+1} x", @"\sin \left( x\right) ^1", @"\sin \left( x\right) ")] [InlineData(@"\sin^{+-1} x", @"\sin \left( x\right) ^{-1}", @"\csc \left( x\right) ")] @@ -869,9 +884,10 @@ public void SimpleArithmeticSyntax(string simpleSyntax, string latex) => [InlineData(@"\top\nleftrightarrow\bot", @"\top \veebar \bot ", @"\top ")] [InlineData(@"\bot\nleftrightarrow\bot", @"\bot \veebar \bot ", @"\bot ")] [InlineData(@"x=x", @"x=x", @"\top ")] - [InlineData(@"x\le x", @"x\leq x", @"\top ")] - [InlineData(@"x\leq x", @"x\leq x", @"\top ")] - [InlineData(@"x\leqslant x", @"x\leq x", @"\top ")] + // AngouriMath 2.2.0 carries the domain <= needs rather than asserting the tautology outright. + [InlineData(@"x\le x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")] + [InlineData(@"x\leq x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")] + [InlineData(@"x\leqslant x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")] [InlineData(@"x\neq y", @"x\neq y", @"x\neq y")] // Cannot simplify without knowing x and y [InlineData(@"1<2", @"1<2", @"\top ")] [InlineData(@"2<1", @"2<1", @"\bot ")] @@ -951,7 +967,8 @@ public void ChainedComparisons(string latex, string converted, string result, st [InlineData(@"-\operatorname{abs}(-1)", @"-\left| -1\right| ", @"-1")] [InlineData(@"-\operatorname{abs}\left|-1\right|", @"-\left| \left| -1\right| \right| ", @"-1")] [InlineData(@"-\left|1\right|^2", @"-\left| 1\right| ^2", @"-1")] - [InlineData(@"\operatorname{sgn}\operatorname{abs} x", @"\operatorname{sgn} \left( \left| x\right| \right) ", @"1")] + // AngouriMath 2.2.0 no longer answers 1 here: sgn(|x|) is 1 away from zero but 0 at zero. + [InlineData(@"\operatorname{sgn}\operatorname{abs} x", @"\operatorname{sgn} \left( \left| x\right| \right) ", @"\operatorname{sgn} \left( \left| x\right| \right) ")] public void Abs(string latex, string converted, string result) => Test(latex, converted, result); [Theory] [InlineData(@"\lim_{x\to2}x+1", @"\lim _{x\rightarrow 2}x+1", @"3")] diff --git a/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj b/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj index 8f16b6e7..9b5aa3db 100644 --- a/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj +++ b/CSharpMath.Evaluation/CSharpMath.Evaluation.csproj @@ -13,7 +13,7 @@ - + diff --git a/CSharpMath.Evaluation/Evaluation.cs b/CSharpMath.Evaluation/Evaluation.cs index 58285cf1..7fe4bcdd 100644 --- a/CSharpMath.Evaluation/Evaluation.cs +++ b/CSharpMath.Evaluation/Evaluation.cs @@ -498,6 +498,12 @@ string GreekToLaTeXCommandName(string n) => handleFunction = MathS.Signum; handleFunctionInverse = arg => MathS.NaN; goto handleFunction; + // Euler's totient, which AngouriMath writes \operatorname{\varphi}. It is not injective + // (φ(1) = φ(2) = 1), so there is no inverse to offer -- as for abs and sgn above. + case Atoms.LargeOperator { Nucleus: "φ" }: + handleFunction = MathS.NumberTheory.Phi; + handleFunctionInverse = arg => MathS.NaN; + goto handleFunction; case Atoms.LargeOperator { Nucleus: "lim", Subscript: var limitSubscript }: Entity limitVariable, limitTarget; int limitSubscriptIndex = 0; @@ -567,6 +573,12 @@ string GreekToLaTeXCommandName(string n) => handlePrecedence = Precedence.MultiplicationDivision; handleBinary = (a, b) => a / b; goto handleBinary; + // \bmod, which AngouriMath emits for its modulo node. It binds like multiplication and + // division there too (Priority.Mul), so a+b \bmod c is a+(b mod c) on both sides. + case Atoms.BinaryOperator { Nucleus: "mod" }: + handlePrecedence = Precedence.MultiplicationDivision; + handleBinary = MathS.Mod; + goto handleBinary; case Atoms.Ordinary { Nucleus: "%" }: handlePostfix = x => x / 100; goto handlePostfix; diff --git a/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs b/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs new file mode 100644 index 00000000..23452f75 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs @@ -0,0 +1,37 @@ +using Xunit; + +namespace CSharpMath.Rendering.Tests { + /// + /// \bmod is the first binary operator here whose nucleus is a word rather than a symbol, + /// and \operatorname{φ} the first operator name written with a non-ASCII letter. Both come + /// from AngouriMath. Glyph coverage is already asserted by + /// ; what is new is the layout, so this + /// measures rather than comparing against a baseline image. + /// + public class TestAngouriMathForms { + static System.Drawing.RectangleF Measure(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + Assert.Null(painter.ErrorMessage); + return painter.Measure(FrontEnd.TextPainter.DefaultCanvasWidth); + } + + [Theory] + [InlineData(@"x\bmod y")] + [InlineData(@"\operatorname{φ}\left( x\right) ")] + [InlineData(@"\operatorname{φ}\left( x\bmod y\right) ")] + public void TheyLayOut(string latex) { + var measured = Measure(latex); + Assert.True(measured.Width > 0, $"zero width for {latex}"); + Assert.True(measured.Height > 0, $"zero height for {latex}"); + } + + /// + /// The three letters of "mod" and the spacing around a binary operator are actually laid out, + /// rather than the atom occupying no room -- which a zero-width check alone would not catch, + /// since x and y are there either way. + /// + [Fact] + public void ModuloTakesUpRoom() => + Assert.True(Measure(@"x\bmod y").Width > Measure(@"xy").Width * 2); + } +} diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 3b4f86f0..3ef5599a 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -195,9 +195,30 @@ public static class LaTeXSettings { } }, { @"\operatorname", (parser, accumulate, stopChar) => { if (!parser.ReadCharIfAvailable('{')) return "Expected {"; - var operatorname = parser.ReadString(); + // An operator name is a word, so letters -- but a letter may be written as a command: + // AngouriMath emits \operatorname{\varphi} for Euler's totient, and φ is a letter like + // any other. Commands standing for anything else are refused rather than spliced in, + // which keeps \operatorname{a|} the error it has always been. + // + // Letters are taken as char.IsLetter and not as parser.ReadString(), which is ASCII-only: + // this method writes the name back out as \operatorname{φ}, so refusing to read φ would + // mean refusing to read its own output. + var operatorname = new StringBuilder(); + while (true) { + while (parser.HasCharacters) { + var ch = parser.ReadChar(); + if (char.IsLetter(ch)) operatorname.Append(ch); + else { parser.UndoReadChar(); break; } + } + if (!parser.ReadCharIfAvailable('\\')) break; + var command = parser.ReadString(); + if (!(AtomForCommand(@"\" + command) is { } letter) + || letter.Nucleus.Length == 0 || !letter.Nucleus.All(char.IsLetter)) + return $@"Invalid command \{command} in an operator name"; + operatorname.Append(letter.Nucleus); + } if (!parser.ReadCharIfAvailable('}')) return "Expected }"; - return Ok(new LargeOperator(operatorname, null)); + return Ok(new LargeOperator(operatorname.ToString(), null)); } }, // Bra and Ket implementations are derived from Donald Arseneau's braket LaTeX package. // See: https://www.ctan.org/pkg/braket @@ -689,6 +710,11 @@ atom is Accent accent { @"\wedge", @"\land", new BinaryOperator("∧") }, { @"\setminus", new BinaryOperator("∖") }, { @"\wr", new BinaryOperator("≀") }, + // Not a symbol but a word: \bmod is \mathbin{\operatorname{mod}}, so binary spacing with an + // upright nucleus. A BinaryOperator gives both -- only Variable and Number are put through + // the italicising font changer, so "mod" stays upright. AngouriMath emits this for its + // modulo node, e.g. `x \bmod y`. + { @"\bmod", new BinaryOperator("mod") }, { @"-", new BinaryOperator("−") }, // Use the math minus sign, not hyphen { @"\diamond", new BinaryOperator("⋄") }, { @"\bigtriangleup", new BinaryOperator("△") },