diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index db80590082..489dcb7a40 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/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, diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs index 3eb80cec6f..637109e7ef 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs +++ b/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; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 24dbb65492..9882d9eb9c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -580,7 +580,7 @@ namespace Avalonia.Media.TextFormatting /// 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 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, diff --git a/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs b/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs index c83ae89320..db59f92661 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs +++ b/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 @@ /// public double FontRenderingEmSize { get; } + /// + /// Gets the distance from the top to the baseline of the line of text. + /// + public double Baseline { get; } + /// /// Gets the recommended distance above the baseline. /// diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf new file mode 100644 index 0000000000..2174191d6e Binary files /dev/null and b/tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf differ diff --git a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs index 278bf698de..ab469d7d1b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs +++ b/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 BuildRects(GlyphRun glyphRun) { var height = glyphRun.Bounds.Height; var currentX = glyphRun.IsLeftToRight ? 0d : glyphRun.Bounds.Width; - + var rects = new List(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(); } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index fd48e3112a..fc298c1201 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/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 _textRuns;