Browse Source

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
pull/19934/head
Benedikt Stebner 4 months ago
committed by GitHub
parent
commit
f5fe25b2c6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      src/Avalonia.Base/Media/GlyphRun.cs
  2. 2
      src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
  3. 98
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  4. 7
      src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs
  5. BIN
      tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf
  6. 51
      tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs
  7. 90
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

6
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,

2
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;

98
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
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>

BIN
tests/Avalonia.Skia.UnitTests/Fonts/Inter-Regular.LineGap800.ttf

Binary file not shown.

51
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
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;

Loading…
Cancel
Save