using System; using System.Globalization; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.UnitTests; using Xunit; namespace Avalonia.Skia.UnitTests.Media.TextFormatting { public class TextShaperTests { [Fact] public void Should_Form_Clusters_For_BreakPairs() { using (Start()) { var text = "\n\r\n"; var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 12,0, CultureInfo.CurrentCulture); var shapedBuffer = TextShaper.Current.ShapeText(text, options); Assert.Equal(shapedBuffer.Length, text.Length); Assert.Equal(shapedBuffer.Length, text.Length); Assert.Equal(0, shapedBuffer[0].GlyphCluster); Assert.Equal(1, shapedBuffer[1].GlyphCluster); Assert.Equal(1, shapedBuffer[2].GlyphCluster); } } [Fact] public void Should_Apply_IncrementalTabWidth() { using (Start()) { var text = "012345\t"; var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 12, 0, CultureInfo.CurrentCulture, 100); var shapedBuffer = TextShaper.Current.ShapeText(text.AsMemory().Slice(6), options); Assert.Equal(1, shapedBuffer.Length); Assert.Equal(100, shapedBuffer[0].GlyphAdvance); } } [Fact] public void Should_Not_Split_Cluster() { using (Start()) { var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code")); var buffer = TextShaper.Current.ShapeText("a\"๊a", new TextShaperOptions(typeface.GlyphTypeface)); var splitResult = buffer.Split(1); Assert.NotNull(splitResult.First); Assert.Equal(1, splitResult.First.Length); buffer = splitResult.Second; Assert.NotNull(buffer); //\"๊ splitResult = buffer.Split(1); Assert.NotNull(splitResult.First); Assert.Equal(2, splitResult.First.Length); buffer = splitResult.Second; Assert.NotNull(buffer); } } [Fact] public void Should_Not_Split_RightToLeft_Cluster() { // Arabic letters carry their harakat in the same cluster, so the first cluster of this // text spans two characters. Splitting inside it keeps the cluster's glyphs together in // the leading half - the text boundary has to follow them, or the two halves disagree // about which characters their glyphs cover. const string text = "أَبْجَدِيَّة"; using (Start()) { var codepoint = Codepoint.ReadAt(text, 0, out _); Assert.True(FontManager.Current.TryMatchCharacter(codepoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface)); var options = new TextShaperOptions(typeface.GlyphTypeface, 12, 1, CultureInfo.InvariantCulture); var buffer = TextShaper.Current.ShapeText(text.AsMemory(), options); // Precondition: the first cluster covers the first two characters. Assert.False(buffer.IsLeftToRight); Assert.Equal(0, buffer[buffer.Length - 1].GlyphCluster); Assert.Equal(0, buffer[buffer.Length - 2].GlyphCluster); Assert.Equal(2, buffer[buffer.Length - 3].GlyphCluster); var splitResult = buffer.Split(1); var first = splitResult.First; var second = splitResult.Second; Assert.NotNull(first); Assert.NotNull(second); // The split snaps forward past the cluster, exactly like the left-to-right path. Assert.Equal(2, first!.Text.Length); Assert.Equal(2, first.Length); // No character and no glyph is lost or duplicated. Assert.Equal(text.Length, first.Text.Length + second!.Text.Length); Assert.Equal(buffer.Length, first.Length + second.Length); // Every glyph of the trailing half belongs to the characters the trailing half owns. for (var i = 0; i < second.Length; i++) { Assert.True(second[i].GlyphCluster >= first.Text.Length, $"Glyph {i} has cluster {second[i].GlyphCluster}, which the leading half owns."); } } } [Fact] public void Should_Split_RightToLeft() { var text = "أَبْجَدِيَّة عَرَبِيَّة"; using (Start()) { var codePoint = Codepoint.ReadAt(text, 0, out _); Assert.True(FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface)); var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(typeface.GlyphTypeface)); var splitResult = buffer.Split(6); var first = splitResult.First; Assert.NotNull(first); Assert.Equal(6, first.Length); } } [Fact] public void Should_Split_Zero_Length() { var text = "ABC"; using (Start()) { var buffer = TextShaper.Current.ShapeText(text, new TextShaperOptions(Typeface.Default.GlyphTypeface)); var splitResult = buffer.Split(0); Assert.NotNull(splitResult.First); Assert.Equal(0, splitResult.First.Length); Assert.NotNull(splitResult.Second); Assert.Equal(text.Length, splitResult.Second.Length); } } [Fact] public void ClusterCache_SimpleMode_For_Latin_Text() { using (Start()) { var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); Assert.True(buffer.IsClusterCacheSimple, "Single-codepoint LTR text should use the simple cluster-cache mode."); } } [Fact] public void ClusterCache_SimpleMode_Measures_Correctly() { using (Start()) { var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); Assert.True(buffer.IsClusterCacheSimple); // Sum advances linearly and compare to TotalGlyphAdvance. var expectedTotal = 0d; for (var i = 0; i < buffer.Length; i++) { expectedTotal += buffer[i].GlyphAdvance; } Assert.Equal(expectedTotal, buffer.TotalGlyphAdvance, 5); // Measure: ask for the width of the first 3 glyphs. var threeGlyphsWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance; var fit = buffer.FindLeadingCharCountWithinWidth(threeGlyphsWidth); var widthConsumed = buffer.GetCharRangeWidth(0, fit); Assert.Equal(3, fit); Assert.Equal(threeGlyphsWidth, widthConsumed, 5); // FirstClusterCharLength must be 1 in simple mode. Assert.Equal(1, buffer.FirstClusterCharLength); } } [Fact] public void ClusterCache_SimpleMode_Survives_Split() { using (Start()) { var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); Assert.True(buffer.IsClusterCacheSimple); var split = buffer.Split(3); Assert.NotNull(split.First); Assert.NotNull(split.Second); Assert.Equal(3, split.First!.Length); Assert.Equal(5, split.Second!.Length); Assert.True(split.First.IsClusterCacheSimple, "Split halves of a simple-mode buffer should also be simple-mode."); Assert.True(split.Second.IsClusterCacheSimple); var firstWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance; Assert.Equal(firstWidth, split.First.TotalGlyphAdvance, 5); } } [Fact] public void ClusterCache_NotSimpleMode_For_ComplexClusters() { using (Start()) { var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code")); // Same text the existing Should_Not_Split_Cluster test uses: contains a // two-codepoint cluster that breaks the one-char-per-cluster invariant. var buffer = TextShaper.Current.ShapeText("a\"๊a", new TextShaperOptions(typeface.GlyphTypeface)); Assert.False(buffer.IsClusterCacheSimple, "Multi-char clusters should fall back to the full cluster-start-chars table."); } } [Fact] public void ClusterCache_SimpleMode_TrimmingHelpers_Are_Correct() { using (Start()) { var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); Assert.True(buffer.IsClusterCacheSimple); var advances = new double[buffer.Length]; for (var i = 0; i < buffer.Length; i++) { advances[i] = buffer[i].GlyphAdvance; } double Sum(int start, int end) { var w = 0d; for (var i = start; i < end; i++) { w += advances[i]; } return w; } // GetCharRangeWidth: exact sub-range sums, including out-of-range clamping. // These must not throw in simple mode (the regression: _clusterStartChars is null). Assert.Equal(Sum(0, 3), buffer.GetCharRangeWidth(0, 3), 5); Assert.Equal(Sum(2, 5), buffer.GetCharRangeWidth(2, 5), 5); Assert.Equal(Sum(0, 8), buffer.GetCharRangeWidth(-2, 100), 5); // clamped to [0, 8] Assert.Equal(0d, buffer.GetCharRangeWidth(4, 4), 5); // FindLeadingCharCountWithinWidth: budget mid-way into the 4th glyph -> first 3 fit. var leadingBudget = Sum(0, 3) + advances[3] * 0.5; Assert.Equal(3, buffer.FindLeadingCharCountWithinWidth(leadingBudget)); // FindTrailingCharCountWithinWidth: budget mid-way into glyph index 4 -> last 3 fit. var trailingBudget = Sum(5, 8) + advances[4] * 0.5; var trailingCount = buffer.FindTrailingCharCountWithinWidth(trailingBudget, out var consumed); Assert.Equal(3, trailingCount); Assert.Equal(Sum(5, 8), consumed, 5); } } [Fact] public void ClusterCache_SimpleMode_TrimmingHelpers_Survive_Split() { using (Start()) { var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface)); Assert.True(buffer.IsClusterCacheSimple); var split = buffer.Split(3); var second = split.Second; Assert.NotNull(second); Assert.True(second!.IsClusterCacheSimple); Assert.Equal(5, second.Length); // "DEFGH" var advances = new double[second.Length]; for (var i = 0; i < second.Length; i++) { advances[i] = second[i].GlyphAdvance; } // Exercises the _clusterStartIdx offset on a simple-mode sub-buffer. var firstTwo = advances[0] + advances[1]; Assert.Equal(firstTwo, second.GetCharRangeWidth(0, 2), 5); var leadingBudget = firstTwo + advances[2] * 0.5; Assert.Equal(2, second.FindLeadingCharCountWithinWidth(leadingBudget)); } } private static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface .With(renderInterface: new PlatformRenderInterface(null), fontManagerImpl: new CustomFontManagerImpl())); return disposable; } } }