diff --git a/CSharpMath.Core.Tests/Display/TypesetterTests.cs b/CSharpMath.Core.Tests/Display/TypesetterTests.cs index d6fb2e45..793810fc 100644 --- a/CSharpMath.Core.Tests/Display/TypesetterTests.cs +++ b/CSharpMath.Core.Tests/Display/TypesetterTests.cs @@ -11,6 +11,15 @@ namespace CSharpMath.Core.DisplayTests { public class TypesetterTests { + private sealed class NonScalarAlignedGlyphFinder : IGlyphFinder { + public TGlyph FindGlyphForCharacterAtIndex(TFont font, int index, string str) => + TestGlyphFinder.Instance.FindGlyphForCharacterAtIndex(font, index, str); + public System.Collections.Generic.IEnumerable FindGlyphs(TFont font, string str) => + TestGlyphFinder.Instance.FindGlyphs(font, str).Take(1); + public TGlyph EmptyGlyph => TestGlyphFinder.Instance.EmptyGlyph; + public bool GlyphIsEmpty(TGlyph glyph) => TestGlyphFinder.Instance.GlyphIsEmpty(glyph); + } + private sealed class MalformedAssemblyTable : JsonMathTable { private readonly float _advance, _connector; public MalformedAssemblyTable(float advance, float connector = 0) : base( @@ -62,6 +71,38 @@ public void NonFiniteAssemblyMetricsFailDeterministically(bool horizontal) { AtomTests.LaTeXParserTest.ParseLaTeX(latex), _font, context, LineStyle.Display)); } + [Fact] + public void FusedMathAtomsRequireOneGlyphPerUnicodeScalar() { + var context = new TypesettingContext( + (font, size) => new TFont(size), TestGlyphBoundsProvider.Instance, + new NonScalarAlignedGlyphFinder(), _context.MathTable); + + var error = Assert.Throws(() => Typesetter.CreateLine( + AtomTests.LaTeXParserTest.ParseLaTeX("P2"), _font, context, LineStyle.Display)); + + Assert.Contains("one glyph per Unicode scalar", error.Message); + } + + [Fact] + public void PublicInnerDisplayReflectsMutableInnerInkExtent() { + var child = new GlyphDisplay( + new TGlyph('P'), Range.NotFound, _font, 1, 1, 10); + var inner = new ListDisplay(new IDisplay[] { child }); + var right = new GlyphDisplay( + new TGlyph(')'), Range.NotFound, _font, 1, 1, 2); + var display = new InnerDisplay(inner, null, right, Range.NotFound); + display.Position = new PointF(5, 0); + + Assert.Equal(12, display.Width); + Assert.Equal(15, right.Position.X); + + child.Position = new PointF(20, 0); + display.Position = new PointF(5, 0); + + Assert.Equal(32, display.Width); + Assert.Equal(35, right.Position.X); + } + static System.Action?> TestList((int, int) range, double ascent, double descent, double width, double x, double y, LinePosition linePos, int indexInParent, params System.Action>[] inspectors) => d => { var list = Assert.IsType>(d); @@ -106,7 +147,7 @@ public void TestSingleCharacter(string latex) => [Theory, InlineData("xyzw"), InlineData("xy2w"), InlineData("12.3"), InlineData("|`@/"), InlineData("1`y.")] public void TestVariablesNumbersAndOrdinaries(string latex) => - TestOuter(latex, 4, 14, 4, 40, + TestOuter(latex, 4, 14, 4, latex is "xy2w" or "1`y." ? 40.16 : 40, d => { var line = Assert.IsType>(d); Assert.Equal(4, line.Atoms.Count); @@ -117,7 +158,7 @@ public void TestVariablesNumbersAndOrdinaries(string latex) => Assert.Equal(14, line.Ascent); Assert.Equal(4, line.Descent); - Assert.Equal(40, line.Width); + Approximately.Equal(latex is "xy2w" or "1`y." ? 40.16 : 40, line.Width); }); [Theory] [InlineData("%\n1234", "1234", 1, 4)] @@ -309,7 +350,7 @@ public void TestFraction(string latex, double lineThickness) => }); [Theory, InlineData("2x+3=y"), InlineData("y=3+2x"), InlineData("y-3=2x"), InlineData("3=y-2x")] public void TestEquationWithOperatorsAndRelations(string latex) => - TestOuter(latex, 6, 14, 4, 80, d => { + TestOuter(latex, 6, 14, 4, latex is "2x+3=y" ? 80.32 : 80.16, d => { var line = Assert.IsType>(d); Assert.Equal(6, line.Atoms.Count); @@ -320,28 +361,28 @@ public void TestEquationWithOperatorsAndRelations(string latex) => Assert.Equal(14, line.Ascent); Assert.Equal(4, line.Descent); - Assert.Equal(80, line.Width); + Approximately.Equal(latex is "2x+3=y" ? 80.32 : 80.16, line.Width); }); [Theory, InlineData("[", "]"), InlineData("(", @"\}"), InlineData(@"\{", "]")] // Using ) confuses the test explorer... public void TestInner(string left, string right) => - TestOuter($@"a\left{left}x\right{right}", 2, 14, 4, 43.333, + TestOuter($@"a\left{left}x\right{right}", 2, 14, 4, 43.553, d => Assert.IsType>(d), d => { var inner = Assert.IsType>(d); - Approximately.At(13.333, 0, inner.Position); + Approximately.At(13.553, 0, inner.Position); Assert.Equal(new Range(1, 1), inner.Range); Assert.Equal(14, inner.Ascent); Assert.Equal(4, inner.Descent); Assert.Equal(30, inner.Width); var glyph = Assert.IsType>(inner.Left); - Approximately.At(13.333, 0, glyph.Position); + Approximately.At(13.553, 0, glyph.Position); Assert.Equal(Range.NotFound, glyph.Range); Assert.False(glyph.HasScript); Assert.Equal(left.EnumerateRunes().Last(), glyph.Glyph); - TestList(1, 14, 4, 10, 23.333, 0, LinePosition.Regular, Range.UndefinedInt, + TestList(1, 14, 4, 10, 23.553, 0, LinePosition.Regular, Range.UndefinedInt, d => { var line = Assert.IsType>(d); Assert.Single(line.Atoms); @@ -352,7 +393,7 @@ public void TestInner(string left, string right) => })(inner.Inner); var glyph2 = Assert.IsType>(inner.Right); - Approximately.At(33.333, 0, glyph2.Position); + Approximately.At(33.553, 0, glyph2.Position); Assert.Equal(Range.NotFound, glyph2.Range); Assert.False(glyph2.HasScript); Assert.Equal(right.EnumerateRunes().Last(), glyph2.Glyph); @@ -479,7 +520,7 @@ public void TestLimit() => Assert.Equal("lim", string.Concat(largeOpText.Text)); Approximately.Equal(new PointF(31.111f, 0), largeOpText.Position); Assert.False(largeOpText.HasScript); - TestList(3, 11.046, 2.8, 26, 38.111, -18.386, LinePosition.Regular, Range.UndefinedInt, + TestList(3, 11.046, 2.8, 26.32, 37.951, -18.386, LinePosition.Regular, Range.UndefinedInt, d => { var subscript = Assert.IsType>(d); Assert.Equal("𝑥→0", string.Concat(subscript.Text)); @@ -488,7 +529,7 @@ public void TestLimit() => Assert.True(subscript.HasScript); Assert.Equal(new Range(0, 3), subscript.Range); }, - TestList(1, 7, 2, 5, 21, 4.046, LinePosition.Superscript, 2, + TestList(1, 7, 2, 5, 21.32, 4.046, LinePosition.Superscript, 2, d => { var superscript = Assert.IsType>(d); Assert.Equal("+", string.Concat(superscript.Text)); diff --git a/CSharpMath.Core.Tests/Editor/PointForIndexTests.cs b/CSharpMath.Core.Tests/Editor/PointForIndexTests.cs index aaed55bd..3caf9b8d 100644 --- a/CSharpMath.Core.Tests/Editor/PointForIndexTests.cs +++ b/CSharpMath.Core.Tests/Editor/PointForIndexTests.cs @@ -142,8 +142,8 @@ public static TestData ExponentsData { public static TestData Issue46Data => new TestData { - { (57.777, 0), 4 }, - { (75.097, 0), 5 }, + { (58.097, 0), 4 }, + { (75.418, 0), 5 }, }; [Theory, MemberData(nameof(Issue46Data))] // https://github.com/verybadcat/CSharpMath/issues/46 public void Issue46(PointF point, MathListIndex expected) => Test("2+x+x^y", point, expected); @@ -209,10 +209,10 @@ public void Complex(PointF point, MathListIndex expected) => { (10, 9.68), 0, (SubIndex.Superscript, 0) }, { (17, 9.68), 0, (SubIndex.Superscript, 1) }, { (21.453, 0), 1 }, - { (31.453, 0), 2 }, - { (41.453, 0), 3 }, - { (51.453, 0), 4 }, - { (61.453, 0), 5 }, + { (31.773, 0), 2 }, + { (41.773, 0), 3 }, + { (51.773, 0), 4 }, + { (61.773, 0), 5 }, }; [Theory, MemberData(nameof(IntegralData))] public void Integral(PointF point, MathListIndex expected) => Test(@"\int_a^b x\ dx", point, expected); @@ -225,10 +225,10 @@ public void Complex(PointF point, MathListIndex expected) => { (1.5, 20.8), 0, (SubIndex.Superscript, 0) }, { (8.5, 20.8), 0, (SubIndex.Superscript, 1) }, { (13.333, 0), 1 }, - { (23.333, 0), 2 }, - { (33.333, 0), 3 }, - { (43.333, 0), 4 }, - { (53.333, 0), 5 }, + { (23.653, 0), 2 }, + { (33.653, 0), 3 }, + { (43.653, 0), 4 }, + { (53.653, 0), 5 }, }; [Theory, MemberData(nameof(IntegralLimitsData))] public void IntegralLimits(PointF point, MathListIndex expected) => Test(@"\int\limits_a^b x\ dx", point, expected); @@ -237,9 +237,9 @@ public void Complex(PointF point, MathListIndex expected) => { (0, 0), 0 }, { (13.333, 0), 1 }, { (53.333, 0), 1, (SubIndex.BetweenBaseAndScripts, 1) }, - { (22.833, -17.14), 1, (SubIndex.Subscript, 0) }, - { (29.833, -17.14), 1, (SubIndex.Subscript, 1) }, - { (36.833, -17.14), 1, (SubIndex.Subscript, 2) }, + { (22.673, -17.14), 1, (SubIndex.Subscript, 0) }, + { (29.993, -17.14), 1, (SubIndex.Subscript, 1) }, + { (36.993, -17.14), 1, (SubIndex.Subscript, 2) }, { (56.666, 0), 2 }, { (66.666, 0), 2, (SubIndex.BetweenBaseAndScripts, 1) }, { (66.666, -6.12), 2, (SubIndex.Subscript, 0) }, @@ -274,19 +274,19 @@ public void Complex(PointF point, MathListIndex expected) => new TestData { { (0, 0), 0 }, { (13.333, 0), 1 }, - { (26.667, 0), 2 }, - { (36.667, 0), 2, (SubIndex.Inner, 0) }, - { (46.667, 0), 2, (SubIndex.Inner, 1) }, - { (60, 0), 2, (SubIndex.Inner, 2) }, - { (70, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 0) }, - { (80, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 1) }, - { (90, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 2) }, - { (103.333, 0), 2, (SubIndex.Inner, 3) }, - { (113.333, 0), 2, (SubIndex.Inner, 4) }, - { (123.333, 0), 2, (SubIndex.Inner, 5) }, - { (136.666, 0), 3 }, - { (150, 0), 4 }, - { (160, 0), 5 }, + { (26.887, 0), 2 }, + { (36.887, 0), 2, (SubIndex.Inner, 0) }, + { (46.887, 0), 2, (SubIndex.Inner, 1) }, + { (60.22, 0), 2, (SubIndex.Inner, 2) }, + { (70.22, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 0) }, + { (80.22, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 1) }, + { (90.22, 0), 2, (SubIndex.Inner, 2), (SubIndex.Inner, 2) }, + { (103.553, 0), 2, (SubIndex.Inner, 3) }, + { (113.553, 0), 2, (SubIndex.Inner, 4) }, + { (123.553, 0), 2, (SubIndex.Inner, 5) }, + { (136.887, 0), 3 }, + { (150.22, 0), 4 }, + { (160.22, 0), 5 }, }; [Theory, MemberData(nameof(InnerData))] public void Inner(PointF point, MathListIndex expected) => Test(@"\int a\left(bb\left[cc\right]dd\right)e\sum ", point, expected); diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Cases.png b/CSharpMath.Rendering.Tests/MathDisplay/Cases.png index 3a5a78bb..2aa2607d 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Cases.png and b/CSharpMath.Rendering.Tests/MathDisplay/Cases.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/FontStyles.png b/CSharpMath.Rendering.Tests/MathDisplay/FontStyles.png index 230818fb..4c7db0eb 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/FontStyles.png and b/CSharpMath.Rendering.Tests/MathDisplay/FontStyles.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/FunctionDomainCodomain.png b/CSharpMath.Rendering.Tests/MathDisplay/FunctionDomainCodomain.png index ad7785e1..61025a03 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/FunctionDomainCodomain.png and b/CSharpMath.Rendering.Tests/MathDisplay/FunctionDomainCodomain.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Issue32MatrixRightDelimiter.png b/CSharpMath.Rendering.Tests/MathDisplay/Issue32MatrixRightDelimiter.png new file mode 100644 index 00000000..67ba5f47 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/Issue32MatrixRightDelimiter.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LnEquation.png b/CSharpMath.Rendering.Tests/MathDisplay/LnEquation.png index 15e73c0f..5cb13c97 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/LnEquation.png and b/CSharpMath.Rendering.Tests/MathDisplay/LnEquation.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Logic.png b/CSharpMath.Rendering.Tests/MathDisplay/Logic.png index 04a01748..ffb58e31 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Logic.png and b/CSharpMath.Rendering.Tests/MathDisplay/Logic.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix.png index a45ff0e6..84dca844 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix2.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix2.png index 4e5e54dd..cc5dcc50 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix2.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix2.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix3.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix3.png index 9611e87f..4f6226eb 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix3.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix3.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix4.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix4.png index 9bcf8ac0..478db0b1 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix4.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix4.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix5.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix5.png index 64402f4e..75ef99c5 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix5.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix5.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix6.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix6.png index 84a1b348..5dcc95e4 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix6.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix6.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrix7.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrix7.png index 0f83faca..2936bdb2 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrix7.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrix7.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Matrixception.png b/CSharpMath.Rendering.Tests/MathDisplay/Matrixception.png index a22286d9..dd029996 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Matrixception.png and b/CSharpMath.Rendering.Tests/MathDisplay/Matrixception.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/QuadraticFormula.png b/CSharpMath.Rendering.Tests/MathDisplay/QuadraticFormula.png index b80eb171..53a09a32 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/QuadraticFormula.png and b/CSharpMath.Rendering.Tests/MathDisplay/QuadraticFormula.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/QuarticSolutions.png b/CSharpMath.Rendering.Tests/MathDisplay/QuarticSolutions.png index 89b6ec08..56ec23b1 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/QuarticSolutions.png and b/CSharpMath.Rendering.Tests/MathDisplay/QuarticSolutions.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/SolveEquations.png b/CSharpMath.Rendering.Tests/MathDisplay/SolveEquations.png index 9f070b51..b73d65bb 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/SolveEquations.png and b/CSharpMath.Rendering.Tests/MathDisplay/SolveEquations.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/SummationWithCup.png b/CSharpMath.Rendering.Tests/MathDisplay/SummationWithCup.png index 2b587d0f..2d08ff1b 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/SummationWithCup.png and b/CSharpMath.Rendering.Tests/MathDisplay/SummationWithCup.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/TangentPeriodShift.png b/CSharpMath.Rendering.Tests/MathDisplay/TangentPeriodShift.png index fdfb44e9..536f7a0d 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/TangentPeriodShift.png and b/CSharpMath.Rendering.Tests/MathDisplay/TangentPeriodShift.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/UnderbraceSubscriptIntegral.png b/CSharpMath.Rendering.Tests/MathDisplay/UnderbraceSubscriptIntegral.png index 19a83195..d0101130 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/UnderbraceSubscriptIntegral.png and b/CSharpMath.Rendering.Tests/MathDisplay/UnderbraceSubscriptIntegral.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/VectorProjection.png b/CSharpMath.Rendering.Tests/MathDisplay/VectorProjection.png index 28eeacaf..484811d5 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/VectorProjection.png and b/CSharpMath.Rendering.Tests/MathDisplay/VectorProjection.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Cases.png b/CSharpMath.Rendering.Tests/MathInline/Cases.png index 3a5a78bb..2aa2607d 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Cases.png and b/CSharpMath.Rendering.Tests/MathInline/Cases.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/FontStyles.png b/CSharpMath.Rendering.Tests/MathInline/FontStyles.png index 230818fb..4c7db0eb 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/FontStyles.png and b/CSharpMath.Rendering.Tests/MathInline/FontStyles.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/FunctionDomainCodomain.png b/CSharpMath.Rendering.Tests/MathInline/FunctionDomainCodomain.png index ad7785e1..61025a03 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/FunctionDomainCodomain.png and b/CSharpMath.Rendering.Tests/MathInline/FunctionDomainCodomain.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LnEquation.png b/CSharpMath.Rendering.Tests/MathInline/LnEquation.png index cadfe0f2..256b5ad7 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/LnEquation.png and b/CSharpMath.Rendering.Tests/MathInline/LnEquation.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Logic.png b/CSharpMath.Rendering.Tests/MathInline/Logic.png index 04a01748..ffb58e31 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Logic.png and b/CSharpMath.Rendering.Tests/MathInline/Logic.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix.png b/CSharpMath.Rendering.Tests/MathInline/Matrix.png index a45ff0e6..84dca844 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix2.png b/CSharpMath.Rendering.Tests/MathInline/Matrix2.png index 4e5e54dd..cc5dcc50 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix2.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix2.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix3.png b/CSharpMath.Rendering.Tests/MathInline/Matrix3.png index 9611e87f..4f6226eb 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix3.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix3.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix4.png b/CSharpMath.Rendering.Tests/MathInline/Matrix4.png index 9bcf8ac0..478db0b1 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix4.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix4.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix5.png b/CSharpMath.Rendering.Tests/MathInline/Matrix5.png index 64402f4e..75ef99c5 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix5.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix5.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix6.png b/CSharpMath.Rendering.Tests/MathInline/Matrix6.png index 84a1b348..5dcc95e4 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix6.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix6.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrix7.png b/CSharpMath.Rendering.Tests/MathInline/Matrix7.png index 0f83faca..2936bdb2 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrix7.png and b/CSharpMath.Rendering.Tests/MathInline/Matrix7.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Matrixception.png b/CSharpMath.Rendering.Tests/MathInline/Matrixception.png index a22286d9..dd029996 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Matrixception.png and b/CSharpMath.Rendering.Tests/MathInline/Matrixception.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/QuadraticFormula.png b/CSharpMath.Rendering.Tests/MathInline/QuadraticFormula.png index 483518bf..9f306c9d 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/QuadraticFormula.png and b/CSharpMath.Rendering.Tests/MathInline/QuadraticFormula.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/SolveEquations.png b/CSharpMath.Rendering.Tests/MathInline/SolveEquations.png index 9f070b51..b73d65bb 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/SolveEquations.png and b/CSharpMath.Rendering.Tests/MathInline/SolveEquations.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/SummationWithCup.png b/CSharpMath.Rendering.Tests/MathInline/SummationWithCup.png index 011037fa..a7d3ac07 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/SummationWithCup.png and b/CSharpMath.Rendering.Tests/MathInline/SummationWithCup.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/TangentPeriodShift.png b/CSharpMath.Rendering.Tests/MathInline/TangentPeriodShift.png index 65185fcf..98ecc966 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/TangentPeriodShift.png and b/CSharpMath.Rendering.Tests/MathInline/TangentPeriodShift.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/UnderbraceSubscriptIntegral.png b/CSharpMath.Rendering.Tests/MathInline/UnderbraceSubscriptIntegral.png index 848f9028..dcee0664 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/UnderbraceSubscriptIntegral.png and b/CSharpMath.Rendering.Tests/MathInline/UnderbraceSubscriptIntegral.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/VectorProjection.png b/CSharpMath.Rendering.Tests/MathInline/VectorProjection.png index 30939260..d3d92c65 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/VectorProjection.png and b/CSharpMath.Rendering.Tests/MathInline/VectorProjection.png differ diff --git a/CSharpMath.Rendering.Tests/TestMeasure.cs b/CSharpMath.Rendering.Tests/TestMeasure.cs index eecaf865..dfaf50ce 100644 --- a/CSharpMath.Rendering.Tests/TestMeasure.cs +++ b/CSharpMath.Rendering.Tests/TestMeasure.cs @@ -292,6 +292,142 @@ public void TrailingSuperscriptInkDoesNotChangeAdvance() { Assert.Equal(advance, display.Width); } + [Fact] + public void OrdinaryItalicCorrectionIsAppliedBeforeCloseAtom() { + static Display.Displays.TextLineDisplay Line(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + return Assert.IsType>(Assert.Single(root.Displays)); + } + + var line = Line(@"P)"); + var run = Assert.Single(line.Runs).Run; + Assert.Equal(2, run.GlyphInfos.Count); + Assert.NotEqual(0, run.GlyphInfos[0].KernAfterGlyph); + Assert.Equal(0, run.GlyphInfos[1].KernAfterGlyph); + } + + [Fact] + public void OrdinaryItalicCorrectionSurvivesStyledRunBoundary() { + static Display.Displays.TextLineDisplay Line(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + return Assert.IsType>(Assert.Single(root.Displays)); + } + + var run = Assert.Single(Line(@"\mathit{P}\mathrm{Q})").Runs).Run; + var withoutClose = Assert.Single(Line(@"\mathit{P}\mathrm{Q}").Runs).Run; + Assert.Equal(3, run.GlyphInfos.Count); + Assert.Equal(2, withoutClose.GlyphInfos.Count); + Assert.Equal(withoutClose.GlyphInfos[0].KernAfterGlyph, run.GlyphInfos[0].KernAfterGlyph); + Assert.Equal(0, run.GlyphInfos[2].KernAfterGlyph); + } + + [Fact] + public void UprightFusedRunSuppressesInteriorCorrection() { + var painter = new SkiaSharp.MathPainter { LaTeX = @"\mathrm{PQ}" }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var line = Assert.IsType>(Assert.Single(root.Displays)); + var glyphs = Assert.Single(line.Runs).Run.GlyphInfos; + Assert.Equal(2, glyphs.Count); + Assert.Equal(0, glyphs[0].KernAfterGlyph); + } + + [Fact] + public void OrdinaryItalicCorrectionAccumulatesWithBinarySpacing() { + static Display.Displays.TextLineDisplay Line(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + return Assert.IsType>(Assert.Single(root.Displays)); + } + + var close = Line(@"P)").Runs.Single().Run.GlyphInfos[0].KernAfterGlyph; + var binary = Line(@"P+Q").Runs.Single().Run.GlyphInfos[0].KernAfterGlyph; + var font = new Fonts(Array.Empty(), FrontEnd.PainterConstants.DefaultFontSize); + var binarySpacing = 4 * MathTable.Instance.MuUnit(font); + Assert.Equal(close + binarySpacing, binary, precision: 4); + } + + [Fact] + public void ScriptedFinalGlyphIsCorrectedOnlyByScriptLayout() { + var painter = new SkiaSharp.MathPainter { LaTeX = @"P^2" }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var line = Assert.IsType>( + root.Displays.Single(display => display is Display.Displays.TextLineDisplay)); + Assert.Equal(0, Assert.Single(line.Runs).Run.GlyphInfos[0].KernAfterGlyph); + Assert.Single(root.Displays.OfType>(), + display => display.LinePosition == Display.LinePosition.Superscript); + } + + [Fact] + public void LargeOperatorItalicCorrectionIsIsolatedFromFollowingRun() { + var painter = new SkiaSharp.MathPainter { LaTeX = @"\sum P" }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + Assert.Contains(root.Displays, display => display is Display.Displays.GlyphDisplay); + var line = Assert.Single(root.Displays.OfType>()); + Assert.Single(line.Runs); + Assert.Single(line.Runs[0].Run.GlyphInfos); + } + + [Theory] + [InlineData(@"P\sum")] + public void ItalicCorrectionSurvivesCompositeFollowingAtom(string latex) { + static float FirstKern(string source) { + var painter = new SkiaSharp.MathPainter { LaTeX = source }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var line = Assert.Single(root.Displays.OfType>()); + return Assert.Single(line.Runs).Run.GlyphInfos[0].KernAfterGlyph; + } + + Assert.Equal(FirstKern(@"P)"), FirstKern(latex), precision: 4); + } + + [Theory] + [InlineData(@"P\quad")] + [InlineData(@"P\displaystyle")] + public void NonDisplayingTerminalAtomDoesNotApplyItalicCorrection(string latex) { + static float Width(string source) { + var painter = new SkiaSharp.MathPainter { LaTeX = source }; + painter.Measure(); + return painter.Display!.Width; + } + + Assert.Equal(Width("P"), Width(latex), precision: 4); + } + + [Theory] + [InlineData(@"P\displaystyle )")] + public void NonDisplayingAtomPreservesPendingItalicCorrection(string latex) { + static float FirstKern(string source) { + var painter = new SkiaSharp.MathPainter { LaTeX = source }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var line = Assert.Single(root.Displays.OfType>()); + return Assert.Single(line.Runs).Run.GlyphInfos[0].KernAfterGlyph; + } + static float Gap(string source) { + var painter = new SkiaSharp.MathPainter { LaTeX = source }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var lines = root.Displays.OfType>().ToArray(); + Assert.Equal(2, lines.Length); + return lines[1].Position.X - (lines[0].Position.X + lines[0].Width); + } + + var correction = FirstKern(@"P)"); + if (latex.Contains(@"\quad")) + Assert.Equal(Gap(@"Q\quad Q") + correction, Gap(latex), precision: 4); + else + Assert.Equal(correction, Gap(latex), precision: 4); + } + [Theory] [InlineData(FrontEnd.TextAlignment.Center)] [InlineData(FrontEnd.TextAlignment.Right)] @@ -309,5 +445,140 @@ public void FiniteCanvasAlignmentUsesInkWidth(FrontEnd.TextAlignment alignment) Assert.Equal(expected, x, precision: 4); Assert.True(display.Width < display.InkWidth()); } + + static Display.Displays.TextLineDisplay FirstLine(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + static Display.Displays.TextLineDisplay? Find(Display.IDisplay display) { + if (display is Display.Displays.TextLineDisplay line) return line; + if (display is Display.Displays.ListDisplay list) + return list.Displays.Select(Find).FirstOrDefault(line => line != null); + if (display is Display.Displays.BoxDisplay box) + return Find(box.Child); + if (display is Display.Displays.InnerDisplay inner) + return Find(inner.Inner); + return null; + } + + return Find(painter.Display!)!; + } + + static float FirstKern(string latex) => + Assert.Single(FirstLine(latex).Runs).Run.GlyphInfos[0].KernAfterGlyph; + + [Theory] + [InlineData(@"P)")] + [InlineData(@"P2")] + [InlineData("PΑ")] + public void OrdinaryItalicCorrectionHandlesStraightUnicodeSuccessors(string latex) { + Assert.Equal(FirstKern(@"P)"), FirstKern(latex), precision: 4); + Assert.NotEqual(0, FirstKern(latex)); + } + + [Theory] + [InlineData(@"Ph")] + [InlineData("P\U0001D452")] // mathematical italic small e + [InlineData("P\U0001D468")] // mathematical bold italic capital A + public void OrdinaryItalicCorrectionDoesNotSeparateSlantedUnicodeSuccessors(string latex) { + Assert.Equal(0, FirstKern(latex)); + } + + [Fact] + public void FusedOrdinaryRunAppliesEveryInternalItalicCorrection() { + var glyphs = Assert.Single(FirstLine("P2Q3").Runs).Run.GlyphInfos; + + Assert.Equal(4, glyphs.Count); + Assert.Equal(FirstKern("P)"), glyphs[0].KernAfterGlyph, precision: 4); + Assert.Equal(0, glyphs[1].KernAfterGlyph); + Assert.Equal(FirstKern("Q)"), glyphs[2].KernAfterGlyph, precision: 4); + Assert.Equal(0, glyphs[3].KernAfterGlyph); + } + + [Fact] + public void FusedInternalItalicCorrectionSurvivesScriptOnStraightSuccessor() { + var glyphs = Assert.Single(FirstLine("P2^3").Runs).Run.GlyphInfos; + + Assert.Equal(2, glyphs.Count); + Assert.Equal(FirstKern("P)"), glyphs[0].KernAfterGlyph, precision: 4); + Assert.Equal(0, glyphs[1].KernAfterGlyph); + } + + [Fact] + public void OrdinaryItalicCorrectionIsAddedToExistingRule16Spacing() { + var close = FirstKern(@"P)"); + var binary = FirstKern(@"P+Q"); + var font = new Fonts(Array.Empty(), FrontEnd.PainterConstants.DefaultFontSize); + Assert.Equal(close + 4 * MathTable.Instance.MuUnit(font), binary, precision: 4); + } + + [Theory] + [InlineData(@"P^2)")] + [InlineData(@"P_2)")] + [InlineData(@"{P Q}^2)")] + [InlineData(@"\boxed{P}2")] + [InlineData(@"P\frac{2}{3}")] + [InlineData(@"P\begin{array}{c}2\\3\end{array}")] + public void ScriptAndCompositePredecessorsDoNotDuplicateItalicCorrection(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + Assert.Null(Record.Exception(() => painter.Measure())); + var line = FirstLine(latex); + Assert.All(line.Runs.SelectMany(run => run.Run.GlyphInfos), glyph => + Assert.InRange(glyph.KernAfterGlyph, 0, FirstKern(@"P)"))); + } + + [Theory] + [InlineData(@"\color{red}{P})")] + [InlineData(@"{P})")] + public void WrapperBoundariesRetainSingleTrailingItalicCorrection(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + Assert.Equal(MeasureWidth(@"P)"), painter.Display!.Width, precision: 4); + } + + [Fact] + public void ColorBoxBoundaryIncludesItsPaddingOnce() { + var painter = new SkiaSharp.MathPainter { LaTeX = @"\colorbox{red}{P})" }; + painter.Measure(); + Assert.Equal(MeasureWidth(@"P)"), painter.Display!.Width, precision: 4); + Assert.True(painter.Display.InkWidth() >= painter.Display.Width); + } + + [Theory] + [InlineData(@"\left.P\right)")] + [InlineData(@"\left(P\right)")] + public void InnerBoundariesIncludeTheRightDelimiterAfterTheNestedContent(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + Assert.True(painter.Display!.Width > MeasureWidth("P")); + Assert.True(painter.Display.InkWidth() >= painter.Display.Width); + } + + static float MeasureWidth(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + return painter.Measure().Width; + } + + [Fact] + public void ItalicCorrectionPreservesPrecedingAdvanceAndNestedMatrixPlacement() { + var simple = new SkiaSharp.MathPainter { LaTeX = @"QP)" }; + var matrix = new SkiaSharp.MathPainter { LaTeX = @"\begin{pmatrix}P)\\Q\end{pmatrix}" }; + simple.Measure(); + matrix.Measure(); + Assert.True(simple.Display!.Width > new SkiaSharp.MathPainter { LaTeX = @"P)" }.Measure().Width); + var matrixDisplay = matrix.Display!; + Assert.True(matrixDisplay.InkWidth() >= matrixDisplay.Width); + Assert.True(matrixDisplay.Width > 0); + } + + [Theory] + [InlineData(@"P")] + [InlineData(@"\left.P\right)")] + [InlineData(@"\begin{pmatrix}P&Q\\R&S\end{pmatrix}")] + public void TerminalInkExtentNeverShrinksTheAdvance(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + painter.Measure(); + Assert.NotNull(painter.Display); + Assert.True(painter.Display!.InkWidth() >= painter.Display.Width); + } } } diff --git a/CSharpMath.Rendering.Tests/TestRendering.cs b/CSharpMath.Rendering.Tests/TestRendering.cs index fb492534..a7084f37 100644 --- a/CSharpMath.Rendering.Tests/TestRendering.cs +++ b/CSharpMath.Rendering.Tests/TestRendering.cs @@ -71,6 +71,11 @@ protected abstract void DrawToStream(Painter Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Display }); + [Fact] + public void Issue32MatrixRightDelimiterDisplay() => + Run("Issue32MatrixRightDelimiter", + @"\left(\begin{pmatrix}P & Q \\ R & S\end{pmatrix}\right)", + new TMathPainter { LineStyle = Atom.LineStyle.Display }, folder: "MathDisplay"); [Theory, ClassData(typeof(TestRenderingMathData))] public void MathInline(string file, string latex) => Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Text }); diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index aaf4f9ed..2498f513 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -141,4 +141,4 @@ public sealed class TestRenderingMathData : TestRenderingSharedData inner, IGlyphDisplay inner, IGlyphDisplay? left, + IGlyphDisplay? right, Range range, float completedInnerAdvance) + : this(inner, left, right, range) => _completedInnerAdvance = completedInnerAdvance; ///A display representing the inner list that can be wrapped in delimiters. ///It's position is relative to the parent is not treated as a sub-display. public ListDisplay Inner { get; } @@ -22,7 +25,10 @@ public InnerDisplay(ListDisplay inner, IGlyphDisplay System.Math.Max(Left?.Ascent ?? 0, System.Math.Max(Right?.Ascent ?? 0, Inner.Ascent)); public float Descent => System.Math.Max(Left?.Descent ?? 0, System.Math.Max(Right?.Descent ?? 0, Inner.Descent)); - public float Width => (Left?.Width ?? 0) + Inner.Width + (Right?.Width ?? 0); + private readonly float? _completedInnerAdvance; + private float InnerAdvance => + _completedInnerAdvance ?? System.Math.Max(Inner.Width, Inner.InkWidth()); + public float Width => (Left?.Width ?? 0) + InnerAdvance + (Right?.Width ?? 0); public Range Range { get; } @@ -36,7 +42,7 @@ public PointF Position { Inner.Position = new PointF(value.X + l.Width, value.Y); } else Inner.Position = value; if (Right is { } r) - r.Position = new PointF(Inner.Position.X + Inner.Width, value.Y); + r.Position = new PointF(Inner.Position.X + InnerAdvance, value.Y); } } public bool HasScript { get; set; } @@ -58,4 +64,4 @@ public void SetTextColorRecursive(Color? textColor) { public override string ToString() => $@"\inner[{Left}][{Right}]{{{Inner}}}"; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/FrontEnd/IGlyphFinder.cs b/CSharpMath/Display/FrontEnd/IGlyphFinder.cs index 631ec31e..25aeb095 100644 --- a/CSharpMath/Display/FrontEnd/IGlyphFinder.cs +++ b/CSharpMath/Display/FrontEnd/IGlyphFinder.cs @@ -2,8 +2,10 @@ namespace CSharpMath.Display.FrontEnd { ///For changing a string into glyphs which will appear on the page. public interface IGlyphFinder where TFont : IFont { TGlyph FindGlyphForCharacterAtIndex(TFont font, int index, string str); + /// Returns exactly one glyph for each Unicode scalar value in . + /// Contextual shaping is performed by text rendering; math glyph lookup is scalar-aligned. System.Collections.Generic.IEnumerable FindGlyphs(TFont font, string str); TGlyph EmptyGlyph { get; } bool GlyphIsEmpty(TGlyph glyph); } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index 09bac458..b9aab69c 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; +using System.Runtime.CompilerServices; using CSharpMath.Atom; using CSharpMath.Atom.Atoms; using CSharpMath.Display.Displays; @@ -105,6 +106,11 @@ public class Typesetter where TFont : IFont { internal readonly AttributedString _currentLine; internal Range _currentLineIndexRange = Range.NotFound; internal readonly List _currentAtoms = new List(); + private MathAtom? _trailingCorrectionAtom; + private float _pendingTrailingCorrection; + private GlyphInfo? _correctedTrailingGlyph; + private readonly Dictionary> _ownedDisplays = + new(ReferenceComparer.Instance); internal const int _delimiterFactor = 901; internal const int _delimiterShortfallPoints = 5; private LineStyle _scriptStyle => _style switch { @@ -158,6 +164,8 @@ List _PreprocessMathList() { // style: the fused run is stamped with a single face (iosMath 76fd773). if (newAtom is Ordinary && prevAtom is Ordinary o && o.Superscript.IsEmpty() && o.Subscript.IsEmpty() && o.FontStyle == newAtom.FontStyle) { + // Internal slanted-to-straight boundaries (for example P2 or PΑ) + // are corrected after the fused run is glyph-mapped. prevAtom.Fuse(newAtom); // skip the current node as we fused it continue; @@ -175,6 +183,21 @@ List _PreprocessMathList() { private void CreateDisplayAtoms(List preprocessedAtoms) { MathAtom? prevAtom = null; foreach (var atom in preprocessedAtoms) { + if (prevAtom != null) { + if (atom is Style or Comment) + CaptureTrailingItalicCorrection(prevAtom); + else if (atom is Space) { + // Explicit math space is a real inter-display gap, not an + // implicit successor boundary. + _pendingTrailingCorrection = 0; + } else if (_pendingTrailingCorrection != 0) { + if (SuccessorIsStraight(atom)) + _currentPosition.X += _pendingTrailingCorrection; + _pendingTrailingCorrection = 0; + } else { + ApplyTrailingItalicCorrection(prevAtom, atom); + } + } switch (atom) { case Number _: case Variable _: @@ -203,6 +226,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { colorDisplay.Position = _currentPosition; _currentPosition.X += colorDisplay.Width; _displayAtoms.Add(colorDisplay); + RememberDisplay(atom, colorDisplay); break; case ColorBox colorBox: AddDisplayLine(false); @@ -212,6 +236,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { colorDisplay.Position = _currentPosition; _currentPosition.X += colorDisplay.Width; _displayAtoms.Add(colorDisplay); + RememberDisplay(atom, colorDisplay); break; case Group group: AddDisplayLine(false); @@ -224,6 +249,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { groupInnerDisplay.Position = _currentPosition; groupInnerDisplay.SetRangeOverride(atom.IndexRange); _displayAtoms.Add(groupInnerDisplay); + RememberDisplay(atom, groupInnerDisplay); _currentPosition.X += groupInnerDisplay.Width; if (atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) { // Scripts attach after the whole group. @@ -243,6 +269,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { Position = _currentPosition }; _displayAtoms.Add(boxDisplay); + RememberDisplay(atom, boxDisplay); _currentPosition.X += boxDisplay.Width; if (atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) { MakeScripts(atom, boxDisplay, atom.IndexRange.Location, 0); @@ -297,6 +324,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { innerDisplay.Position = _currentPosition; _currentPosition.X += innerDisplay.Width; _displayAtoms.Add(innerDisplay); + RememberDisplay(atom, innerDisplay); if (atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) { MakeScripts(atom, innerDisplay, atom.IndexRange.Location, 0); } @@ -420,16 +448,22 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { InterElementSpaces.Get(Typesetter.SpacingAtom(prevAtom), Typesetter.SpacingAtom(atom), _style, _styleFont, _mathTable); if (_currentLine.Length > 0) { if (interElementSpace > 0) { - _currentLine.Runs.Last().GlyphInfos.Last().KernAfterGlyph = interElementSpace; + // Rule 16 spacing and Rule 17 italic correction share the + // same inter-glyph kern slot; preserve both contributions. + _currentLine.Runs.Last().GlyphInfos.Last().KernAfterGlyph += interElementSpace; } } else { _currentPosition.X += interElementSpace; } } var nucleusText = atom.Nucleus; - var glyphs = _context.GlyphFinder.FindGlyphs(_font, nucleusText); var current = new AttributedGlyphRun( - nucleusText, glyphs, _font, atom is Placeholder, (atom as Placeholder)?.Color); + nucleusText, _context.GlyphFinder.FindGlyphs(_font, nucleusText), _font, + atom is Placeholder, (atom as Placeholder)?.Color); + if (atom.FusedAtoms is { Count: > 1 } + && current.GlyphInfos.Count != UnicodeScalarCount(nucleusText)) + throw new InvalidOperationException( + "Math glyph lookup must return one glyph per Unicode scalar for fused atoms."); _currentLine.AppendGlyphRun(current); if (_currentLineIndexRange.Location == Range.UndefinedInt) _currentLineIndexRange = atom.IndexRange; @@ -440,6 +474,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { _currentAtoms.AddRange(atom.FusedAtoms); else _currentAtoms.Add(atom); + ApplyFusedInternalItalicCorrections(atom, current); if (atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) { var line = AddDisplayLine(true); if (line is null) throw new InvalidCodePathException("evenIfLengthIsZero not respected"); @@ -600,6 +635,162 @@ private void AddInterElementSpace(MathAtom? prev, MathAtom current) => prev != null ? InterElementSpaces.Get(Typesetter.SpacingAtom(prev), Typesetter.SpacingAtom(current), _style, _styleFont, _mathTable) : _spaced ? InterElementSpaces.Get(new Open(""), Typesetter.SpacingAtom(current), _style, _styleFont, _mathTable) : 0; + + private void ApplyTrailingItalicCorrection(MathAtom atom, MathAtom successor) { + if (ReferenceEquals(_trailingCorrectionAtom, atom)) + return; + _trailingCorrectionAtom = atom; + if (!SuccessorIsStraight(successor)) + return; + if (FindOwnedLastGlyph(atom) is not { } last || ReferenceEquals(_correctedTrailingGlyph, last.Glyph)) + return; + var correction = ApplyCorrection(last); + // Composite atoms have already been emitted and advanced the outer + // cursor. Their nested glyph kern affects drawing only, so account for + // the same correction in the outer successor position exactly once. + if (!IsSimpleTrailingAtom(atom)) + _currentPosition.X += correction; + } + + private void CaptureTrailingItalicCorrection(MathAtom atom) { + if (ReferenceEquals(_trailingCorrectionAtom, atom)) + return; + _trailingCorrectionAtom = atom; + if (FindOwnedLastGlyph(atom) is { } last) + _pendingTrailingCorrection += _mathTable.GetItalicCorrection(last.Run.Font, last.Glyph.Glyph); + } + + private float ApplyCorrection((AttributedGlyphRun Run, GlyphInfo Glyph) last) { + if (ReferenceEquals(_correctedTrailingGlyph, last.Glyph)) return 0; + var correction = _mathTable.GetItalicCorrection(last.Run.Font, last.Glyph.Glyph); + if (correction != 0) last.Glyph.KernAfterGlyph += correction; + _correctedTrailingGlyph = last.Glyph; + return correction; + } + + private (AttributedGlyphRun Run, GlyphInfo Glyph)? FindOwnedLastGlyph(MathAtom wrapper) { + var atom = ResolveTrailingAtom(wrapper); + if (atom is null + || wrapper.Subscript.IsNonEmpty() || wrapper.Superscript.IsNonEmpty() + || atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) return null; + // Simple predecessors are emitted into the current text line. The + // preprocessor may have fused/replaced their atom, so line ownership is + // intentionally structural rather than reference-based. + if (IsSimpleTrailingAtom(wrapper) + && _currentLine.Length > 0 + && _currentLine.Runs.LastOrDefault()?.GlyphInfos.LastOrDefault() is { } current) + return (_currentLine.Runs.Last(), current); + // Composite ownership is scoped to the wrapper's own produced display. + // Nested preprocessing creates fresh atom instances; FindLastGlyph must + // therefore inspect display shape, not atom identity. + return _ownedDisplays.TryGetValue(wrapper, out var owned) ? FindLastGlyph(owned) : null; + } + + private void RememberDisplay(MathAtom atom, IDisplay display) => + _ownedDisplays[atom] = display; + + private sealed class ReferenceComparer : IEqualityComparer { + internal static readonly ReferenceComparer Instance = new(); + public bool Equals(MathAtom? x, MathAtom? y) => ReferenceEquals(x, y); + public int GetHashCode(MathAtom obj) => RuntimeHelpers.GetHashCode(obj); + } + + private static MathAtom? ResolveTrailingAtom(MathAtom atom) => atom switch { + Variable or Number or UnaryOperator or Ordinary => atom, + Colored colored => LastVisibleAtom(colored.InnerList), + ColorBox colorBox => LastVisibleAtom(colorBox.InnerList), + Group group => LastVisibleAtom(group.InnerList), + Box box => LastVisibleAtom(box.InnerList), + Inner inner when inner.LeftBoundary == Boundary.Empty && inner.RightBoundary == Boundary.Empty + => LastVisibleAtom(inner.InnerList), + _ => null + }; + + private static MathAtom? LastVisibleAtom(MathList list) { + for (var i = list.Atoms.Count - 1; i >= 0; i--) + if (list.Atoms[i] is not (Comment or Space or Style)) + return ResolveTrailingAtom(list.Atoms[i]); + return null; + } + + private static bool IsSimpleTrailingAtom(MathAtom atom) => + atom is Variable or Number or UnaryOperator or Ordinary; + + private static (AttributedGlyphRun Run, GlyphInfo Glyph)? FindLastGlyph( + IDisplay display) { + switch (display) { + case TextLineDisplay line: + for (var i = line.Runs.Count - 1; i >= 0; i--) + if (line.Runs[i].Run.GlyphInfos.LastOrDefault() is { } glyph) + return (line.Runs[i].Run, glyph); + break; + case ListDisplay list when list.LinePosition == LinePosition.Regular: + for (var i = list.Displays.Count - 1; i >= 0; i--) + if (FindLastGlyph(list.Displays[i]) is { } found) return found; + break; + case BoxDisplay box when box.DrawChild: + return FindLastGlyph(box.Child); + case InnerDisplay inner: + return FindLastGlyph(inner.Inner); + } + return null; + } + + private static bool SuccessorIsStraight(MathAtom atom) { + if (atom is Ordinary) + return !IsSlantedOrdinary(LastFusedAtom(atom)); + if (atom is BinaryOperator or Relation or Punctuation or Open or Close or LargeDelimiter or LargeOperator) + return true; + if (atom is Colored colored) return FirstVisibleIsStraight(colored.InnerList); + if (atom is ColorBox colorBox) return FirstVisibleIsStraight(colorBox.InnerList); + if (atom is Group group) return FirstVisibleIsStraight(group.InnerList); + if (atom is Inner inner) + return inner.LeftBoundary.Nucleus?.Length > 0 || FirstVisibleIsStraight(inner.InnerList); + return false; + } + + private static bool IsSlantedOrdinary(MathAtom atom) { + if (atom.FontStyle is FontStyle.Italic or FontStyle.BoldItalic or FontStyle.Caligraphic) + return true; + if (atom.FontStyle != FontStyle.Default || string.IsNullOrEmpty(atom.Nucleus)) return false; + var codePoint = char.ConvertToUtf32(atom.Nucleus, 0); + return codePoint == 0x210E + || codePoint is >= 0x1D434 and <= 0x1D467 + or >= 0x1D44E and <= 0x1D481 + or >= 0x1D6E2 and <= 0x1D715 + or >= 0x1D716 and <= 0x1D71B + or >= 0x1D468 and <= 0x1D49B + or >= 0x1D71C and <= 0x1D755; + } + + private static MathAtom LastFusedAtom(MathAtom atom) => + atom.FusedAtoms is { Count: > 0 } fused ? fused[fused.Count - 1] : atom; + + private void ApplyFusedInternalItalicCorrections( + MathAtom atom, AttributedGlyphRun run) { + if (atom.FusedAtoms is not { Count: > 1 } fused) return; + var glyphIndex = 0; + for (var i = 0; i < fused.Count - 1; i++) { + glyphIndex += UnicodeScalarCount(fused[i].Nucleus); + if (!IsSlantedOrdinary(fused[i]) || IsSlantedOrdinary(fused[i + 1])) continue; + if (glyphIndex > 0 && glyphIndex <= run.GlyphInfos.Count) { + var glyph = run.GlyphInfos[glyphIndex - 1]; + glyph.KernAfterGlyph += _mathTable.GetItalicCorrection(run.Font, glyph.Glyph); + } + } + } + + private static int UnicodeScalarCount(string text) { + var count = 0; + for (var i = 0; i < text.Length; i++, count++) + if (char.IsHighSurrogate(text[i]) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) i++; + return count; + } + + private static bool FirstVisibleIsStraight(MathList list) => + list.Atoms.FirstOrDefault(atom => atom is not (Comment or Space or Style)) is { } atom + && SuccessorIsStraight(atom); + internal TextLineDisplay? AddDisplayLine(bool evenIfLengthIsZero) { if (evenIfLengthIsZero || (_currentLine != null && _currentLine.Length > 0)) { _currentLine.SetFont(_styleFont); @@ -953,7 +1144,11 @@ private InnerDisplay MakeInner(Inner inner, Range range) { inner.RightBoundary is Boundary { Nucleus: var right } && right?.Length > 0 ? FindGlyphForBoundary(right, glyphHeight) : null; - return new InnerDisplay(innerListDisplay, leftGlyph, rightGlyph, range); + // This nested layout is complete. Cache its local ink-aware advance so + // later width/position queries do not repeatedly traverse the tree. + var innerAdvance = Math.Max(innerListDisplay.Width, innerListDisplay.InkWidth()); + return new InnerDisplay( + innerListDisplay, leftGlyph, rightGlyph, range, innerAdvance); } private IGlyphDisplay FindGlyphForBoundary(