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 1 year 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 return new GlyphRunMetrics
{ {
Baseline = (-GlyphTypeface.Metrics.Ascent + GlyphTypeface.Metrics.LineGap) * Scale, Baseline = -GlyphTypeface.Metrics.Ascent * Scale,
Width = width, Width = width,
WidthIncludingTrailingWhitespace = widthIncludingTrailingWhitespace, WidthIncludingTrailingWhitespace = widthIncludingTrailingWhitespace,
Height = height, 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. // 4) TextWidth is the max of the text width among lines.
// We choose to update all related metrics at once (OverhangLeading, WidthIncludingTrailingWhitespace, OverhangTrailing) // We choose to update all related metrics at once (OverhangLeading, WidthIncludingTrailingWhitespace, OverhangTrailing)
// if the current line has a larger text width. // if the current line has a larger text width.
var previousTextWidth = _metrics.OverhangLeading + _metrics.WidthIncludingTrailingWhitespace + _metrics.OverhangTrailing; var previousTextWidth = _metrics.WidthIncludingTrailingWhitespace;
var textWidth = currentLine.OverhangLeading + currentLine.WidthIncludingTrailingWhitespace + currentLine.OverhangTrailing; var textWidth = currentLine.WidthIncludingTrailingWhitespace;
if (previousTextWidth < textWidth) if (previousTextWidth < textWidth)
{ {
_metrics.WidthIncludingTrailingWhitespace = currentLine.WidthIncludingTrailingWhitespace; _metrics.WidthIncludingTrailingWhitespace = currentLine.WidthIncludingTrailingWhitespace;

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

@ -88,6 +88,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns> /// <returns>
/// The overhang after distance. /// The overhang after distance.
/// </returns> /// </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; } public abstract double OverhangAfter { get; }
/// <summary> /// <summary>
@ -96,6 +99,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns> /// <returns>
/// The overhang leading distance. /// The overhang leading distance.
/// </returns> /// </returns>
/// <remarks>
/// When the leading drawn pixel comes before the alignment point, the value is negative.
/// </remarks>
public abstract double OverhangLeading { get; } public abstract double OverhangLeading { get; }
/// <summary> /// <summary>
@ -104,6 +110,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns> /// <returns>
/// The overhang trailing distance. /// The overhang trailing distance.
/// </returns> /// </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; } public abstract double OverhangTrailing { get; }
/// <summary> /// <summary>

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

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

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

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

16
src/Avalonia.Controls/TextBlock.cs

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

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

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

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

@ -71,7 +71,6 @@ namespace Avalonia.Direct2D1.Media
var height = metrics.Height; var height = metrics.Height;
if (height < 0) if (height < 0)
{ {
ybearing += height;
height = -height; height = -height;
} }
@ -79,7 +78,9 @@ namespace Avalonia.Direct2D1.Media
var xOffset = metrics.XBearing * scale; var xOffset = metrics.XBearing * scale;
var xWidth = xOffset > 0 ? xOffset : 0; var xWidth = xOffset > 0 ? xOffset : 0;
var xBearing = 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; 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.Media.TextFormatting;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
using static System.Net.Mime.MediaTypeNames;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting 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 class FixedRunsTextSource : ITextSource
{ {
private readonly IReadOnlyList<TextRun> _textRuns; 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