Browse Source

Fix overhang calculation (#19013)

* - Adjusts the overhang calculation so it matches what WPF produces
- Fixes baseline alignment for inline controls

* Adjust OverhangTrailing
Add unit test

* Only run overhang unit test on Windows
release/11.3.5
Benedikt Stebner 6 months ago
parent
commit
9e6287d191
  1. 2
      src/Avalonia.Base/Media/GlyphRun.cs
  2. 5
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  3. 9
      src/Avalonia.Base/Media/TextFormatting/TextLine.cs
  4. 114
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  5. 5
      src/Avalonia.Controls/Documents/InlineUIContainer.cs
  6. 16
      src/Avalonia.Controls/TextBlock.cs
  7. 4
      src/Skia/Avalonia.Skia/GlyphRunImpl.cs
  8. 5
      src/Windows/Avalonia.Direct2D1/Media/GlyphRunImpl.cs
  9. 32
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  10. 14
      tests/Avalonia.Skia.UnitTests/Win32Theory.cs

2
src/Avalonia.Base/Media/GlyphRun.cs

@ -691,7 +691,7 @@ namespace Avalonia.Media
return new GlyphRunMetrics
{
Baseline = (-GlyphTypeface.Metrics.Ascent + GlyphTypeface.Metrics.LineGap) * Scale,
Baseline = -GlyphTypeface.Metrics.Ascent * Scale,
Width = width,
WidthIncludingTrailingWhitespace = widthIncludingTrailingWhitespace,
Height = height,

5
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@ -687,8 +687,9 @@ namespace Avalonia.Media.TextFormatting
// 4) TextWidth is the max of the text width among lines.
// We choose to update all related metrics at once (OverhangLeading, WidthIncludingTrailingWhitespace, OverhangTrailing)
// if the current line has a larger text width.
var previousTextWidth = _metrics.OverhangLeading + _metrics.WidthIncludingTrailingWhitespace + _metrics.OverhangTrailing;
var textWidth = currentLine.OverhangLeading + currentLine.WidthIncludingTrailingWhitespace + currentLine.OverhangTrailing;
var previousTextWidth = _metrics.WidthIncludingTrailingWhitespace;
var textWidth = currentLine.WidthIncludingTrailingWhitespace;
if (previousTextWidth < textWidth)
{
_metrics.WidthIncludingTrailingWhitespace = currentLine.WidthIncludingTrailingWhitespace;

9
src/Avalonia.Base/Media/TextFormatting/TextLine.cs

@ -88,6 +88,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns>
/// The overhang after distance.
/// </returns>
/// <remarks>
/// The value is positive if the bottommost drawn pixel goes below the line bottom, and is negative if it is within (on or above) the line.
/// </remarks>
public abstract double OverhangAfter { get; }
/// <summary>
@ -96,6 +99,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns>
/// The overhang leading distance.
/// </returns>
/// <remarks>
/// When the leading drawn pixel comes before the alignment point, the value is negative.
/// </remarks>
public abstract double OverhangLeading { get; }
/// <summary>
@ -104,6 +110,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns>
/// The overhang trailing distance.
/// </returns>
/// <remarks>
/// The <see cref="OverhangTrailing"/> value will be positive when the trailing drawn pixel comes before the trailing alignment point.
/// </remarks>
public abstract double OverhangTrailing { get; }
/// <summary>

114
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@ -101,7 +101,7 @@ namespace Avalonia.Media.TextFormatting
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point lineOrigin)
{
var (currentX, currentY) = lineOrigin + new Point(Start, 0);
var (currentX, currentY) = lineOrigin + new Point(Start, 0);
foreach (var textRun in _textRuns)
{
@ -109,7 +109,7 @@ namespace Avalonia.Media.TextFormatting
{
case DrawableTextRun drawableTextRun:
{
var offsetY = GetBaselineOffset(drawableTextRun);
var offsetY = GetBaselineOffset(this, drawableTextRun);
drawableTextRun.Draw(drawingContext, new Point(currentX, currentY + offsetY));
@ -121,7 +121,7 @@ namespace Avalonia.Media.TextFormatting
}
}
private double GetBaselineOffset(DrawableTextRun textRun)
public static double GetBaselineOffset(TextLine textLine, DrawableTextRun textRun)
{
var baseline = textRun.Baseline;
var baselineAlignment = textRun.Properties?.BaselineAlignment;
@ -131,19 +131,19 @@ namespace Avalonia.Media.TextFormatting
switch (baselineAlignment)
{
case BaselineAlignment.Baseline:
baselineOffset += Baseline;
baselineOffset += textLine.Baseline;
break;
case BaselineAlignment.Top:
case BaselineAlignment.TextTop:
baselineOffset += Height - Extent + textRun.Size.Height / 2;
baselineOffset += textLine.Height - textLine.Extent + textRun.Size.Height / 2;
break;
case BaselineAlignment.Center:
baselineOffset += Height / 2 + baseline - textRun.Size.Height / 2;
baselineOffset += textLine.Height / 2 + baseline - textRun.Size.Height / 2;
break;
case BaselineAlignment.Subscript:
case BaselineAlignment.Bottom:
case BaselineAlignment.TextBottom:
baselineOffset += Height - textRun.Size.Height + baseline;
baselineOffset += textLine.Height - textRun.Size.Height + baseline;
break;
case BaselineAlignment.Superscript:
baselineOffset += baseline;
@ -1349,44 +1349,64 @@ namespace Avalonia.Media.TextFormatting
var descent = fontMetrics.Descent * scale;
var lineGap = fontMetrics.LineGap * scale;
var height = descent - ascent + lineGap;
var lineHeight = _paragraphProperties.LineHeight;
var lineSpacing = _paragraphProperties.LineSpacing;
var bounds = new Rect();
for (var index = 0; index < _textRuns.Length; index++)
{
switch (_textRuns[index])
{
case ShapedTextRun textRun:
{
var textMetrics = textRun.TextMetrics;
var glyphRun = textRun.GlyphRun;
var runBounds = glyphRun.InkBounds.WithX(widthIncludingWhitespace + glyphRun.InkBounds.X);
{
var textMetrics = textRun.TextMetrics;
bounds = bounds.Union(runBounds);
if (ascent > textMetrics.Ascent)
{
ascent = textMetrics.Ascent;
}
if (ascent > textMetrics.Ascent)
{
ascent = textMetrics.Ascent;
}
if (descent < textMetrics.Descent)
{
descent = textMetrics.Descent;
}
if (descent < textMetrics.Descent)
{
descent = textMetrics.Descent;
}
if (lineGap < textMetrics.LineGap)
{
lineGap = textMetrics.LineGap;
}
if (lineGap < textMetrics.LineGap)
{
lineGap = textMetrics.LineGap;
break;
}
if (descent - ascent + lineGap > height)
case DrawableTextRun drawableTextRun:
{
height = descent - ascent + lineGap;
if (drawableTextRun.Size.Height > -ascent)
{
ascent = -drawableTextRun.Size.Height;
}
break;
}
}
}
var height = descent - ascent + lineGap;
var inkBounds = new Rect();
for (var index = 0; index < _textRuns.Length; index++)
{
switch (_textRuns[index])
{
case ShapedTextRun textRun:
{
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));
inkBounds = inkBounds.Union(runBounds);
widthIncludingWhitespace += textRun.Size.Width;
@ -1395,26 +1415,22 @@ namespace Avalonia.Media.TextFormatting
case DrawableTextRun drawableTextRun:
{
widthIncludingWhitespace += drawableTextRun.Size.Width;
if (drawableTextRun.Size.Height > height)
{
height = drawableTextRun.Size.Height;
}
//Align the bounds at the common baseline
var offsetY = -ascent - drawableTextRun.Baseline;
//Adjust current ascent so drawables and text align at the bottom edge of the line.
var offset = Math.Max(0, drawableTextRun.Baseline + ascent - descent);
ascent -= offset;
bounds = bounds.Union(new Rect(new Point(bounds.Right, 0), drawableTextRun.Size));
inkBounds = inkBounds.Union(new Rect(new Point(widthIncludingWhitespace, offsetY), drawableTextRun.Size));
widthIncludingWhitespace += drawableTextRun.Size.Width;
break;
}
}
}
height += lineSpacing;
var width = widthIncludingWhitespace;
var isRtl = _paragraphProperties.FlowDirection == FlowDirection.RightToLeft;
for (int i = 0; i < _textRuns.Length; i++)
@ -1442,16 +1458,18 @@ namespace Avalonia.Media.TextFormatting
}
}
var extent = inkBounds.Height;
//The width of overhanging pixels at the bottom
var overhangAfter = Math.Max(0, bounds.Bottom - height);
//The width of overhanging pixels at the origin
var overhangLeading = Math.Abs(Math.Min(bounds.Left, 0));
//The width of overhanging pixels at the end
var overhangTrailing = Math.Max(0, bounds.Right - widthIncludingWhitespace);
var overhangAfter = inkBounds.Bottom - height;
//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;
@ -1461,15 +1479,15 @@ namespace Avalonia.Media.TextFormatting
var start = GetParagraphOffsetX(width, widthIncludingWhitespace);
_inkBounds = new Rect(bounds.Position + new Point(start, 0), bounds.Size);
_inkBounds = inkBounds.Translate(new Vector(start, 0));
_bounds = new Rect(start, 0, widthIncludingWhitespace, height);
return new TextLineMetrics
{
HasOverflowed = hasOverflowed,
Height = height + lineSpacing,
Extent = bounds.Height,
Height = height,
Extent = extent,
NewlineLength = newLineLength,
Start = start,
TextBaseline = -ascent,

5
src/Avalonia.Controls/Documents/InlineUIContainer.cs

@ -18,11 +18,6 @@ namespace Avalonia.Controls.Documents
public static readonly StyledProperty<Control> ChildProperty =
AvaloniaProperty.Register<InlineUIContainer, Control>(nameof(Child));
static InlineUIContainer()
{
BaselineAlignmentProperty.OverrideDefaultValue<InlineUIContainer>(BaselineAlignment.Top);
}
/// <summary>
/// Initializes a new instance of InlineUIContainer element.
/// </summary>

16
src/Avalonia.Controls/TextBlock.cs

@ -748,8 +748,14 @@ namespace Avalonia.Controls
protected override Size ArrangeOverride(Size finalSize)
{
var scale = LayoutHelper.GetLayoutScale(this);
var padding = LayoutHelper.RoundLayoutThickness(Padding, scale);
var scale = 1.0;
var padding = Padding;
if (UseLayoutRounding)
{
scale = LayoutHelper.GetLayoutScale(this);
padding = LayoutHelper.RoundLayoutThickness(Padding, scale);
}
var availableSize = finalSize.Deflate(padding);
@ -783,9 +789,11 @@ namespace Avalonia.Controls
//Fixes: #17194
VisualChildren.Add(control);
var offsetY = TextLineImpl.GetBaselineOffset(textLine, drawable);
control.Arrange(
new Rect(new Point(currentX, currentY),
new Size(control.DesiredSize.Width, textLine.Height)));
new Rect((new Point(currentX, currentY + offsetY)),
control.DesiredSize));
}
currentX += drawable.Size.Width;

4
src/Skia/Avalonia.Skia/GlyphRunImpl.cs

@ -76,14 +76,14 @@ namespace Avalonia.Skia
var gBounds = glyphBounds[i];
var advance = glyphInfos[i].GlyphAdvance;
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, baselineOrigin.Y + gBounds.Top, gBounds.Width, gBounds.Height));
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
currentX += advance;
}
ArrayPool<SKRect>.Shared.Return(glyphBounds);
BaselineOrigin = baselineOrigin;
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, 0));
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
}
public IGlyphTypeface GlyphTypeface => _glyphTypefaceImpl;

5
src/Windows/Avalonia.Direct2D1/Media/GlyphRunImpl.cs

@ -71,7 +71,6 @@ namespace Avalonia.Direct2D1.Media
var height = metrics.Height;
if (height < 0)
{
ybearing += height;
height = -height;
}
@ -79,7 +78,9 @@ namespace Avalonia.Direct2D1.Media
var xOffset = metrics.XBearing * scale;
var xWidth = xOffset > 0 ? xOffset : 0;
var xBearing = xOffset < 0 ? xOffset : 0;
runBounds = runBounds.Union(new Rect(currentX + xBearing, baselineOrigin.Y + ybearing, xWidth + metrics.Width * scale, height * scale));
//yBearing is the vertical distance from the baseline to the top of the glyph's bbox. It is usually positive for horizontal layouts, and negative for vertical ones.
runBounds = runBounds.Union(new Rect(currentX + xBearing, baselineOrigin.Y - ybearing * scale, xWidth + metrics.Width * scale, height * scale));
}
currentX += glyphInfos[i].GlyphAdvance;

32
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -9,6 +9,7 @@ using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using Xunit;
using static System.Net.Mime.MediaTypeNames;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
@ -1802,6 +1803,37 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[InlineData("y", -8, -1.304, -5.44)]
[InlineData("f", -12, -11.824, -4.44)]
[InlineData("a", 1, -0.232, -20.44)]
[Win32Theory("Values depend on the Skia platform backend")]
public void Should_Produce_Overhang(string text, double leading, double trailing, double after)
{
const string symbolsFont = "resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Source Serif";
using (Start())
{
var typeface = new Typeface(FontFamily.Parse(symbolsFont));
var defaultProperties = new GenericTextRunProperties(typeface, 64);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left,
true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0));
Assert.NotNull(textLine);
Assert.Equal(leading, textLine.OverhangLeading, 2);
Assert.Equal(trailing, textLine.OverhangTrailing, 2);
Assert.Equal(after, textLine.OverhangAfter, 2);
}
}
private class FixedRunsTextSource : ITextSource
{
private readonly IReadOnlyList<TextRun> _textRuns;

14
tests/Avalonia.Skia.UnitTests/Win32Theory.cs

@ -0,0 +1,14 @@
using System.Runtime.InteropServices;
using Xunit;
namespace Avalonia.Skia.UnitTests
{
internal class Win32Theory: TheoryAttribute
{
public Win32Theory(string message)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Skip = message;
}
}
}
Loading…
Cancel
Save