浏览代码

Distribute LineGap evenly to top and bottom (#19556)

* Distribute LineGap evenly to top and bottom

* Add comment

* Add tests

* Fix line height override and add tests for it

* Remove extra spaces
Benedikt Stebner 1 周之前
父节点
当前提交
f5fe25b2c6

+ 5 - 1
src/Avalonia.Base/Media/GlyphRun.cs

@@ -708,9 +708,13 @@ namespace Avalonia.Media
                 }
             }
 
+            var ascent = GlyphTypeface.Metrics.Ascent * Scale;
+            var lineGap = GlyphTypeface.Metrics.LineGap * Scale;
+            var baseline = -ascent + lineGap * 0.5;
+
             return new GlyphRunMetrics
             {
-                Baseline = -GlyphTypeface.Metrics.Ascent * Scale,
+                Baseline = baseline,
                 Width = width,
                 WidthIncludingTrailingWhitespace = widthIncludingTrailingWhitespace,
                 Height = height,

+ 1 - 1
src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs

@@ -36,7 +36,7 @@ namespace Avalonia.Media.TextFormatting
 
         public TextMetrics TextMetrics { get; }
 
-        public override double Baseline => -TextMetrics.Ascent;
+        public override double Baseline => -TextMetrics.Ascent + TextMetrics.LineGap * 0.5;
 
         public override Size Size => GlyphRun.Bounds.Size;
 

+ 54 - 44
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@@ -580,7 +580,7 @@ namespace Avalonia.Media.TextFormatting
         /// </returns>
         private int GetLastDirectionalRunIndex(int indexedRunIndex, FlowDirection flowDirection, ref double directionalWidth)
         {
-            if(_indexedTextRuns is null)
+            if (_indexedTextRuns is null)
             {
                 return -1;
             }
@@ -624,7 +624,7 @@ namespace Avalonia.Media.TextFormatting
 
         public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
         {
-            if(textLength == 0)
+            if (textLength == 0)
             {
                 throw new ArgumentOutOfRangeException(nameof(textLength), textLength, $"{nameof(textLength)} ('0') must be a non-zero value. ");
             }
@@ -643,7 +643,7 @@ namespace Avalonia.Media.TextFormatting
                 var indexedTextRun = _indexedTextRuns[0];
                 var currentDirection = GetRunDirection(indexedTextRun.TextRun, _resolvedFlowDirection);
 
-                return [new TextBounds(new Rect(0,0,0, Height), currentDirection, [])];
+                return [new TextBounds(new Rect(0, 0, 0, Height), currentDirection, [])];
             }
 
             //We can return early if the requested text range is after the line's text range.
@@ -667,7 +667,7 @@ namespace Avalonia.Media.TextFormatting
                 {
                     break;
                 }
-   
+
                 var currentTextRun = currentIndexedRun.TextRun;
 
                 if (currentTextRun == null)
@@ -691,7 +691,7 @@ namespace Avalonia.Media.TextFormatting
                 {
                     directionalWidth = currentDrawable.Size.Width;
                 }
-    
+
                 var firstRunIndex = currentIndexedRun.RunIndex;
                 var lastRunIndex = GetLastDirectionalRunIndex(indexedRunIndex, currentDirection, ref directionalWidth);
 
@@ -709,8 +709,8 @@ namespace Avalonia.Media.TextFormatting
                         }
                     default:
                         {
-                             currentBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex,
-                                    currentPosition, remainingLength, out coveredLength, out currentPosition);
+                            currentBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex,
+                                   currentPosition, remainingLength, out coveredLength, out currentPosition);
 
                             break;
                         }
@@ -729,7 +729,7 @@ namespace Avalonia.Media.TextFormatting
 
                 lastBounds = currentBounds;
 
-                if(coveredLength <= 0)
+                if (coveredLength <= 0)
                 {
                     throw new InvalidOperationException("Covered length must be greater than zero.");
                 }
@@ -988,14 +988,14 @@ namespace Avalonia.Media.TextFormatting
                 {
                     var runBounds = GetRunBounds(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition);
 
-                    if(runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
+                    if (runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
                     {
                         textRunBounds.Add(runBounds);
                     }
 
                     currentPosition = runBounds.TextSourceCharacterIndex + runBounds.Length;
 
-                    if(i == firstRunIndex)
+                    if (i == firstRunIndex)
                     {
                         startX = runBounds.Rectangle.Left;
                     }
@@ -1112,7 +1112,7 @@ namespace Avalonia.Media.TextFormatting
             var startHitIndex = startHit.FirstCharacterIndex;
 
             //If the requested text range starts at the trailing edge we need to move at the end of the hit
-            if(startHitIndex < startIndex)
+            if (startHitIndex < startIndex)
             {
                 startHitIndex += startHit.TrailingLength;
             }
@@ -1230,7 +1230,7 @@ namespace Avalonia.Media.TextFormatting
                         }
                     case not null:
                         {
-                            if(direction == LogicalDirection.Forward)
+                            if (direction == LogicalDirection.Forward)
                             {
                                 if (textPosition == codepointIndex)
                                 {
@@ -1316,8 +1316,6 @@ namespace Avalonia.Media.TextFormatting
                 }
             }
 
-            var height = descent - ascent + lineGap;
-
             var inkBounds = new Rect();
 
             for (var index = 0; index < _textRuns.Length; index++)
@@ -1325,31 +1323,53 @@ namespace Avalonia.Media.TextFormatting
                 switch (_textRuns[index])
                 {
                     case ShapedTextRun textRun:
-                    {
-                        var glyphRun = textRun.GlyphRun;
-                        //Align the ink bounds at the common baseline
-                        var offsetY = -ascent - textRun.Baseline;
+                        {
+                            var glyphRun = textRun.GlyphRun;
+                            //Align the ink bounds at the common baseline
+                            var offsetY = -ascent - textRun.Baseline;
 
-                        var runBounds = glyphRun.InkBounds.Translate(new Vector(widthIncludingWhitespace, offsetY));
+                            var runBounds = glyphRun.InkBounds.Translate(new Vector(widthIncludingWhitespace, offsetY));
 
-                        inkBounds = inkBounds.Union(runBounds);
+                            inkBounds = inkBounds.Union(runBounds);
 
-                        widthIncludingWhitespace += textRun.Size.Width;
+                            widthIncludingWhitespace += textRun.Size.Width;
 
-                        break;
-                    }
+                            break;
+                        }
 
                     case DrawableTextRun drawableTextRun:
-                    {
-                        //Align the bounds at the common baseline
-                        var offsetY = -ascent - drawableTextRun.Baseline;
+                        {
+                            //Align the bounds at the common baseline
+                            var offsetY = -ascent - drawableTextRun.Baseline;
 
-                        inkBounds = inkBounds.Union(new Rect(new Point(widthIncludingWhitespace, offsetY), drawableTextRun.Size));
+                            inkBounds = inkBounds.Union(new Rect(new Point(widthIncludingWhitespace, offsetY), drawableTextRun.Size));
 
-                        widthIncludingWhitespace += drawableTextRun.Size.Width;
-                        
-                        break;
-                    }
+                            widthIncludingWhitespace += drawableTextRun.Size.Width;
+
+                            break;
+                        }
+                }
+            }
+
+            var halfLineGap = lineGap * 0.5;
+            var naturalHeight = descent - ascent + lineGap;
+            var baseline = -ascent + halfLineGap;
+            var height = naturalHeight;
+
+            if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight))
+            {
+                if (lineHeight <= naturalHeight)
+                {
+                    //Clamp to the specified line height
+                    height = lineHeight;
+                    baseline = -ascent;
+                }
+                else
+                {
+                    // Center the text vertically within the specified line height
+                    height = lineHeight;
+                    var extra = lineHeight - (descent - ascent);
+                    baseline = -ascent + extra / 2;
                 }
             }
 
@@ -1385,24 +1405,14 @@ namespace Avalonia.Media.TextFormatting
             }
 
             var extent = inkBounds.Height;
-            //The width of overhanging pixels at the bottom
-            var overhangAfter = inkBounds.Bottom - height;
+            //The height of overhanging pixels at the bottom
+            var overhangAfter = inkBounds.Bottom - height + halfLineGap;
             //The width of overhanging pixels at the natural alignment point. Positive value means we are inside.
             var overhangLeading = inkBounds.Left;
             //The width of overhanging pixels at the end of the natural bounds. Positive value means we are inside.
             var overhangTrailing = widthIncludingWhitespace - inkBounds.Right;
             var hasOverflowed = width > _paragraphWidth;
 
-            if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight))
-            {
-                //Center the line
-                var offset = (height - lineHeight) / 2;
-
-                ascent += offset;
-
-                height = lineHeight;
-            }
-
             var start = GetParagraphOffsetX(width, widthIncludingWhitespace);
 
             _inkBounds = inkBounds.Translate(new Vector(start, 0));
@@ -1416,7 +1426,7 @@ namespace Avalonia.Media.TextFormatting
                 Extent = extent,
                 NewlineLength = newLineLength,
                 Start = start,
-                TextBaseline = -ascent,
+                TextBaseline = baseline,
                 TrailingWhitespaceLength = trailingWhitespaceLength,
                 Width = width,
                 WidthIncludingTrailingWhitespace = widthIncludingWhitespace,

+ 7 - 0
src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs

@@ -19,6 +19,8 @@
 
             LineGap = fontMetrics.LineGap * scale;
 
+            Baseline = -Ascent + LineGap * 0.5;
+
             LineHeight = Descent - Ascent + LineGap;
 
             UnderlineThickness = fontMetrics.UnderlineThickness * scale;
@@ -35,6 +37,11 @@
         /// </summary>
         public double FontRenderingEmSize { get; }
 
+        /// <summary>
+        /// Gets the distance from the top to the baseline of the line of text.
+        /// </summary>
+        public double Baseline { get; }
+
         /// <summary>
         /// Gets the recommended distance above the baseline.
         /// </summary>

二进制
tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf


+ 35 - 16
tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs

@@ -70,7 +70,7 @@ namespace Avalonia.Skia.UnitTests.Media
                 var rects = BuildRects(glyphRun);
 
                 rects.Reverse();
-                
+
                 if (glyphRun.IsLeftToRight)
                 {
                     foreach (var rect in rects)
@@ -95,7 +95,7 @@ namespace Avalonia.Skia.UnitTests.Media
                 }
             }
         }
-        
+
         [InlineData("ABC012345", 0)] //LeftToRight
         [InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן", 1)] //RightToLeft
         [Theory]
@@ -113,19 +113,19 @@ namespace Avalonia.Skia.UnitTests.Media
                 {
                     var characterHit =
                         glyphRun.GetCharacterHitFromDistance(glyphRun.Bounds.Width, out _);
-                    
+
                     Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
                 }
                 else
                 {
-                     var characterHit =
-                        glyphRun.GetCharacterHitFromDistance(0, out _);
-                    
+                    var characterHit =
+                       glyphRun.GetCharacterHitFromDistance(0, out _);
+
                     Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
                 }
-                
+
                 var rects = BuildRects(glyphRun);
-                
+
                 var lastCluster = -1;
                 var index = 0;
 
@@ -379,12 +379,31 @@ namespace Avalonia.Skia.UnitTests.Media
             }
         }
 
+        [Fact]
+        public void Should_Add_Half_LineGap_To_Baseline()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Inter");
+                var options = new TextShaperOptions(typeface.GlyphTypeface, 14);
+                var shapedBuffer = TextShaper.Current.ShapeText("F", options);
+
+                var textMetrics = new TextMetrics(shapedBuffer.GlyphTypeface, 14);
+
+                var glyphRun = CreateGlyphRun(shapedBuffer);
+
+                var expectedBaseline = -textMetrics.Ascent + textMetrics.LineGap / 2;
+
+                Assert.Equal(expectedBaseline, glyphRun.Metrics.Baseline);
+            }
+        }
+
         private static List<Rect> BuildRects(GlyphRun glyphRun)
         {
             var height = glyphRun.Bounds.Height;
 
             var currentX = glyphRun.IsLeftToRight ? 0d : glyphRun.Bounds.Width;
-            
+
             var rects = new List<Rect>(glyphRun.GlyphInfos!.Count);
 
             var lastCluster = -1;
@@ -392,7 +411,7 @@ namespace Avalonia.Skia.UnitTests.Media
             for (var index = 0; index < glyphRun.GlyphInfos.Count; index++)
             {
                 var currentCluster = glyphRun.GlyphInfos[index].GlyphCluster;
-                
+
                 var advance = glyphRun.GlyphInfos[index].GlyphAdvance;
 
                 if (lastCluster != currentCluster)
@@ -412,11 +431,11 @@ namespace Avalonia.Skia.UnitTests.Media
 
                     rects.Remove(rect);
 
-                    rect = glyphRun.IsLeftToRight ? 
-                        rect.WithWidth(rect.Width + advance) : 
+                    rect = glyphRun.IsLeftToRight ?
+                        rect.WithWidth(rect.Width + advance) :
                         new Rect(rect.X - advance, 0, rect.Width + advance, height);
-                    
-                   rects.Add(rect);
+
+                    rects.Add(rect);
                 }
 
                 if (glyphRun.IsLeftToRight)
@@ -436,14 +455,14 @@ namespace Avalonia.Skia.UnitTests.Media
 
         private static GlyphRun CreateGlyphRun(ShapedBuffer shapedBuffer)
         {
-            var glyphRun =  new GlyphRun(
+            var glyphRun = new GlyphRun(
                 shapedBuffer.GlyphTypeface,
                 shapedBuffer.FontRenderingEmSize,
                 shapedBuffer.Text,
                 shapedBuffer,
                 biDiLevel: shapedBuffer.BidiLevel);
 
-            if(shapedBuffer.BidiLevel == 1)
+            if (shapedBuffer.BidiLevel == 1)
             {
                 shapedBuffer.Reverse();
             }

+ 90 - 0
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@@ -2195,6 +2195,96 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
             }
         }
 
+        [Fact]
+        public void Should_Add_Half_LineGap_To_Baseline()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Inter");
+                var defaultProperties = new GenericTextRunProperties(typeface);
+
+                var textSource = new SingleBufferTextSource("F", defaultProperties);
+
+                var formatter = new TextFormatterImpl();
+
+                var textLine =
+                    formatter.FormatLine(textSource, 0, double.PositiveInfinity,
+                        new GenericTextParagraphProperties(defaultProperties));
+
+                Assert.NotNull(textLine);
+
+                var textMetrics = new TextMetrics(typeface.GlyphTypeface, 12);
+
+                var expectedBaseline = -textMetrics.Ascent + textMetrics.LineGap / 2;
+
+                Assert.Equal(expectedBaseline, textLine.Baseline);
+            }
+        }
+
+        [Fact]
+        public void Should_Clamp_Baseline_When_LineHeight_Is_Smaller_Than_Natural()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Inter");
+                var defaultProperties = new GenericTextRunProperties(typeface);
+
+                var textSource = new SingleBufferTextSource("F", defaultProperties);
+
+                var formatter = new TextFormatterImpl();
+
+                var textMetrics = new TextMetrics(typeface.GlyphTypeface, 12);
+                var natural = -textMetrics.Ascent + textMetrics.Descent + textMetrics.LineGap;
+
+                var smallerLineHeight = natural - 2;
+
+                // Force a smaller line height than ascent+descent+lineGap
+                var paragraphProps = new GenericTextParagraphProperties(defaultProperties, lineHeight: smallerLineHeight);
+
+                var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProps);
+
+                Assert.NotNull(textLine);
+
+                // In this case, baseline should equal -Ascent (lineGap ignored)
+                var expectedBaseline = -textMetrics.Ascent;
+
+                Assert.Equal(expectedBaseline, textLine.Baseline);
+                Assert.Equal(paragraphProps.LineHeight, textLine.Height);
+            }
+        }
+
+        [Fact]
+        public void Should_Distribute_Extra_Space_When_LineHeight_Is_Larger_Than_Natural()
+        {
+            using (Start())
+            {
+                var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Inter");
+                var defaultProperties = new GenericTextRunProperties(typeface);
+
+                var textSource = new SingleBufferTextSource("F", defaultProperties);
+
+                var formatter = new TextFormatterImpl();
+
+                var textMetrics = new TextMetrics(typeface.GlyphTypeface, 12);
+                var natural = -textMetrics.Ascent + textMetrics.Descent + textMetrics.LineGap;
+
+                var largerLineHeight = natural + 50;
+
+                var paragraphProps = new GenericTextParagraphProperties(defaultProperties, lineHeight: largerLineHeight);
+
+                var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProps);
+
+                Assert.NotNull(textLine);
+
+                // Extra space is distributed evenly above and below
+                var extra = largerLineHeight - (textMetrics.Descent - textMetrics.Ascent);
+                var expectedBaseline = -textMetrics.Ascent + extra / 2;
+
+                Assert.Equal(expectedBaseline, textLine.Baseline, 5);
+                Assert.Equal(largerLineHeight, textLine.Height, 5);
+            }
+        }
+
         private class FixedRunsTextSource : ITextSource
         {
             private readonly IReadOnlyList<TextRun> _textRuns;