Browse Source

Add GlyphRun support

Benedikt Schroeder 5 years ago
parent
commit
ab5e062deb
29 changed files with 1389 additions and 7 deletions
  1. 2 2
      build/HarfBuzzSharp.props
  2. 2 2
      build/SkiaSharp.props
  3. 3 0
      samples/RenderDemo/MainWindow.xaml
  4. 14 0
      samples/RenderDemo/Pages/GlyphRunPage.xaml
  5. 80 0
      samples/RenderDemo/Pages/GlyphRunPage.xaml.cs
  6. 68 0
      src/Avalonia.Visuals/Media/CharacterHit.cs
  7. 16 0
      src/Avalonia.Visuals/Media/DrawingContext.cs
  8. 459 0
      src/Avalonia.Visuals/Media/GlyphRun.cs
  9. 50 0
      src/Avalonia.Visuals/Media/GlyphRunDrawing.cs
  10. 5 0
      src/Avalonia.Visuals/Media/GlyphTypeface.cs
  11. 8 0
      src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs
  12. 12 0
      src/Avalonia.Visuals/Platform/IGlyphRunImpl.cs
  13. 5 0
      src/Avalonia.Visuals/Platform/IGlyphTypefaceImpl.cs
  14. 8 0
      src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs
  15. 15 0
      src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs
  16. 91 0
      src/Avalonia.Visuals/Rendering/SceneGraph/GlyphRunNode.cs
  17. 154 0
      src/Avalonia.Visuals/Utility/ReadOnlySlice.cs
  18. 14 0
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  19. 35 0
      src/Skia/Avalonia.Skia/GlyphRunImpl.cs
  20. 5 0
      src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs
  21. 85 0
      src/Skia/Avalonia.Skia/PlatformRenderInterface.cs
  22. 50 0
      src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs
  23. 16 0
      src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs
  24. 19 0
      src/Windows/Avalonia.Direct2D1/Media/GlyphRunImpl.cs
  25. 7 2
      src/Windows/Avalonia.Direct2D1/Media/GlyphTypefaceImpl.cs
  26. 47 0
      tests/Avalonia.UnitTests/MockGlyphTypeface.cs
  27. 6 0
      tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs
  28. 112 0
      tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
  29. 1 1
      tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs

+ 2 - 2
build/HarfBuzzSharp.props

@@ -1,6 +1,6 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="HarfBuzzSharp" Version="2.6.1-rc.153" />
-    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.6.1-rc.153" />
+    <PackageReference Include="HarfBuzzSharp" Version="2.6.1" />
+    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.6.1" />
   </ItemGroup>
 </Project>

+ 2 - 2
build/SkiaSharp.props

@@ -1,6 +1,6 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="SkiaSharp" Version="1.68.1-rc.153" />
-    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="1.68.1-rc.153" />
+    <PackageReference Include="SkiaSharp" Version="1.68.1" />
+    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="1.68.1" />
   </ItemGroup>
 </Project>

+ 3 - 0
samples/RenderDemo/MainWindow.xaml

@@ -41,6 +41,9 @@
       <TabItem Header="RenderTargetBitmap">
         <pages:RenderTargetBitmapPage/>
       </TabItem>
+      <TabItem Header="GlyphRun">
+        <pages:GlyphRunPage/>
+      </TabItem>
     </TabControl>
   </DockPanel>
 </Window>

+ 14 - 0
samples/RenderDemo/Pages/GlyphRunPage.xaml

@@ -0,0 +1,14 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="RenderDemo.Pages.GlyphRunPage">
+  <Border
+    Background="White">
+    <DrawingPresenter
+      x:Name="drawingPresenter"
+      Stretch="None">
+    </DrawingPresenter>
+  </Border>
+</UserControl>

+ 80 - 0
samples/RenderDemo/Pages/GlyphRunPage.xaml.cs

@@ -0,0 +1,80 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace RenderDemo.Pages
+{
+    public class GlyphRunPage : UserControl
+    {
+        private DrawingPresenter _drawingPresenter;
+        private GlyphTypeface _glyphTypeface = Typeface.Default.GlyphTypeface;
+        private readonly Random _rand = new Random();
+        private ushort[] _glyphIndices = new ushort[1];
+        private float _fontSize = 20;
+        private int _direction = 10;
+
+        public GlyphRunPage()
+        {
+            this.InitializeComponent();
+        }
+
+        private void InitializeComponent()
+        {
+            AvaloniaXamlLoader.Load(this);
+
+            _drawingPresenter = this.FindControl<DrawingPresenter>("drawingPresenter");
+
+            DispatcherTimer.Run(() =>
+            {
+                UpdateGlyphRun();
+
+                return true;
+            }, TimeSpan.FromSeconds(1));
+        }
+
+        private void UpdateGlyphRun()
+        {
+            var c = (uint)_rand.Next(65, 90);
+
+            if (_fontSize + _direction > 200)
+            {
+                _direction = -10;
+            }
+
+            if (_fontSize + _direction < 20)
+            {
+                _direction = 10;
+            }
+
+            _fontSize += _direction;
+
+            _glyphIndices[0] = _glyphTypeface.GetGlyph(c);
+
+            var scale = (double)_fontSize / _glyphTypeface.DesignEmHeight;
+
+            var drawingGroup = new DrawingGroup();
+
+            var glyphRunDrawing = new GlyphRunDrawing
+            {
+                Foreground = Brushes.Black,
+                GlyphRun = new GlyphRun(_glyphTypeface, _fontSize, _glyphIndices),
+                BaselineOrigin = new Point(0, -_glyphTypeface.Ascent * scale)
+            };
+
+            drawingGroup.Children.Add(glyphRunDrawing);
+
+            var geometryDrawing = new GeometryDrawing
+            {
+                Pen = new Pen(Brushes.Black),
+                Geometry = new RectangleGeometry { Rect = glyphRunDrawing.GlyphRun.Bounds }
+            };
+
+            drawingGroup.Children.Add(geometryDrawing);
+
+            _drawingPresenter.Drawing = drawingGroup;
+        }
+    }
+}

+ 68 - 0
src/Avalonia.Visuals/Media/CharacterHit.cs

@@ -0,0 +1,68 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+
+namespace Avalonia.Media
+{
+    /// <summary>
+    ///     Represents information about a character hit within a glyph run.
+    /// </summary>
+    /// <remarks>
+    ///     The CharacterHit structure provides information about the index of the first
+    ///     character that got hit as well as information about leading or trailing edge.
+    /// </remarks>
+    public readonly struct CharacterHit : IEquatable<CharacterHit>
+    {
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="CharacterHit"/> structure.
+        /// </summary>
+        /// <param name="firstCharacterIndex">Index of the first character that got hit.</param>
+        /// <param name="trailingLength">In the case of a leading edge, this value is 0. In the case of a trailing edge,
+        /// this value is the number of code points until the next valid caret position.</param>
+        public CharacterHit(int firstCharacterIndex, int trailingLength = 0)
+        {
+            FirstCharacterIndex = firstCharacterIndex;
+
+            TrailingLength = trailingLength;
+        }
+
+        /// <summary>
+        ///     Gets the index of the first character that got hit.
+        /// </summary>
+        public int FirstCharacterIndex { get; }
+
+        /// <summary>
+        ///     Gets the trailing length value for the character that got hit.
+        /// </summary>
+        public int TrailingLength { get; }
+
+        public bool Equals(CharacterHit other)
+        {
+            return FirstCharacterIndex == other.FirstCharacterIndex && TrailingLength == other.TrailingLength;
+        }
+
+        public override bool Equals(object obj)
+        {
+            return obj is CharacterHit other && Equals(other);
+        }
+
+        public override int GetHashCode()
+        {
+            unchecked
+            {
+                return FirstCharacterIndex * 397 ^ TrailingLength;
+            }
+        }
+
+        public static bool operator ==(CharacterHit left, CharacterHit right)
+        {
+            return left.Equals(right);
+        }
+
+        public static bool operator !=(CharacterHit left, CharacterHit right)
+        {
+            return !left.Equals(right);
+        }
+    }
+}

+ 16 - 0
src/Avalonia.Visuals/Media/DrawingContext.cs

@@ -187,6 +187,22 @@ namespace Avalonia.Media
             }
         }
 
+        /// <summary>
+        /// Draws a glyph run.
+        /// </summary>
+        /// <param name="foreground">The foreground brush.</param>
+        /// <param name="glyphRun">The glyph run.</param>
+        /// <param name="baselineOrigin">The baseline origin of the glyph run.</param>
+        public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin)
+        {
+            Contract.Requires<ArgumentNullException>(glyphRun != null);
+
+            if (foreground != null)
+            {
+                PlatformImpl.DrawGlyphRun(foreground, glyphRun, baselineOrigin);
+            }
+        }
+
         /// <summary>
         /// Draws a filled rectangle.
         /// </summary>

+ 459 - 0
src/Avalonia.Visuals/Media/GlyphRun.cs

@@ -0,0 +1,459 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using Avalonia.Platform;
+using Avalonia.Utility;
+
+namespace Avalonia.Media
+{
+    /// <summary>
+    ///     Represents a sequence of glyphs from a single face of a single font at a single size, and with a single rendering style.
+    /// </summary>
+    public sealed class GlyphRun : IDisposable
+    {
+        private static readonly IPlatformRenderInterface s_platformRenderInterface =
+            AvaloniaLocator.Current.GetService<IPlatformRenderInterface>();
+
+        private IGlyphRunImpl _glyphRunImpl;
+        private GlyphTypeface _glyphTypeface;
+        private double _fontRenderingEmSize;
+        private Rect? _bounds;
+
+        private ReadOnlySlice<ushort> _glyphIndices;
+        private ReadOnlySlice<double> _glyphAdvances;
+        private ReadOnlySlice<Vector> _glyphOffsets;
+        private ReadOnlySlice<ushort> _glyphClusters;
+        private ReadOnlySlice<char> _characters;
+
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="GlyphRun"/> class.
+        /// </summary>
+        public GlyphRun()
+        {
+
+        }
+
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="GlyphRun"/> class by specifying properties of the class.
+        /// </summary>
+        /// <param name="glyphTypeface">The glyph typeface.</param>
+        /// <param name="fontRenderingEmSize">The rendering em size.</param>
+        /// <param name="glyphIndices">The glyph indices.</param>
+        /// <param name="glyphAdvances">The glyph advances.</param>
+        /// <param name="glyphOffsets">The glyph offsets.</param>
+        /// <param name="characters">The characters.</param>
+        /// <param name="glyphClusters">The glyph clusters.</param>
+        /// <param name="bidiLevel">The bidi level.</param>
+        /// <param name="bounds">The bound.</param>
+        public GlyphRun(
+            GlyphTypeface glyphTypeface,
+            double fontRenderingEmSize,
+            ReadOnlySlice<ushort> glyphIndices,
+            ReadOnlySlice<double> glyphAdvances = default,
+            ReadOnlySlice<Vector> glyphOffsets = default,
+            ReadOnlySlice<char> characters = default,
+            ReadOnlySlice<ushort> glyphClusters = default,
+            int bidiLevel = 0,
+            Rect? bounds = null)
+        {
+            GlyphTypeface = glyphTypeface;
+
+            FontRenderingEmSize = fontRenderingEmSize;
+
+            GlyphIndices = glyphIndices;
+
+            GlyphAdvances = glyphAdvances;
+
+            GlyphOffsets = glyphOffsets;
+
+            Characters = characters;
+
+            GlyphClusters = glyphClusters;
+
+            BidiLevel = bidiLevel;
+
+            Initialize(bounds);
+        }
+
+        /// <summary>
+        ///     Gets or sets the <see cref="Media.GlyphTypeface"/> for the <see cref="GlyphRun"/>.
+        /// </summary>
+        public GlyphTypeface GlyphTypeface
+        {
+            get => _glyphTypeface;
+            set => Set(ref _glyphTypeface, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets the em size used for rendering the <see cref="GlyphRun"/>.
+        /// </summary>
+        public double FontRenderingEmSize
+        {
+            get => _fontRenderingEmSize;
+            set => Set(ref _fontRenderingEmSize, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets an array of <see cref="ushort"/> values that represent the glyph indices in the rendering physical font.
+        /// </summary>
+        public ReadOnlySlice<ushort> GlyphIndices
+        {
+            get => _glyphIndices;
+            set => Set(ref _glyphIndices, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets an array of <see cref="double"/> values that represent the advances corresponding to the glyph indices.
+        /// </summary>
+        public ReadOnlySlice<double> GlyphAdvances
+        {
+            get => _glyphAdvances;
+            set => Set(ref _glyphAdvances, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets an array of <see cref="Vector"/> values representing the offsets of the glyphs in the <see cref="GlyphRun"/>.
+        /// </summary>
+        public ReadOnlySlice<Vector> GlyphOffsets
+        {
+            get => _glyphOffsets;
+            set => Set(ref _glyphOffsets, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets the list of UTF16 code points that represent the Unicode content of the <see cref="GlyphRun"/>.
+        /// </summary>
+        public ReadOnlySlice<char> Characters
+        {
+            get => _characters;
+            set => Set(ref _characters, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets a list of <see cref="int"/> values representing a mapping from character index to glyph index.
+        /// </summary>
+        public ReadOnlySlice<ushort> GlyphClusters
+        {
+            get => _glyphClusters;
+            set => Set(ref _glyphClusters, value);
+        }
+
+        /// <summary>
+        ///     Gets or sets the bidirectional nesting level of the <see cref="GlyphRun"/>.
+        /// </summary>
+        public int BidiLevel
+        {
+            get;
+            set;
+        }
+
+        /// <summary>
+        /// 
+        /// </summary>
+        internal double Scale => FontRenderingEmSize / GlyphTypeface.DesignEmHeight;
+
+        /// <summary>
+        ///     
+        /// </summary>
+        internal bool IsLeftToRight => ((BidiLevel & 1) == 0);
+
+        /// <summary>
+        ///     Gets or sets the conservative bounding box of the <see cref="GlyphRun"/>.
+        /// </summary>
+        public Rect Bounds
+        {
+            get
+            {
+                if (_bounds == null)
+                {
+                    _bounds = CalculateBounds();
+                }
+
+                return _bounds.Value;
+            }
+            set => _bounds = value;
+        }
+
+        public IGlyphRunImpl GlyphRunImpl
+        {
+            get
+            {
+                if (_glyphRunImpl == null)
+                {
+                    Initialize(null);
+                }
+
+                return _glyphRunImpl;
+            }
+        }
+
+        public double GetDistanceFromCharacterHit(CharacterHit characterHit)
+        {
+            var distance = 0.0;
+
+            var end = _glyphClusters.AsSpan().BinarySearch((ushort)characterHit.FirstCharacterIndex);
+
+            if (end < 0)
+            {
+                return 0;
+            }
+
+            // If TrailingLength > 0 we have to use the next cluster while TrailingLength != 0
+            for (var i = 0; i < end + characterHit.TrailingLength; i++)
+            {
+                if (GlyphAdvances.IsEmpty)
+                {
+                    var glyph = GlyphIndices[i];
+
+                    distance += GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
+                }
+                else
+                {
+                    distance += GlyphAdvances[i];
+                }
+            }
+
+            return distance;
+        }
+
+        public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside)
+        {
+            // Before
+            if (distance < 0)
+            {
+                isInside = false;
+
+                var firstCharacterHit = FindNearestCharacterHit(_glyphClusters[0], out _);
+
+                return IsLeftToRight ? new CharacterHit(firstCharacterHit.FirstCharacterIndex) : firstCharacterHit;
+            }
+
+            //After
+            if (distance > Bounds.Size.Width)
+            {
+                isInside = false;
+
+                var lastCharacterHit = FindNearestCharacterHit(_glyphClusters[_glyphClusters.Length - 1], out _);
+
+                return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex);
+            }
+
+            //Within
+            var currentX = 0.0;
+            var index = 0;
+
+            for (; index < GlyphIndices.Length; index++)
+            {
+                if (GlyphAdvances.IsEmpty)
+                {
+                    var glyph = GlyphIndices[index];
+
+                    currentX += GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
+                }
+                else
+                {
+                    currentX += GlyphAdvances[index];
+                }
+
+                if (currentX > distance)
+                {
+                    break;
+                }
+            }
+
+            if (index == GlyphIndices.Length)
+            {
+                index--;
+            }
+
+            var characterHit = FindNearestCharacterHit(GlyphClusters[index], out var width);
+
+            isInside = distance < currentX && width > 0;
+
+            var isTrailing = distance > currentX - width / 2;
+
+            return isTrailing ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex);
+        }
+
+        public CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
+        {
+
+            if (characterHit.TrailingLength == 0)
+            {
+                return FindNearestCharacterHit(characterHit.FirstCharacterIndex, out _);
+            }
+
+            var nextCharacterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _);
+
+            return new CharacterHit(nextCharacterHit.FirstCharacterIndex);
+        }
+
+        public CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
+        {
+            return characterHit.TrailingLength == 0 ?
+                FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _) :
+                new CharacterHit(characterHit.FirstCharacterIndex);
+        }
+
+        private class ReverseComparer<T> : IComparer<T>
+        {
+            public int Compare(T x, T y)
+            {
+                return Comparer<T>.Default.Compare(y, x);
+            }
+        }
+
+        private static readonly IComparer<ushort> s_ascendingComparer = Comparer<ushort>.Default;
+        private static readonly IComparer<ushort> s_descendingComparer = new ReverseComparer<ushort>();
+
+        internal CharacterHit FindNearestCharacterHit(int index, out double width)
+        {
+            width = 0.0;
+
+            if (index < 0)
+            {
+                return default;
+            }
+
+            var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer;
+
+            var clusters = _glyphClusters.AsSpan();
+
+            int start;
+
+            if (index == 0 && clusters[0] == 0)
+            {
+                start = 0;
+            }
+            else
+            {
+                // Find the start of the cluster at the character index.
+                start = clusters.BinarySearch((ushort)index, comparer);
+            }
+
+            // No cluster found.
+            if (start < 0)
+            {
+                while (index > 0 && start < 0)
+                {
+                    index--;
+
+                    start = clusters.BinarySearch((ushort)index, comparer);
+                }
+
+                if (start < 0)
+                {
+                    return default;
+                }
+            }
+
+            var trailingLength = 0;
+
+            var currentCluster = clusters[start];
+
+            while (start > 0 && clusters[start - 1] == currentCluster)
+            {
+                start--;
+            }
+
+            for (var lastIndex = start; lastIndex < _glyphClusters.Length; ++lastIndex)
+            {
+                if (_glyphClusters[lastIndex] != currentCluster)
+                {
+                    break;
+                }
+
+                if (GlyphAdvances.IsEmpty)
+                {
+                    var glyph = GlyphIndices[lastIndex];
+
+                    width += GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
+                }
+                else
+                {
+                    width += GlyphAdvances[lastIndex];
+                }
+
+                trailingLength++;
+            }
+
+            return new CharacterHit(currentCluster, trailingLength);
+        }
+
+        private Rect CalculateBounds()
+        {
+            var scale = FontRenderingEmSize / GlyphTypeface.DesignEmHeight;
+
+            var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * scale;
+
+            var width = 0.0;
+
+            if (GlyphAdvances.IsEmpty)
+            {
+                foreach (var glyph in GlyphIndices)
+                {
+                    width += GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
+                }
+            }
+            else
+            {
+                foreach (var advance in GlyphAdvances)
+                {
+                    width += advance;
+                }
+            }
+
+            return new Rect(0, 0, width, height);
+        }
+
+        private void Set<T>(ref T field, T value)
+        {
+            if (_glyphRunImpl != null)
+            {
+                throw new InvalidOperationException("GlyphRun can't be changed after is has been initialized.'");
+            }
+
+            field = value;
+        }
+
+        private void Initialize(Rect? bounds)
+        {
+            if (GlyphIndices.Length == 0)
+            {
+                throw new InvalidOperationException();
+            }
+
+            var glyphCount = GlyphIndices.Length;
+
+            if (GlyphAdvances.Length > 0 && GlyphAdvances.Length != glyphCount)
+            {
+                throw new InvalidOperationException();
+            }
+
+            if (GlyphOffsets.Length > 0 && GlyphOffsets.Length != glyphCount)
+            {
+                throw new InvalidOperationException();
+            }
+
+            _glyphRunImpl = s_platformRenderInterface.CreateGlyphRun(this, out var width);
+
+            if (bounds.HasValue)
+            {
+                _bounds = bounds;
+            }
+            else
+            {
+                var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
+
+                _bounds = new Rect(0, 0, width, height);
+            }
+        }
+
+        void IDisposable.Dispose()
+        {
+            _glyphRunImpl?.Dispose();
+        }
+    }
+}

+ 50 - 0
src/Avalonia.Visuals/Media/GlyphRunDrawing.cs

@@ -0,0 +1,50 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+namespace Avalonia.Media
+{
+    public class GlyphRunDrawing : Drawing
+    {
+        public static readonly StyledProperty<IBrush> ForegroundProperty =
+            AvaloniaProperty.Register<GlyphRunDrawing, IBrush>(nameof(Foreground));
+
+        public static readonly StyledProperty<GlyphRun> GlyphRunProperty =
+            AvaloniaProperty.Register<GlyphRunDrawing, GlyphRun>(nameof(GlyphRun));
+
+        public static readonly StyledProperty<Point> BaselineOriginProperty =
+            AvaloniaProperty.Register<GlyphRunDrawing, Point>(nameof(BaselineOrigin));
+
+        public IBrush Foreground
+        {
+            get => GetValue(ForegroundProperty);
+            set => SetValue(ForegroundProperty, value);
+        }
+
+        public GlyphRun GlyphRun
+        {
+            get => GetValue(GlyphRunProperty);
+            set => SetValue(GlyphRunProperty, value);
+        }
+
+        public Point BaselineOrigin
+        {
+            get => GetValue(BaselineOriginProperty);
+            set => SetValue(BaselineOriginProperty, value);
+        }
+
+        public override void Draw(DrawingContext context)
+        {
+            if (GlyphRun == null)
+            {
+                return;
+            }
+
+            context.DrawGlyphRun(Foreground, GlyphRun, BaselineOrigin);
+        }
+
+        public override Rect GetBounds()
+        {
+            return GlyphRun?.Bounds ?? default;
+        }
+    }
+}

+ 5 - 0
src/Avalonia.Visuals/Media/GlyphTypeface.cs

@@ -66,6 +66,11 @@ namespace Avalonia.Media
         /// </summary>
         public int StrikethroughThickness => PlatformImpl.StrikethroughThickness;
 
+        /// <summary>
+        ///     A <see cref="bool"/> value indicating whether all glyphs in the font have the same advancement. 
+        /// </summary>
+        public bool IsFixedPitch => PlatformImpl.IsFixedPitch;
+
         /// <summary>
         ///     Returns an glyph index for the specified codepoint.
         /// </summary>

+ 8 - 0
src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs

@@ -86,6 +86,14 @@ namespace Avalonia.Platform
         /// <param name="text">The text.</param>
         void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text);
 
+        /// <summary>
+        /// Draws a glyph run.
+        /// </summary>
+        /// <param name="foreground">The foreground.</param>
+        /// <param name="glyphRun">The glyph run.</param>
+        /// <param name="baselineOrigin">The baseline origin of the glyph run.</param>
+        void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin);
+
         /// <summary>
         /// Creates a new <see cref="IRenderTargetBitmapImpl"/> that can be used as a render layer
         /// for the current render target.

+ 12 - 0
src/Avalonia.Visuals/Platform/IGlyphRunImpl.cs

@@ -0,0 +1,12 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+
+namespace Avalonia.Platform
+{
+    /// <summary>
+    ///     Actual implementation of a glyph run that stores platform dependent resources.
+    /// </summary>
+    public interface IGlyphRunImpl : IDisposable { }
+}

+ 5 - 0
src/Avalonia.Visuals/Platform/IGlyphTypefaceImpl.cs

@@ -47,6 +47,11 @@ namespace Avalonia.Platform
         /// </summary>
         int StrikethroughThickness { get; }
 
+        /// <summary>
+        ///     A <see cref="bool"/> value indicating whether all glyphs in the font have the same advancement. 
+        /// </summary>
+        bool IsFixedPitch { get; }
+
         /// <summary>
         ///     Returns an glyph index for the specified codepoint.
         /// </summary>

+ 8 - 0
src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs

@@ -117,5 +117,13 @@ namespace Avalonia.Platform
         /// </summary>
         /// <returns>The font manager.</returns>
         IFontManagerImpl CreateFontManager();
+
+        /// <summary>
+        /// Creates a platform implementation of a glyph run.
+        /// </summary>
+        /// <param name="glyphRun">The glyph run.</param>
+        /// <param name="width">The glyph run's width.</param>
+        /// <returns></returns>
+        IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width);
     }
 }

+ 15 - 0
src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs

@@ -190,6 +190,21 @@ namespace Avalonia.Rendering.SceneGraph
             }
         }
 
+        /// <inheritdoc/>
+        public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin)
+        {
+            var next = NextDrawAs<GlyphRunNode>();
+
+            if (next == null || !next.Item.Equals(Transform, foreground, glyphRun))
+            {
+                Add(new GlyphRunNode(Transform, foreground, glyphRun, baselineOrigin, CreateChildScene(foreground)));
+            }
+
+            else
+            {
+                ++_drawOperationindex;
+            }
+        }
         public IRenderTargetBitmapImpl CreateLayer(Size size)
         {
             throw new NotSupportedException("Creating layers on a deferred drawing context not supported");

+ 91 - 0
src/Avalonia.Visuals/Rendering/SceneGraph/GlyphRunNode.cs

@@ -0,0 +1,91 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System.Collections.Generic;
+
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Rendering.SceneGraph
+{
+    /// <summary>
+    /// A node in the scene graph which represents a text draw.
+    /// </summary>
+    internal class GlyphRunNode : BrushDrawOperation
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="GlyphRunNode"/> class.
+        /// </summary>
+        /// <param name="transform">The transform.</param>
+        /// <param name="foreground">The foreground brush.</param>
+        /// <param name="glyphRun">The glyph run to draw.</param>
+        /// <param name="baselineOrigin">The baseline origin of the glyph run.</param>
+        /// <param name="childScenes">Child scenes for drawing visual brushes.</param>
+        public GlyphRunNode(
+            Matrix transform,
+            IBrush foreground,
+            GlyphRun glyphRun,
+            Point baselineOrigin,
+            IDictionary<IVisual, Scene> childScenes = null)
+            : base(glyphRun.Bounds, transform, null)
+        {
+            Transform = transform;
+            Foreground = foreground?.ToImmutable();
+            GlyphRun = glyphRun;
+            BaselineOrigin = baselineOrigin;
+            ChildScenes = childScenes;
+        }
+
+        /// <summary>
+        /// Gets the transform with which the node will be drawn.
+        /// </summary>
+        public Matrix Transform { get; }
+
+        /// <summary>
+        /// Gets the foreground brush.
+        /// </summary>
+        public IBrush Foreground { get; }
+
+        /// <summary>
+        /// Gets the text to draw.
+        /// </summary>
+        public GlyphRun GlyphRun { get; }
+
+        /// <summary>
+        /// Gets the baseline origin.
+        /// </summary>
+        public Point BaselineOrigin { get; set; }
+
+        /// <inheritdoc/>
+        public override IDictionary<IVisual, Scene> ChildScenes { get; }
+
+        /// <inheritdoc/>
+        public override void Render(IDrawingContextImpl context)
+        {
+            context.Transform = Transform;
+            context.DrawGlyphRun(Foreground, GlyphRun, BaselineOrigin);
+        }
+
+        /// <summary>
+        /// Determines if this draw operation equals another.
+        /// </summary>
+        /// <param name="transform">The transform of the other draw operation.</param>
+        /// <param name="foreground">The foreground of the other draw operation.</param>
+        /// <param name="glyphRun">The text of the other draw operation.</param>
+        /// <returns>True if the draw operations are the same, otherwise false.</returns>
+        /// <remarks>
+        /// The properties of the other draw operation are passed in as arguments to prevent
+        /// allocation of a not-yet-constructed draw operation object.
+        /// </remarks>
+        internal bool Equals(Matrix transform, IBrush foreground, GlyphRun glyphRun)
+        {
+            return transform == Transform &&
+                   Equals(foreground, Foreground) &&
+                   Equals(glyphRun, GlyphRun);
+        }
+
+        /// <inheritdoc/>
+        public override bool HitTest(Point p) => Bounds.Contains(p);
+    }
+}

+ 154 - 0
src/Avalonia.Visuals/Utility/ReadOnlySlice.cs

@@ -0,0 +1,154 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using Avalonia.Utilities;
+
+namespace Avalonia.Utility
+{
+    /// <summary>
+    ///     ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region.
+    /// </summary>
+    /// <typeparam name="T">The type of elements in the slice.</typeparam>
+    public readonly struct ReadOnlySlice<T> : IReadOnlyList<T>
+    {
+        public ReadOnlySlice(ReadOnlyMemory<T> buffer) : this(buffer, 0, buffer.Length) { }
+
+        public ReadOnlySlice(ReadOnlyMemory<T> buffer, int start, int length)
+        {
+            Buffer = buffer;
+            Start = start;
+            Length = length;
+        }
+
+        /// <summary>
+        ///     Gets the start.
+        /// </summary>
+        /// <value>
+        ///     The start.
+        /// </value>
+        public int Start { get; }
+
+        /// <summary>
+        ///     Gets the end.
+        /// </summary>
+        /// <value>
+        ///     The end.
+        /// </value>
+        public int End => Start + Length - 1;
+
+        /// <summary>
+        ///     Gets the length.
+        /// </summary>
+        /// <value>
+        ///     The length.
+        /// </value>
+        public int Length { get; }
+
+        /// <summary>
+        ///     Gets a value that indicates whether this instance of <see cref="ReadOnlySpan{T}"/> is Empty.
+        /// </summary>
+        public bool IsEmpty => Length == 0;
+
+        /// <summary>
+        ///     The buffer.
+        /// </summary>
+        public ReadOnlyMemory<T> Buffer { get; }
+
+        public T this[int index] => Buffer.Span[Start + index];
+
+        /// <summary>
+        ///     Returns a span of the underlying buffer.
+        /// </summary>
+        /// <returns>The <see cref="ReadOnlySpan{T}"/> of the underlying buffer.</returns>
+        public ReadOnlySpan<T> AsSpan()
+        {
+            return Buffer.Span.Slice(Start, Length);
+        }
+
+        /// <summary>
+        ///     Returns a sub slice of elements that start at the specified index and has the specified number of elements.
+        /// </summary>
+        /// <param name="start">The start of the sub slice.</param>
+        /// <param name="length">The length of the sub slice.</param>
+        /// <returns>A <see cref="ReadOnlySlice{T}"/> that contains the specified number of elements from the specified start.</returns>
+        public ReadOnlySlice<T> AsSlice(int start, int length)
+        {
+            if (start < 0 || start >= Length)
+            {
+                throw new ArgumentOutOfRangeException(nameof(start));
+            }
+
+            if (Start + start > End)
+            {
+                throw new ArgumentOutOfRangeException(nameof(length));
+            }
+
+            return new ReadOnlySlice<T>(Buffer, Start + start, length);
+        }
+
+        /// <summary>
+        ///     Returns a specified number of contiguous elements from the start of the slice.
+        /// </summary>
+        /// <param name="length">The number of elements to return.</param>
+        /// <returns>A <see cref="ReadOnlySlice{T}"/> that contains the specified number of elements from the start of this slice.</returns>
+        public ReadOnlySlice<T> Take(int length)
+        {
+            if (length > Length)
+            {
+                throw new ArgumentOutOfRangeException(nameof(length));
+            }
+
+            return new ReadOnlySlice<T>(Buffer, Start, length);
+        }
+
+        /// <summary>
+        ///     Bypasses a specified number of elements in the slice and then returns the remaining elements.
+        /// </summary>
+        /// <param name="length">The number of elements to skip before returning the remaining elements.</param>
+        /// <returns>A <see cref="ReadOnlySlice{T}"/> that contains the elements that occur after the specified index in this slice.</returns>
+        public ReadOnlySlice<T> Skip(int length)
+        {
+            if (length > Length)
+            {
+                throw new ArgumentOutOfRangeException(nameof(length));
+            }
+
+            return new ReadOnlySlice<T>(Buffer, Start + length, Length - length);
+        }
+
+        /// <summary>
+        /// Returns an enumerator for the slice.
+        /// </summary>
+        public ImmutableReadOnlyListStructEnumerator<T> GetEnumerator()
+        {
+            return new ImmutableReadOnlyListStructEnumerator<T>(this);
+        }
+
+        IEnumerator<T> IEnumerable<T>.GetEnumerator()
+        {
+            return GetEnumerator();
+        }
+
+        IEnumerator IEnumerable.GetEnumerator()
+        {
+            return GetEnumerator();
+        }
+
+        int IReadOnlyCollection<T>.Count => Length;
+
+        T IReadOnlyList<T>.this[int index] => this[index];
+
+        public static implicit operator ReadOnlySlice<T>(T[] array)
+        {
+            return new ReadOnlySlice<T>(array);
+        }
+
+        public static implicit operator ReadOnlySlice<T>(ReadOnlyMemory<T> memory)
+        {
+            return new ReadOnlySlice<T>(memory);
+        }
+    }
+}

+ 14 - 0
src/Skia/Avalonia.Skia/DrawingContextImpl.cs

@@ -232,6 +232,20 @@ namespace Avalonia.Skia
             }
         }
 
+        /// <inheritdoc />
+        public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin)
+        {
+            using (var paint = CreatePaint(foreground, glyphRun.Bounds.Size))
+            {
+                var glyphRunImpl = (GlyphRunImpl)glyphRun.GlyphRunImpl;
+
+                paint.ApplyTo(glyphRunImpl.Paint);
+
+                Canvas.DrawText(glyphRunImpl.TextBlob, (float)baselineOrigin.X,
+                    (float)baselineOrigin.Y, glyphRunImpl.Paint);
+            }
+        }
+
         /// <inheritdoc />
         public IRenderTargetBitmapImpl CreateLayer(Size size)
         {

+ 35 - 0
src/Skia/Avalonia.Skia/GlyphRunImpl.cs

@@ -0,0 +1,35 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using Avalonia.Platform;
+using SkiaSharp;
+
+namespace Avalonia.Skia
+{
+    /// <inheritdoc />
+    public class GlyphRunImpl : IGlyphRunImpl
+    {
+        public GlyphRunImpl(SKPaint paint, SKTextBlob textBlob)
+        {
+            Paint = paint;
+            TextBlob = textBlob;
+        }
+
+        /// <summary>
+        ///     Gets the paint to draw with.
+        /// </summary>
+        public SKPaint Paint { get; }
+
+        /// <summary>
+        ///     Gets the text blob to draw.
+        /// </summary>
+        public SKTextBlob TextBlob { get; }
+
+        void IDisposable.Dispose()
+        {
+            TextBlob.Dispose();
+            Paint.Dispose();
+        }
+    }
+}

+ 5 - 0
src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs

@@ -61,6 +61,8 @@ namespace Avalonia.Skia
             {
                 StrikethroughThickness = strikethroughThickness;
             }
+
+            IsFixedPitch = Typeface.IsFixedPitch;
         }
 
         public Face Face { get; }
@@ -93,6 +95,9 @@ namespace Avalonia.Skia
         /// <inheritdoc cref="IGlyphTypefaceImpl"/>
         public int StrikethroughThickness { get; }
 
+        /// <inheritdoc cref="IGlyphTypefaceImpl"/>
+        public bool IsFixedPitch { get; }
+
         /// <inheritdoc cref="IGlyphTypefaceImpl"/>
         public ushort GetGlyph(uint codepoint)
         {

+ 85 - 0
src/Skia/Avalonia.Skia/PlatformRenderInterface.cs

@@ -156,5 +156,90 @@ namespace Avalonia.Skia
         {
             return new FontManagerImpl();
         }
+
+        /// <inheritdoc />
+        public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width)
+        {
+            var count = glyphRun.GlyphIndices.Length;
+
+            var glyphTypeface = (GlyphTypefaceImpl)glyphRun.GlyphTypeface.PlatformImpl;
+
+            var typeface = glyphTypeface.Typeface;
+
+            var paint = new SKPaint
+            {
+                TextSize = (float)glyphRun.FontRenderingEmSize,
+                Typeface = typeface,
+                TextEncoding = SKTextEncoding.GlyphId,
+                IsAntialias = true,
+                IsStroke = false,
+                SubpixelText = true
+            };
+
+            using (var textBlobBuilder = new SKTextBlobBuilder())
+            {
+                var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight);
+
+                if (glyphRun.GlyphOffsets.IsEmpty)
+                {
+                    width = 0;
+
+                    var buffer = textBlobBuilder.AllocateHorizontalRun(paint, count, 0);
+
+                    if (!glyphTypeface.IsFixedPitch)
+                    {
+                        var positions = buffer.GetPositionSpan();
+
+                        for (var i = 0; i < count; i++)
+                        {
+                            positions[i] = (float)width;
+
+                            if (glyphRun.GlyphAdvances.IsEmpty)
+                            {
+                                width += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale;
+                            }
+                            else
+                            {
+                                width += glyphRun.GlyphAdvances[i];
+                            }
+                        }
+                    }
+
+                    buffer.SetGlyphs(glyphRun.GlyphIndices.AsSpan());
+                }
+                else
+                {
+                    var buffer = textBlobBuilder.AllocatePositionedRun(paint, count);
+
+                    var glyphPositions = buffer.GetPositionSpan();
+
+                    var currentX = 0.0;
+
+                    for (var i = 0; i < count; i++)
+                    {
+                        var glyphOffset = glyphRun.GlyphOffsets[i];
+
+                        glyphPositions[i] = new SKPoint((float)(currentX + glyphOffset.X), (float)glyphOffset.Y);
+
+                        if (glyphRun.GlyphAdvances.IsEmpty)
+                        {
+                            currentX += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale;
+                        }
+                        else
+                        {
+                            currentX += glyphRun.GlyphAdvances[i];
+                        }
+                    }
+
+                    buffer.SetGlyphs(glyphRun.GlyphIndices.AsSpan());
+
+                    width = currentX;
+                }
+
+                var textBlob = textBlobBuilder.Build();
+
+                return new GlyphRunImpl(paint, textBlob);
+            }
+        }
     }
 }

+ 50 - 0
src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs

@@ -11,6 +11,9 @@ using Avalonia.Direct2D1.Media;
 using Avalonia.Direct2D1.Media.Imaging;
 using Avalonia.Media;
 using Avalonia.Platform;
+using SharpDX.DirectWrite;
+using GlyphRun = Avalonia.Media.GlyphRun;
+using TextAlignment = Avalonia.Media.TextAlignment;
 
 namespace Avalonia
 {
@@ -196,5 +199,52 @@ namespace Avalonia.Direct2D1
         {
             return new FontManagerImpl();
         }
+
+        public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width)
+        {
+            var glyphTypeface = (GlyphTypefaceImpl)glyphRun.GlyphTypeface.PlatformImpl;
+
+            var glyphCount = glyphRun.GlyphIndices.Length;
+
+            var run = new SharpDX.DirectWrite.GlyphRun
+            {
+                FontFace = glyphTypeface.FontFace,
+                FontSize = (float)glyphRun.FontRenderingEmSize
+            };
+
+            var indices = new short[glyphCount];
+
+            for (var i = 0; i < glyphCount; i++)
+            {
+                indices[i] = (short)glyphRun.GlyphIndices[i];
+            }
+
+            run.Indices = indices;
+
+            run.Advances = new float[glyphCount];
+
+            width = 0;
+
+            for (var i = 0; i < glyphCount; i++)
+            {
+                run.Advances[i] = (float)glyphRun.GlyphAdvances[i];
+                width += run.Advances[i];
+            }
+
+            run.Offsets = new GlyphOffset[glyphCount];
+
+            for (var i = 0; i < glyphCount; i++)
+            {
+                var offset = glyphRun.GlyphOffsets[i];
+
+                run.Offsets[i] = new GlyphOffset
+                {
+                    AdvanceOffset = (float)offset.X,
+                    AscenderOffset = (float)offset.Y
+                };
+            }
+
+            return new GlyphRunImpl(run);
+        }
     }
 }

+ 16 - 0
src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs

@@ -316,6 +316,22 @@ namespace Avalonia.Direct2D1.Media
             }
         }
 
+        /// <summary>
+        /// Draws a glyph run.
+        /// </summary>
+        /// <param name="foreground">The foreground.</param>
+        /// <param name="glyphRun">The glyph run.</param>
+        /// <param name="baselineOrigin"></param>
+        public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin)
+        {
+            using (var brush = CreateBrush(foreground, glyphRun.Bounds.Size))
+            {
+                var glyphRunImpl = (GlyphRunImpl)glyphRun.GlyphRunImpl;
+
+                _renderTarget.DrawGlyphRun(baselineOrigin.ToSharpDX(), glyphRunImpl.GlyphRun, brush.PlatformBrush, MeasuringMode.Natural);
+            }
+        }
+
         public IRenderTargetBitmapImpl CreateLayer(Size size)
         {
             if (_layerFactory != null)

+ 19 - 0
src/Windows/Avalonia.Direct2D1/Media/GlyphRunImpl.cs

@@ -0,0 +1,19 @@
+using Avalonia.Platform;
+
+namespace Avalonia.Direct2D1.Media
+{
+    internal class GlyphRunImpl : IGlyphRunImpl
+    {
+        public GlyphRunImpl(SharpDX.DirectWrite.GlyphRun glyphRun)
+        {
+            GlyphRun = glyphRun;
+        }
+
+        public SharpDX.DirectWrite.GlyphRun GlyphRun { get; }
+
+        public void Dispose()
+        {
+            GlyphRun?.Dispose();
+        }
+    }
+}

+ 7 - 2
src/Windows/Avalonia.Direct2D1/Media/GlyphTypefaceImpl.cs

@@ -17,7 +17,7 @@ namespace Avalonia.Direct2D1.Media
         {
             DWFont = Direct2D1FontCollectionCache.GetFont(typeface);
 
-            FontFace = new FontFace(DWFont);
+            FontFace = new FontFace(DWFont).QueryInterface<FontFace1>();
 
             Face = new Face(GetTable);
 
@@ -59,6 +59,8 @@ namespace Avalonia.Direct2D1.Media
             {
                 StrikethroughThickness = strikethroughThickness;
             }
+
+            IsFixedPitch = FontFace.IsMonospacedFont;
         }
 
         private Blob GetTable(Face face, Tag tag)
@@ -82,7 +84,7 @@ namespace Avalonia.Direct2D1.Media
 
         public SharpDX.DirectWrite.Font DWFont { get; }
 
-        public FontFace FontFace { get; }
+        public FontFace1 FontFace { get; }
 
         public Face Face { get; }
 
@@ -113,6 +115,9 @@ namespace Avalonia.Direct2D1.Media
         /// <inheritdoc cref="IGlyphTypefaceImpl"/>
         public int StrikethroughThickness { get; }
 
+        /// <inheritdoc cref="IGlyphTypefaceImpl"/>
+        public bool IsFixedPitch { get; }
+
         /// <inheritdoc cref="IGlyphTypefaceImpl"/>
         public ushort GetGlyph(uint codepoint)
         {

+ 47 - 0
tests/Avalonia.UnitTests/MockGlyphTypeface.cs

@@ -0,0 +1,47 @@
+using System;
+using Avalonia.Platform;
+
+namespace Avalonia.UnitTests
+{
+    public class MockGlyphTypeface : IGlyphTypefaceImpl
+    {
+        public short DesignEmHeight => 10;
+        public int Ascent => 100;
+        public int Descent => 0;
+        public int LineGap { get; }
+        public int UnderlinePosition { get; }
+        public int UnderlineThickness { get; }
+        public int StrikethroughPosition { get; }
+        public int StrikethroughThickness { get; }
+        public bool IsFixedPitch { get; }
+
+        public ushort GetGlyph(uint codepoint)
+        {
+            return 0;
+        }
+
+        public ushort[] GetGlyphs(ReadOnlySpan<uint> codepoints)
+        {
+            return new ushort[codepoints.Length];
+        }
+
+        public int GetGlyphAdvance(ushort glyph)
+        {
+            return 100;
+        }
+
+        public int[] GetGlyphAdvances(ReadOnlySpan<ushort> glyphs)
+        {
+            var advances = new int[glyphs.Length];
+
+            for (var i = 0; i < advances.Length; i++)
+            {
+                advances[i] = 100;
+            }
+
+            return advances;
+        }
+
+        public void Dispose() { }
+    }
+}

+ 6 - 0
tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs

@@ -83,5 +83,11 @@ namespace Avalonia.UnitTests
         {
             return new MockFontManagerImpl();
         }
+
+        public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width)
+        {
+            width = 0;
+            return Mock.Of<IGlyphRunImpl>();
+        }
     }
 }

+ 112 - 0
tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs

@@ -0,0 +1,112 @@
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Visuals.UnitTests.Media
+{
+    public class GlyphRunTests : TestWithServicesBase
+    {
+        public GlyphRunTests()
+        {
+            AvaloniaLocator.CurrentMutable
+                .Bind<IPlatformRenderInterface>().ToSingleton<MockPlatformRenderInterface>();
+        }
+
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 25.0, 0, 3, true)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 20.0, 2, 0, true)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 26.0, 2, 1, true)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 35.0, 2, 1, false)]
+        [Theory]
+        public void Should_Get_TextBounds_FromDistance(double[] advances, ushort[] clusters, double distance, int start,
+            int trailingLengthExpected, bool isInsideExpected)
+        {
+            using (var glyphRun = CreateGlyphRun(advances, clusters))
+            {
+                var textBounds = glyphRun.GetCharacterHitFromDistance(distance, out var isInside);
+
+                Assert.Equal(start, textBounds.FirstCharacterIndex);
+
+                Assert.Equal(trailingLengthExpected, textBounds.TrailingLength);
+
+                Assert.Equal(isInsideExpected, isInside);
+            }
+        }
+
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 30.0)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 0, 1, 1, 1, 10.0)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 3 }, 0, 2, 1, 2, 20.0)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 3 }, 0, 1, 1, 2, 20.0)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 1, 1, 0 }, 1, 1, 1, 2, 20.0)]
+        [Theory]
+        public void Should_Find_Nearest_CharacterHit(double[] advances, ushort[] clusters, int bidiLevel,
+            int index, int expectedIndex, int expectedLength, double expectedWidth)
+        {
+            using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
+            {
+                var textBounds = glyphRun.FindNearestCharacterHit(index, out var width);
+
+                Assert.Equal(expectedIndex, textBounds.FirstCharacterIndex);
+
+                Assert.Equal(expectedLength, textBounds.TrailingLength);
+
+                Assert.Equal(expectedWidth, width, 2);
+            }
+        }
+
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 0)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 1)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 0, 0, 3 }, 3, 0, 3, 1, 0)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 0, 0, 0 }, 3, 0, 3, 1, 1)]
+        [InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 1, 4 }, 4, 0, 4, 1, 0)]
+        [InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 4, 1, 1, 1, 0 }, 4, 0, 4, 1, 1)]
+        [Theory]
+        public void Should_Get_Next_CharacterHit(double[] advances, ushort[] clusters,
+            int currentIndex, int currentLength,
+            int nextIndex, int nextLength,
+            int bidiLevel)
+        {
+            using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
+            {
+                var characterHit = glyphRun.GetNextCaretCharacterHit(new CharacterHit(currentIndex, currentLength));
+
+                Assert.Equal(nextIndex, characterHit.FirstCharacterIndex);
+
+                Assert.Equal(nextLength, characterHit.TrailingLength);
+            }
+        }
+
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 0, 0)]
+        [InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 0, 1)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 0, 0, 3 }, 3, 1, 3, 0, 0)]
+        [InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 0, 0, 0 }, 3, 1, 3, 0, 1)]
+        [InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 1, 4 }, 4, 1, 4, 0, 0)]
+        [InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 4, 1, 1, 1, 0 }, 4, 1, 4, 0, 1)]
+        [Theory]
+        public void Should_Get_Previous_CharacterHit(double[] advances, ushort[] clusters,
+            int currentIndex, int currentLength,
+            int previousIndex, int previousLength,
+            int bidiLevel)
+        {
+            using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
+            {
+                var characterHit = glyphRun.GetPreviousCaretCharacterHit(new CharacterHit(currentIndex, currentLength));
+
+                Assert.Equal(previousIndex, characterHit.FirstCharacterIndex);
+
+                Assert.Equal(previousLength, characterHit.TrailingLength);
+            }
+        }
+
+        private static GlyphRun CreateGlyphRun(double[] glyphAdvances, ushort[] glyphClusters, int bidiLevel = 0)
+        {
+            var count = glyphAdvances.Length;
+            var glyphIndices = new ushort[count];
+
+            var bounds = new Rect(0, 0, count * 10, 10);
+
+            return new GlyphRun(new GlyphTypeface(new MockGlyphTypeface()), 10, glyphIndices, glyphAdvances,
+                glyphClusters: glyphClusters, bidiLevel: bidiLevel, bounds: bounds);
+        }
+    }
+}

+ 1 - 1
tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs

@@ -51,7 +51,7 @@ namespace Avalonia.Visuals.UnitTests.VisualTree
             throw new NotImplementedException();
         }
 
-        public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface)
+        public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width)
         {
             throw new NotImplementedException();
         }