diff --git a/.github/workflows/Format.yml b/.github/workflows/Format.yml index 7be9c907..25457d72 100644 --- a/.github/workflows/Format.yml +++ b/.github/workflows/Format.yml @@ -13,5 +13,14 @@ jobs: - uses: actions/checkout@v7 with: submodules: recursive + - name: Remove Uno.Example from solution + # CSharpMath.Uno.Example must be removed rather than excluded: dotnet format's + # --exclude only skips formatting analysis, but the project is still restored + # and its Uno.Resizetizer splash-screen task crashes inside dotnet format due + # to a SkiaSharp assembly-version conflict (the tool hosts SkiaSharp 2.88 + # while Uno.Resizetizer requires 3.x / native libSkiaSharp 116.0). + shell: bash + run: + dotnet sln CSharpMath.slnx remove CSharpMath.Uno.Example/CSharpMath.Uno.Example.csproj - name: Check formatting (Fix with "dotnet format --exclude Typography" at repository root) run: dotnet format --exclude Typography --verify-no-changes --verbosity diagnostic \ No newline at end of file diff --git a/CSharpMath.Core.Example/BackEnd/JsonMathTable.cs b/CSharpMath.Core.Example/BackEnd/JsonMathTable.cs index 542cacff..e74ed94f 100644 --- a/CSharpMath.Core.Example/BackEnd/JsonMathTable.cs +++ b/CSharpMath.Core.Example/BackEnd/JsonMathTable.cs @@ -205,6 +205,14 @@ public override float LowerLimitGapMin(TFont font) => public override float LowerLimitBaselineDropMin(TFont font) => ConstantFromTable(font, nameof(LowerLimitBaselineDropMin)); + public override float StretchStackTopShiftUp(TFont font) => + ConstantFromTable(font, nameof(StretchStackTopShiftUp)); + public override float StretchStackBottomShiftDown(TFont font) => + ConstantFromTable(font, nameof(StretchStackBottomShiftDown)); + public override float StretchStackGapAboveMin(TFont font) => + ConstantFromTable(font, nameof(StretchStackGapAboveMin)); + public override float StretchStackGapBelowMin(TFont font) => + ConstantFromTable(font, nameof(StretchStackGapBelowMin)); #region overline/underline public override float UnderbarVerticalGap(TFont font) => ConstantFromTable(font, nameof(UnderbarVerticalGap)); diff --git a/CSharpMath.Core.Tests/Atom/IosMath2026PortTests.cs b/CSharpMath.Core.Tests/Atom/IosMath2026PortTests.cs new file mode 100644 index 00000000..41bce160 --- /dev/null +++ b/CSharpMath.Core.Tests/Atom/IosMath2026PortTests.cs @@ -0,0 +1,655 @@ +using System; +using System.Linq; +using CSharpMath.Atom; +using CSharpMath.Atom.Atoms; +using Xunit; + +namespace CSharpMath.Core.AtomTests { + // Tests for the features ported from iosMath's 2026 commits. + public class IosMath2026PortTests { + static MathList ParseLaTeX(string latex) { + var builder = new LaTeXParser(latex); + var (mathList, error) = builder.Build(); + Assert.Null(error); + Assert.NotNull(mathList); + return mathList; + } + static string RoundTrip(string latex) => LaTeXParser.MathListToLaTeX(ParseLaTeX(latex)).ToString(); + static Action CheckAtom(string nucleus) where T : MathAtom => + atom => { + var typed = Assert.IsType(atom); + Assert.Equal(nucleus, typed.Nucleus); + }; + + #region Fraction family (\dfrac \tfrac \dbinom \tbinom \cfrac) — iosMath 58b1f8d + [Fact] + public void DfracParsesWithDisplayStyleOverride() { + var list = ParseLaTeX(@"\dfrac1c"); + var frac = Assert.IsType(Assert.Single(list)); + Assert.True(frac.HasRule); + Assert.Equal(FractionStyle.Display, frac.StyleOverride); + Assert.Equal(@"\frac{\displaystyle{1}}{\displaystyle{c}}", RoundTrip(@"\dfrac1c")); + } + [Fact] + public void TfracParsesWithTextStyleOverride() { + var frac = Assert.IsType(Assert.Single(ParseLaTeX(@"\tfrac1c"))); + Assert.True(frac.HasRule); + Assert.Equal(FractionStyle.Text, frac.StyleOverride); + Assert.Equal(@"\frac{\textstyle{1}}{\textstyle{c}}", RoundTrip(@"\tfrac1c")); + } + [Fact] + public void DbinomAndTbinomParse() { + var dbinom = Assert.IsType(Assert.Single(ParseLaTeX(@"\dbinom{n}{k}"))); + Assert.False(dbinom.HasRule); + Assert.Equal(FractionStyle.Display, dbinom.StyleOverride); + Assert.Equal(new Boundary("("), dbinom.LeftDelimiter); + var tbinom = Assert.IsType(Assert.Single(ParseLaTeX(@"\tbinom{n}{k}"))); + Assert.False(tbinom.HasRule); + Assert.Equal(FractionStyle.Text, tbinom.StyleOverride); + } + [Fact] + public void CfracParsesWithAlignment() { + var cfrac = Assert.IsType(Assert.Single(ParseLaTeX(@"\cfrac[l]{a}{b}"))); + Assert.True(cfrac.HasRule); + Assert.True(cfrac.IsContinuedFraction); + Assert.Equal(FractionAlignment.Left, cfrac.NumeratorAlignment); + var right = Assert.IsType(Assert.Single(ParseLaTeX(@"\cfrac[r]{a}{b}"))); + Assert.Equal(FractionAlignment.Right, right.NumeratorAlignment); + // Invalid alignment is a parse error. + var builder = new LaTeXParser(@"\cfrac[zzz]{a}{b}"); + Assert.NotNull(builder.Build().Error); + } + [Fact] + public void PlainFracKeepsAutoStyle() { + var frac = Assert.IsType(Assert.Single(ParseLaTeX(@"\frac1c"))); + Assert.Equal(FractionStyle.Auto, frac.StyleOverride); + Assert.False(frac.IsContinuedFraction); + Assert.Equal(@"\frac{1}{c}", RoundTrip(@"\frac1c")); + } + #endregion + + #region Over/under stack commands — iosMath 43626a4 + 94d8edf + [Theory] + [InlineData("overrightarrow", "→", true)] + [InlineData("overleftarrow", "←", true)] + [InlineData("overleftrightarrow", "↔", true)] + [InlineData("underrightarrow", "→", false)] + [InlineData("underleftarrow", "←", false)] + [InlineData("underleftrightarrow", "↔", false)] + [InlineData("overbrace", "⏞", true)] + public void StretchyStackCommandsParse(string command, string glyph, bool hasOver) { + var stack = Assert.IsType(Assert.Single(ParseLaTeX($@"\{command}{{x}}"))); + Assert.Equal(typeof(Ordinary), stack.DisplayClassType); + Assert.Single(stack.InnerList); + if (hasOver) { + var extensible = Assert.IsType(stack.Over); + Assert.Equal(glyph, extensible.Glyph); + Assert.Null(stack.Under); + } else { + var extensible = Assert.IsType(stack.Under); + Assert.Equal(glyph, extensible.Glyph); + Assert.Null(stack.Over); + } + Assert.Equal($@"\{command}{{x}}", RoundTrip($@"\{command}{{x}}")); + } + // \underbrace keeps its pre-port UnderAnnotation registration (its _ attaches an + // under-list), which the iosMath Stack port does not replace. + [Fact] + public void UnderbraceStaysAnUnderAnnotation() { + var underbrace = Assert.IsType(Assert.Single(ParseLaTeX(@"\underbrace{x}"))); + Assert.Equal("\u23df", underbrace.Nucleus); + } + [Fact] + public void StackrelForcesRelationClass() { + var stack = Assert.IsType(Assert.Single(ParseLaTeX(@"\stackrel{?}{=}"))); + Assert.Equal(typeof(Relation), stack.DisplayClassType); + var overRow = Assert.IsType(stack.Over); + Assert.Single(overRow.List); + Assert.Equal(@"\stackrel{?}{=}", RoundTrip(@"\stackrel{?}{=}")); + } + [Fact] + public void StackbinForcesBinaryClass() { + var stack = Assert.IsType(Assert.Single(ParseLaTeX(@"\stackbin{x}{+}"))); + Assert.Equal(typeof(BinaryOperator), stack.DisplayClassType); + Assert.Equal(@"\stackbin{x}{+}", RoundTrip(@"\stackbin{x}{+}")); + } + [Theory] + [InlineData(@"\overset{!}{=}", typeof(Relation))] // lone Rel base inherits Relation + [InlineData(@"\overset{a}{+}", typeof(BinaryOperator))] // lone Bin base inherits Binary + [InlineData(@"\overset{a}{x}", typeof(Ordinary))] + public void OversetInheritsBaseClass(string latex, System.Type expectedClass) { + var stack = Assert.IsType(Assert.Single(ParseLaTeX(latex))); + Assert.Equal(expectedClass, stack.DisplayClassType); + } + [Fact] + public void UndersetParsesWithUnderRow() { + var stack = Assert.IsType(Assert.Single(ParseLaTeX(@"\underset{b}{x}"))); + Assert.NotNull(stack.Under); + Assert.Null(stack.Over); + Assert.Equal(typeof(Ordinary), stack.DisplayClassType); + Assert.Equal(@"\underset{b}{x}", RoundTrip(@"\underset{b}{x}")); + } + [Fact] + public void OversetCanonicalizesToStackrelOrStackbinOnRoundTrip() { + // A lone Bin/Rel base canonicalizes to the dedicated command. + Assert.Equal(@"\stackrel{!}{=}", RoundTrip(@"\overset{!}{=}")); + Assert.Equal(@"\stackbin{a}{+}", RoundTrip(@"\overset{a}{+}")); + } + #endregion + + #region Box family: phantom/smash/lap + cancel — iosMath 9f53483 + d49f251 + [Theory] + [InlineData(@"\phantom{x}", true, true, true, false)] + [InlineData(@"\hphantom{x}", true, false, false, false)] + [InlineData(@"\vphantom{x}", false, true, true, false)] + [InlineData(@"\smash{x}", true, false, false, true)] + public void PhantomFamilyParses(string latex, bool kW, bool kH, bool kD, bool draw) { + var box = Assert.IsType(Assert.Single(ParseLaTeX(latex))); + Assert.Equal(kW, box.KeepWidth); + Assert.Equal(kH, box.KeepHeight); + Assert.Equal(kD, box.KeepDepth); + Assert.Equal(draw, box.DrawChild); + Assert.Equal(StrikeStyle.None, box.StrikeStyle); + } + [Fact] + public void MathStrutSynthesizesParen() { + var box = Assert.IsType(Assert.Single(ParseLaTeX(@"\mathstrut"))); + Assert.False(box.KeepWidth); + Assert.True(box.KeepHeight); + Assert.True(box.KeepDepth); + var paren = Assert.IsType(Assert.Single(box.InnerList)); + Assert.Equal("(", paren.Nucleus); + // Lossy round trip: \mathstrut serializes as \vphantom{(} + Assert.Equal(@"\vphantom{(}", RoundTrip(@"\mathstrut")); + } + [Fact] + public void SmashOptionalArgSelectsAxes() { + var smashT = Assert.IsType(Assert.Single(ParseLaTeX(@"\smash[t]{x}"))); + Assert.False(smashT.KeepHeight); + Assert.True(smashT.KeepDepth); + var smashB = Assert.IsType(Assert.Single(ParseLaTeX(@"\smash[b]{x}"))); + Assert.True(smashB.KeepHeight); + Assert.False(smashB.KeepDepth); + // Unknown optional value degrades to plain smash without error. + var smashQ = Assert.IsType(Assert.Single(ParseLaTeX(@"\smash[q]{x}"))); + Assert.False(smashQ.KeepHeight); + Assert.False(smashQ.KeepDepth); + } + [Theory] + [InlineData(@"\llap{x}", BoxHAlign.Right)] + [InlineData(@"\rlap{x}", BoxHAlign.Left)] + [InlineData(@"\clap{x}", BoxHAlign.Center)] + [InlineData(@"\mathllap{x}", BoxHAlign.Right)] + [InlineData(@"\mathrlap{x}", BoxHAlign.Left)] + [InlineData(@"\mathclap{x}", BoxHAlign.Center)] + public void LapsParse(string latex, BoxHAlign align) { + var box = Assert.IsType(Assert.Single(ParseLaTeX(latex))); + Assert.False(box.KeepWidth); + Assert.True(box.DrawChild); + Assert.Equal(align, box.HAlign); + } + [Theory] + [InlineData(@"\llap{x}", @"\llap{x}")] + [InlineData(@"\rlap{x}", @"\rlap{x}")] + [InlineData(@"\clap{x}", @"\clap{x}")] + public void LapsRoundTrip(string latex, string expected) { + Assert.Equal(expected, RoundTrip(latex)); + } + [Theory] + [InlineData(@"\cancel{x}", StrikeStyle.Forward)] + [InlineData(@"\bcancel{x}", StrikeStyle.Backward)] + [InlineData(@"\xcancel{x}", StrikeStyle.Cross)] + [InlineData(@"\sout{x}", StrikeStyle.Horizontal)] + public void CancelFamilyParses(string latex, StrikeStyle strike) { + var box = Assert.IsType(Assert.Single(ParseLaTeX(latex))); + Assert.True(box.KeepWidth && box.KeepHeight && box.KeepDepth && box.DrawChild); + Assert.Equal(strike, box.StrikeStyle); + Assert.Equal(latex, RoundTrip(latex)); + } + [Fact] + public void CancelAtEofYieldsEmptyBox() { + var box = Assert.IsType(Assert.Single(ParseLaTeX(@"\cancel"))); + Assert.Equal(StrikeStyle.Forward, box.StrikeStyle); + Assert.Empty(box.InnerList); + } + #endregion + + #region Modular arithmetic macros — iosMath 035a9e2 + e278be3 + 4091668 + [Theory] + [InlineData(@"\pmod{n}", 1)] + [InlineData(@"\mod{n}", 1)] + [InlineData(@"\pod{n}", 1)] + [InlineData(@"x\implies y", 0)] + [InlineData(@"x\iff y", 0)] + [InlineData(@"\varliminf", 0)] + public void MacrosRoundTrip(string latex, int argc) { + var macro = Assert.Single(ParseLaTeX(latex), a => a is Macro) as Macro; + Assert.NotNull(macro); + Assert.Equal(argc, macro.Arguments.Count); + Assert.NotEmpty(macro.Expansion()); + // Zero-arg macros keep a trailing space so the name terminates; the others + // round-trip byte-identically. + Assert.Equal(latex, RoundTrip(latex).TrimEnd()); + } + [Fact] + public void PmodExpandsToParenthesizedMod() { + var finalized = ParseLaTeX(@"\pmod{n}").Clone(true); + // Expansion: mkern8mu ( m o d mkern6mu n ) — \mathrm{mod} contributes three + // roman atoms, so the flat expansion is 8 atoms wide. + Assert.DoesNotContain(finalized, a => a is Macro); + Assert.Equal(8, finalized.Count); + Assert.IsType(finalized[0]); + Assert.IsType(finalized[1]); + Assert.IsType(finalized[7]); + } + [Fact] + public void MacroScriptsTransferToExpansion() { + var finalized = ParseLaTeX(@"\pmod{n}^2").Clone(true); + // The superscript lands on the closing paren of the expansion. + var last = Assert.IsType(finalized.Last); + Assert.Single(last.Superscript); + } + [Fact] + public void MissingMacroArgumentIsError() { + Assert.NotNull(new LaTeXParser(@"\pmod").Build().Error); + Assert.NotNull(new LaTeXParser(@"\mod").Build().Error); + Assert.NotNull(new LaTeXParser(@"\pod").Build().Error); + } + [Fact] + public void AddMacroRegistersAndReplaces() { + // iosMath b2b19a8: runtime macro registration, with replacement on re-register. + // Compare finalized expansions, as the invocation itself serializes back as \half. + LaTeXSettings.AddMacro("half", 0, @"\frac{1}{2}"); + Assert.Equal(FinalizedDebugString(@"\frac{1}{2}"), FinalizedDebugString(@"\half")); + LaTeXSettings.AddMacro("half", 0, @"\frac{1}{3}"); + Assert.Equal(FinalizedDebugString(@"\frac{1}{3}"), FinalizedDebugString(@"\half")); + } + static string FinalizedDebugString(string latex) => ParseLaTeX(latex).Clone(true).DebugString; + // iosMath 76fd773: fusing across a font-style change corrupts the style — Fuse + // keeps the first atom's FontStyle, so 1\mathit{2} would drop the italic and + // \mathit{1}2 would spread it onto a plain digit. + [Theory] + [InlineData(@"1\mathit{2}", FontStyle.Default, FontStyle.Italic)] + [InlineData(@"\mathit{1}2", FontStyle.Italic, FontStyle.Default)] + public void FinalizedDoesNotFuseDigitsAcrossFontStyles(string latex, FontStyle firstStyle, FontStyle secondStyle) { + var finalized = ParseLaTeX(latex).Clone(true); + Assert.Equal(2, finalized.Count); + var first = Assert.IsType(finalized[0]); + var second = Assert.IsType(finalized[1]); + Assert.Equal("1", first.Nucleus); + Assert.Equal("2", second.Nucleus); + Assert.Equal(firstStyle, first.FontStyle); + Assert.Equal(secondStyle, second.FontStyle); + } + [Fact] + public void AddMacroValidatesArityAndTemplate() { + Assert.Throws(() => LaTeXSettings.AddMacro("ten", 10, @"x")); + // #2 referenced but only one argument declared. + Assert.Throws(() => LaTeXSettings.AddMacro("badref", 1, @"#2")); + Assert.Throws(() => LaTeXSettings.AddMacro("trailing", 0, @"x#")); + } + [Fact] + public void RawMacroArgumentsPreserveNestedBracesAndHashes() { + LaTeXSettings.AddMacro("rawprobe", 1, @"#1"); + var source = @"\rawprobe{{a}{b}}"; + Assert.Equal(source, RoundTrip(source)); + Assert.Equal("{a}{b}", Assert.IsType(Assert.Single(ParseLaTeX(source))).Arguments[0]); + } + [Fact] + public void MacroSupportsLiteralHashEscape() { + LaTeXSettings.AddMacro("hashprobe", 0, @"\textcolor{##f00}{x}"); + Assert.NotNull(ParseLaTeX(@"\hashprobe").Clone(true)); + } + [Fact] + public void MacroExpansionErrorsCarryCommandContext() { + LaTeXSettings.AddMacro("badexpansion", 0, @"{"); + var error = new LaTeXParser(@"\badexpansion").Build().Error; + Assert.Contains(@"\badexpansion", error); + } + [Fact] + public void MacroExpansionHonorsRecursionCap() { + LaTeXSettings.AddMacro("recursiveProbe", 0, @"\recursiveProbe"); + var error = new LaTeXParser(@"\recursiveProbe").Build().Error; + Assert.Contains("depth", error, System.StringComparison.OrdinalIgnoreCase); + } + [Fact] + public void MacroNestedPlaceholdersAndScriptsRemainAttached() { + LaTeXSettings.AddMacro("joinProbe", 2, @"[#2|#1]"); + var list = ParseLaTeX(@"\joinProbe{{a_b}}{c}^2"); + var macro = Assert.IsType(Assert.Single(list)); + Assert.Equal(new[] { "{a_b}", "c" }, macro.Arguments); + var final = list.Clone(true); + Assert.Contains(final, atom => atom.Superscript.Count == 1); + } + + [Theory] + [InlineData(@"\big(", 1, "Ordinary", "(")] + [InlineData(@"\Big[", 2, "Ordinary", "[")] + [InlineData(@"\bigg\{", 3, "Ordinary", "{")] + [InlineData(@"\Bigg\langle", 4, "Ordinary", "⟨")] + [InlineData(@"\bigl(", 1, "Open", "(")] + [InlineData(@"\Bigl[", 2, "Open", "[")] + [InlineData(@"\biggl\{", 3, "Open", "{")] + [InlineData(@"\Biggl\lceil", 4, "Open", "⌈")] + [InlineData(@"\bigr)", 1, "Close", ")")] + [InlineData(@"\Bigr]", 2, "Close", "]")] + [InlineData(@"\biggr\}", 3, "Close", "}")] + [InlineData(@"\Biggr\rfloor", 4, "Close", "⌋")] + [InlineData(@"\bigm|", 1, "Relation", "|")] + [InlineData(@"\Bigm\|", 2, "Relation", "‖")] + [InlineData(@"\biggm\Vert", 3, "Relation", "‖")] + [InlineData(@"\Biggm\langle", 4, "Relation", "⟨")] + public void ExplicitLargeDelimitersParseAndRoundTrip(string latex, int size, string mathClass, string nucleus) { + var delimiter = Assert.IsType(Assert.Single(ParseLaTeX(latex))); + Assert.Equal(size, (int)delimiter.Size); + Assert.Equal(mathClass, delimiter.MathClass.Name); + Assert.Equal(nucleus, delimiter.Nucleus); + var expected = latex.EndsWith(@"\Vert") ? latex.Replace(@"\Vert", @"\|") : + latex.EndsWith(@"\langle") ? latex.Replace(@"\langle", "<") : latex; + Assert.Equal(expected, RoundTrip(latex)); + } + [Fact] + public void ExplicitLargeDelimiterSupportsNullAndScripts() { + var list = ParseLaTeX(@"\bigl.^2\bigr."); + Assert.Collection(list, + left => { var d = Assert.IsType(left); Assert.Equal("", d.Nucleus); Assert.Single(d.Superscript); }, + right => { var d = Assert.IsType(right); Assert.Equal("", d.Nucleus); }); + Assert.Equal(@"\bigl.^2\bigr.", RoundTrip(@"\bigl.^2\bigr.")); + } + [Theory] + [InlineData(@"\big")] + [InlineData(@"\Bigl")] + [InlineData(@"\bigm?")] + public void ExplicitLargeDelimiterRejectsMissingOrInvalidDelimiter(string latex) { + Assert.NotNull(new LaTeXParser(latex).Build().Error); + } + [Fact] + public void ExplicitLargeDelimiterRemainsIndependentFromAdjacentOrdinaryAtoms() { + var list = ParseLaTeX(@"a\bigl(b"); + Assert.Equal(3, list.Count); + Assert.IsType(list[0]); + Assert.IsType(list[1]); + Assert.IsType(list[2]); + } + + [Fact] + public void TableSpacersAreIndependentAndStylesCanBeSharedSafely() { + var table = Assert.IsType(Assert.Single(ParseLaTeX(@"\begin{aligned}a&b\\c&d\end{aligned}"))); + var spacer0 = table.Cells[0][1][0]; + var spacer1 = table.Cells[1][1][0]; + Assert.NotSame(spacer0, spacer1); + spacer0.Nucleus = "changed"; + Assert.NotEqual("changed", spacer1.Nucleus); + var casesInner = Assert.IsType(Assert.Single(ParseLaTeX(@"\begin{cases}a&b\\c&d\end{cases}"))); + var matrix = Assert.IsType
(casesInner.InnerList[1]); + var style0 = matrix.Cells[0][0].First(); + var style1 = matrix.Cells[1][1].First(); + Assert.Same(style0, style1); + var style = Assert.IsType