浏览代码

Ability to configure font features (#14157)

* Ability to configure font features

* Minor adjustments

---------

Co-authored-by: Herman Kirshin <[email protected]>
Co-authored-by: Benedikt Stebner <[email protected]>
Herman K 1 年之前
父节点
当前提交
88967de49e

+ 7 - 0
samples/ControlCatalog/Pages/TextBlockPage.xaml

@@ -128,6 +128,13 @@
           </Span>.
         </SelectableTextBlock>
       </Border>
+      <Border>
+        <TextBlock FontFamily="Times New Roman">
+          <Run Text="ABC" FontFeatures="+c2sc, +smcp"/>
+          <Run Text="DEF"/>
+          <Run Text="0123" FontFeatures="frac"/>
+        </TextBlock> 
+      </Border>
     </WrapPanel>
   </StackPanel>
 </UserControl>

+ 153 - 0
src/Avalonia.Base/Media/FontFeature.cs

@@ -0,0 +1,153 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Avalonia.Media;
+
+/// <summary>
+/// Font feature
+/// </summary>
+public record FontFeature
+{
+    private const int DefaultValue = 1;
+    private const int InfinityEnd = -1;
+    
+    private static readonly Regex s_featureRegex = new Regex(
+        @"^\s*(?<Value>[+-])?\s*(?<Tag>\w{4})\s*(\[\s*(?<Start>\d+)?(\s*(?<Separator>:)\s*)?(?<End>\d+)?\s*\])?\s*(?(Value)()|(=\s*(?<Value>\d+|on|off)))?\s*$", 
+        RegexOptions.Compiled | RegexOptions.ExplicitCapture);
+
+    /// <summary>Gets or sets the tag.</summary>
+    public string Tag
+    {
+        get;
+        init;
+    }
+
+    /// <summary>Gets or sets the value.</summary>
+    public int Value
+    {
+        get;
+        init;
+    }
+
+    /// <summary>Gets or sets the start.</summary>
+    public int Start
+    {
+        get;
+        init;
+    }
+
+    /// <summary>Gets or sets the end.</summary>
+    public int End
+    {
+        get;
+        init;
+    }
+    
+    /// <summary>
+    /// Creates an instance of FontFeature.
+    /// </summary>
+    public FontFeature()
+    {
+        Tag = string.Empty;
+        Value = DefaultValue;
+        Start = 0;
+        End = InfinityEnd;
+    }
+
+    /// <summary>
+    /// Parses a string to return a <see cref="FontFeature"/>.
+    /// Syntax is the following:
+    ///  
+    ///     Syntax 	        Value 	Start 	End 	 
+    ///     Setting value: 	  	  	  	 
+    ///     kern 	        1 	    0 	    ∞ 	    Turn feature on
+    ///     +kern 	        1 	    0 	    ∞ 	    Turn feature on
+    ///     -kern 	        0 	    0 	    ∞ 	    Turn feature off
+    ///     kern=0 	        0 	    0 	    ∞ 	    Turn feature off
+    ///     kern=1 	        1 	    0 	    ∞ 	    Turn feature on
+    ///     aalt=2 	        2 	    0 	    ∞ 	    Choose 2nd alternate
+    ///     Setting index: 	  	  	  	 
+    ///     kern[] 	        1 	    0 	    ∞ 	    Turn feature on
+    ///     kern[:] 	    1 	    0 	    ∞ 	    Turn feature on
+    ///     kern[5:] 	    1 	    5 	    ∞ 	    Turn feature on, partial
+    ///     kern[:5] 	    1 	    0 	    5 	    Turn feature on, partial
+    ///     kern[3:5] 	    1 	    3 	    5 	    Turn feature on, range
+    ///     kern[3] 	    1 	    3 	    3+1 	Turn feature on, single char
+    ///     Mixing it all: 	  	  	  	 
+    ///     aalt[3:5]=2 	2 	    3 	    5 	    Turn 2nd alternate on for range
+    /// 
+    /// </summary>
+    /// <param name="s">The string.</param>
+    /// <returns>The <see cref="FontFeature"/>.</returns>
+    // ReSharper disable once UnusedMember.Global
+    public static FontFeature Parse(string s)
+    {
+        var match = s_featureRegex.Match(s);
+        
+        if (!match.Success)
+        {
+            return new FontFeature();
+        }
+           
+        var hasSeparator = match.Groups["Separator"].Value == ":";
+        var hasStart = int.TryParse(match.Groups["Start"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var start);
+        var hasEnd = int.TryParse(match.Groups["End"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var end);
+        
+        var stringValue = match.Groups["Value"].Value;
+        if (stringValue == "-" || stringValue.ToUpperInvariant() == "OFF")
+            stringValue = "0";
+        if (stringValue == "+" || stringValue.ToUpperInvariant() == "ON")
+            stringValue = "1";
+
+        var result = new FontFeature
+        {
+            Tag = match.Groups["Tag"].Value,
+            Start = hasStart ? start : 0,
+            End = hasEnd ? end : hasStart && !hasSeparator ? (start + 1) : InfinityEnd,
+            Value = int.TryParse(stringValue, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : DefaultValue,
+        };
+
+        return result;
+    }
+    
+    /// <summary>
+    /// Gets a string representation of the <see cref="FontFeature"/>.
+    /// </summary>
+    /// <returns>The string representation.</returns>
+    public override string ToString()
+    {
+        var result = new StringBuilder(128);
+        
+        if (Value == 0)
+            result.Append('-');
+        result.Append(Tag ?? string.Empty);
+
+        if (Start != 0 || End != InfinityEnd)
+        {
+            result.Append('[');
+            
+            if (Start > 0)
+                result.Append(Start.ToString(CultureInfo.InvariantCulture));
+            
+            if (End != Start + 1) 
+            {
+                result.Append(':');
+                if (End != InfinityEnd)
+                    result.Append(End.ToString(CultureInfo.InvariantCulture));
+            }
+            
+            result.Append(']');
+        }
+
+        if (Value is DefaultValue or 0)
+        {
+            return result.ToString();
+        }
+        
+        result.Append('=');
+        result.Append(Value.ToString(CultureInfo.InvariantCulture));
+
+        return result.ToString();
+    }
+}

+ 10 - 0
src/Avalonia.Base/Media/FontFeatureCollection.cs

@@ -0,0 +1,10 @@
+using Avalonia.Collections;
+
+namespace Avalonia.Media;
+
+/// <summary>
+/// List of font feature settings
+/// </summary>
+public class FontFeatureCollection : AvaloniaList<FontFeature>
+{
+}

+ 66 - 0
src/Avalonia.Base/Media/FormattedText.cs

@@ -3,6 +3,7 @@ using System.Collections;
 using System.ComponentModel;
 using System.Diagnostics;
 using System.Globalization;
+using System.Linq;
 using Avalonia.Media.TextFormatting;
 using Avalonia.Utilities;
 
@@ -50,6 +51,7 @@ namespace Avalonia.Media
         /// <param name="typeface">Type face used to display text.</param>
         /// <param name="emSize">Font em size in visual units (1/96 of an inch).</param>
         /// <param name="foreground">Foreground brush used to render text.</param>
+        /// <param name="features">Optional list of turned on/off features.</param>
         public FormattedText(
             string textToFormat,
             CultureInfo culture,
@@ -183,6 +185,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     runProps.Typeface,
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     foregroundBrush,
@@ -197,6 +200,62 @@ namespace Avalonia.Media
             }
         }
 
+        /// <summary>
+        /// Sets or changes the font features for the text object 
+        /// </summary>
+        /// <param name="fontFeatures">Feature collection</param>
+        public void SetFontFeatures(FontFeatureCollection? fontFeatures)
+        {
+            SetFontFeatures(fontFeatures, 0, _text.Length);
+        }
+        
+        /// <summary>
+        /// Sets or changes the font features for the text object 
+        /// </summary>
+        /// <param name="fontFeatures">Feature collection</param>
+        /// <param name="startIndex">The start index of initial character to apply the change to.</param>
+        /// <param name="count">The number of characters the change should be applied to.</param>
+        public void SetFontFeatures(FontFeatureCollection? fontFeatures, int startIndex, int count)
+        {
+            var limit = ValidateRange(startIndex, count);
+            for (var i = startIndex; i < limit;)
+            {
+                var formatRider = new SpanRider(_formatRuns, _latestPosition, i);
+                i = Math.Min(limit, i + formatRider.Length);
+
+#pragma warning disable 6506
+                // Presharp warns that runProps is not validated, but it can never be null 
+                // because the rider is already checked to be in range
+
+                if (!(formatRider.CurrentElement is GenericTextRunProperties runProps))
+                {
+                    throw new NotSupportedException($"{nameof(runProps)} can not be null.");
+                }
+
+                if ((fontFeatures == null && runProps.FontFeatures == null) ||
+                    (fontFeatures != null && runProps.FontFeatures != null && 
+                     fontFeatures.SequenceEqual(runProps.FontFeatures)))
+                {
+                    continue;
+                }
+
+                var newProps = new GenericTextRunProperties(
+                    runProps.Typeface,
+                    fontFeatures,
+                    runProps.FontRenderingEmSize,
+                    runProps.TextDecorations,
+                    runProps.ForegroundBrush,
+                    runProps.BackgroundBrush,
+                    runProps.BaselineAlignment,
+                    runProps.CultureInfo
+                );
+
+#pragma warning restore 6506
+                _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition,
+                    newProps, formatRider.SpanPosition);
+            }
+        }
+        
         /// <summary>
         /// Sets or changes the font family for the text object 
         /// </summary>
@@ -270,6 +329,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     new Typeface(fontFamily, oldTypeface.Style, oldTypeface.Weight),
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -329,6 +389,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     runProps.Typeface,
+                    runProps.FontFeatures,
                     emSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -391,6 +452,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     runProps.Typeface,
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -450,6 +512,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     new Typeface(oldTypeface.FontFamily, oldTypeface.Style, weight),
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -506,6 +569,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     new Typeface(oldTypeface.FontFamily, style, oldTypeface.Weight),
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -562,6 +626,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     typeface,
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     runProps.TextDecorations,
                     runProps.ForegroundBrush,
@@ -619,6 +684,7 @@ namespace Avalonia.Media
 
                 var newProps = new GenericTextRunProperties(
                     runProps.Typeface,
+                    runProps.FontFeatures,
                     runProps.FontRenderingEmSize,
                     textDecorations,
                     runProps.ForegroundBrush,

+ 23 - 1
src/Avalonia.Base/Media/TextFormatting/GenericTextRunProperties.cs

@@ -1,4 +1,6 @@
-using System.Globalization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -9,9 +11,25 @@ namespace Avalonia.Media.TextFormatting
     {
         private const double DefaultFontRenderingEmSize = 12;
 
+        // TODO12: Remove in 12.0.0 and make fontFeatures parameter in main ctor optional
         public GenericTextRunProperties(Typeface typeface, double fontRenderingEmSize = DefaultFontRenderingEmSize,
             TextDecorationCollection? textDecorations = null, IBrush? foregroundBrush = null,
             IBrush? backgroundBrush = null, BaselineAlignment baselineAlignment = BaselineAlignment.Baseline,
+            CultureInfo? cultureInfo = null) : 
+            this(typeface, null, fontRenderingEmSize, textDecorations, foregroundBrush,
+            backgroundBrush, baselineAlignment, cultureInfo)
+        {
+        }
+        
+        // TODO12:Change signature in 12.0.0
+        public GenericTextRunProperties(
+            Typeface typeface, 
+            FontFeatureCollection? fontFeatures, 
+            double fontRenderingEmSize = DefaultFontRenderingEmSize,
+            TextDecorationCollection? textDecorations = null,
+            IBrush? foregroundBrush = null,
+            IBrush? backgroundBrush = null,
+            BaselineAlignment baselineAlignment = BaselineAlignment.Baseline,
             CultureInfo? cultureInfo = null)
         {
             Typeface = typeface;
@@ -21,6 +39,7 @@ namespace Avalonia.Media.TextFormatting
             BackgroundBrush = backgroundBrush;
             BaselineAlignment = baselineAlignment;
             CultureInfo = cultureInfo;
+            FontFeatures = fontFeatures;
         }
 
         /// <inheritdoc />
@@ -38,6 +57,9 @@ namespace Avalonia.Media.TextFormatting
         /// <inheritdoc />
         public override IBrush? BackgroundBrush { get; }
 
+        /// <inheritdoc />
+        public override FontFeatureCollection? FontFeatures { get; }
+
         /// <inheritdoc />
         public override BaselineAlignment BaselineAlignment { get; }
 

+ 3 - 2
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@@ -272,7 +272,7 @@ namespace Avalonia.Media.TextFormatting
                                 }
 
                                 var shaperOptions = new TextShaperOptions(
-                                    properties.CachedGlyphTypeface,
+                                    properties.CachedGlyphTypeface, properties.FontFeatures,
                                     properties.FontRenderingEmSize, shapeableRun.BidiLevel, properties.CultureInfo,
                                     paragraphProperties.DefaultIncrementalTab, paragraphProperties.LetterSpacing);
 
@@ -976,7 +976,8 @@ namespace Avalonia.Media.TextFormatting
 
             var cultureInfo = textRun.Properties.CultureInfo;
 
-            var shaperOptions = new TextShaperOptions(glyphTypeface, fontRenderingEmSize, (sbyte)flowDirection, cultureInfo);
+            var shaperOptions = new TextShaperOptions(glyphTypeface, textRun.Properties.FontFeatures, 
+                fontRenderingEmSize, (sbyte)flowDirection, cultureInfo);
 
             var shapedBuffer = textShaper.ShapeText(textRun.Text, shaperOptions);
 

+ 47 - 3
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@@ -17,6 +17,7 @@ namespace Avalonia.Media.TextFormatting
 
         private int _textSourceLength;
 
+        // TODO12: Remove in 12.0.0 and make fontFeatures parameter in main ctor optional
         /// <summary>
         /// Initializes a new instance of the <see cref="TextLayout" /> class.
         /// </summary>
@@ -51,10 +52,52 @@ namespace Avalonia.Media.TextFormatting
             double letterSpacing = 0,
             int maxLines = 0,
             IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null)
+            : this(text, typeface, null, fontSize, foreground, textAlignment, textWrapping, textTrimming, textDecorations, 
+            flowDirection, maxWidth, maxHeight, lineHeight, letterSpacing, maxLines, textStyleOverrides)
+        {
+        }
+        
+        // TODO12:Change signature in 12.0.0
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TextLayout" /> class.
+        /// </summary>
+        /// <param name="text">The text.</param>
+        /// <param name="typeface">The typeface.</param>
+        /// <param name="fontSize">Size of the font.</param>
+        /// <param name="foreground">The foreground.</param>
+        /// <param name="textAlignment">The text alignment.</param>
+        /// <param name="textWrapping">The text wrapping.</param>
+        /// <param name="textTrimming">The text trimming.</param>
+        /// <param name="textDecorations">The text decorations.</param>
+        /// <param name="flowDirection">The text flow direction.</param>
+        /// <param name="maxWidth">The maximum width.</param>
+        /// <param name="maxHeight">The maximum height.</param>
+        /// <param name="lineHeight">The height of each line of text.</param>
+        /// <param name="letterSpacing">The letter spacing that is applied to rendered glyphs.</param>
+        /// <param name="maxLines">The maximum number of text lines.</param>
+        /// <param name="textStyleOverrides">The text style overrides.</param>
+        /// <param name="fontFeatures">Optional list of turned on/off features.</param>
+        public TextLayout(
+            string? text,
+            Typeface typeface,
+            FontFeatureCollection? fontFeatures,
+            double fontSize,
+            IBrush? foreground,
+            TextAlignment textAlignment = TextAlignment.Left,
+            TextWrapping textWrapping = TextWrapping.NoWrap,
+            TextTrimming? textTrimming = null,
+            TextDecorationCollection? textDecorations = null,
+            FlowDirection flowDirection = FlowDirection.LeftToRight,
+            double maxWidth = double.PositiveInfinity,
+            double maxHeight = double.PositiveInfinity,
+            double lineHeight = double.NaN,
+            double letterSpacing = 0,
+            int maxLines = 0,
+            IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null)
         {
             _paragraphProperties =
                 CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping,
-                    textDecorations, flowDirection, lineHeight, letterSpacing);
+                    textDecorations, flowDirection, lineHeight, letterSpacing, fontFeatures);
 
             _textSource = new FormattedTextSource(text ?? "", _paragraphProperties.DefaultTextRunProperties, textStyleOverrides);
 
@@ -484,13 +527,14 @@ namespace Avalonia.Media.TextFormatting
         /// <param name="flowDirection">The text flow direction.</param>
         /// <param name="lineHeight">The height of each line of text.</param>
         /// <param name="letterSpacing">The letter spacing that is applied to rendered glyphs.</param>
+        /// <param name="features">Optional list of turned on/off features.</param>
         /// <returns></returns>
         internal static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize,
             IBrush? foreground, TextAlignment textAlignment, TextWrapping textWrapping,
             TextDecorationCollection? textDecorations, FlowDirection flowDirection, double lineHeight,
-            double letterSpacing)
+            double letterSpacing, FontFeatureCollection? features)
         {
-            var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
+            var textRunStyle = new GenericTextRunProperties(typeface, features, fontSize, textDecorations, foreground);
 
             return new GenericTextParagraphProperties(flowDirection, textAlignment, true, false,
                 textRunStyle, textWrapping, lineHeight, 0, letterSpacing);

+ 8 - 2
src/Avalonia.Base/Media/TextFormatting/TextRunProperties.cs

@@ -44,6 +44,11 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         public abstract CultureInfo? CultureInfo { get; }
 
+        /// <summary>
+        /// Optional features of used font.
+        /// </summary>
+        public virtual FontFeatureCollection? FontFeatures => null;
+
         /// <summary>
         /// Run vertical box alignment
         /// </summary>
@@ -64,7 +69,8 @@ namespace Avalonia.Media.TextFormatting
                    && Equals(TextDecorations, other.TextDecorations) &&
                    Equals(ForegroundBrush, other.ForegroundBrush) &&
                    Equals(BackgroundBrush, other.BackgroundBrush) &&
-                   Equals(CultureInfo, other.CultureInfo);
+                   Equals(CultureInfo, other.CultureInfo) &&
+                   Equals(FontFeatures, other.FontFeatures);
         }
 
         public override bool Equals(object? obj)
@@ -101,7 +107,7 @@ namespace Avalonia.Media.TextFormatting
             if (this is GenericTextRunProperties other && other.Typeface == typeface)
                 return this;
 
-            return new GenericTextRunProperties(typeface, FontRenderingEmSize,
+            return new GenericTextRunProperties(typeface, FontFeatures, FontRenderingEmSize, 
                 TextDecorations, ForegroundBrush, BackgroundBrush, BaselineAlignment);
         }
     }

+ 21 - 1
src/Avalonia.Base/Media/TextFormatting/TextShaperOptions.cs

@@ -1,4 +1,5 @@
-using System.Globalization;
+using System.Collections.Generic;
+using System.Globalization;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -7,8 +8,22 @@ namespace Avalonia.Media.TextFormatting
     /// </summary>
     public readonly record struct TextShaperOptions
     {
+        // TODO12: Remove in 12.0.0 and make fontFeatures parameter in main ctor optional
+        public TextShaperOptions(
+            IGlyphTypeface typeface,
+            double fontRenderingEmSize = 12,
+            sbyte bidiLevel = 0,
+            CultureInfo? culture = null,
+            double incrementalTabWidth = 0,
+            double letterSpacing = 0)
+            : this(typeface, null, fontRenderingEmSize, bidiLevel, culture, incrementalTabWidth, letterSpacing)
+        {
+        }
+
+        // TODO12:Change signature in 12.0.0
         public TextShaperOptions(
             IGlyphTypeface typeface, 
+            IReadOnlyList<FontFeature>? fontFeatures,
             double fontRenderingEmSize = 12, 
             sbyte bidiLevel = 0, 
             CultureInfo? culture = null, 
@@ -21,6 +36,7 @@ namespace Avalonia.Media.TextFormatting
             Culture = culture;
             IncrementalTabWidth = incrementalTabWidth;
             LetterSpacing = letterSpacing;
+            FontFeatures = fontFeatures;
         }
 
         /// <summary>
@@ -52,5 +68,9 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         public double LetterSpacing { get; }
 
+        /// <summary>
+        /// Get features.
+        /// </summary>
+        public IReadOnlyList<FontFeature>? FontFeatures { get; } 
     }
 }

+ 8 - 2
src/Avalonia.Controls/Documents/Inline.cs

@@ -102,8 +102,14 @@ namespace Avalonia.Controls.Documents
                 fontWeight = FontWeight.Bold;
             }
 
-            return new GenericTextRunProperties(new Typeface(FontFamily, fontStyle, fontWeight), FontSize,
-                textDecorations, Foreground, background, BaselineAlignment);
+            return new GenericTextRunProperties(
+                new Typeface(FontFamily, fontStyle, fontWeight),
+                FontFeatures, 
+                FontSize,
+                textDecorations, 
+                Foreground, 
+                background,
+                BaselineAlignment);
         }
 
         /// <inheritdoc />

+ 37 - 0
src/Avalonia.Controls/Documents/TextElement.cs

@@ -23,6 +23,14 @@ namespace Avalonia.Controls.Documents
                 defaultValue: FontFamily.Default,
                 inherits: true);
 
+        /// <summary>
+        /// Defines the <see cref="FontFeatures"/> property.
+        /// </summary>
+        public static readonly AttachedProperty<FontFeatureCollection?> FontFeaturesProperty =
+            AvaloniaProperty.RegisterAttached<TextElement, TextElement, FontFeatureCollection?>(
+                nameof(FontFeatures),
+                inherits: true);
+        
         /// <summary>
         /// Defines the <see cref="FontSize"/> property.
         /// </summary>
@@ -87,6 +95,15 @@ namespace Avalonia.Controls.Documents
             set => SetValue(FontFamilyProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the font features.
+        /// </summary>
+        public FontFeatureCollection? FontFeatures
+        {
+            get => GetValue(FontFeaturesProperty);
+            set => SetValue(FontFeaturesProperty, value);
+        }
+
         /// <summary>
         /// Gets or sets the font size.
         /// </summary>
@@ -152,6 +169,26 @@ namespace Avalonia.Controls.Documents
             control.SetValue(FontFamilyProperty, value);
         }
 
+        /// <summary>
+        /// Gets the value of the attached <see cref="FontFeaturesProperty"/> on a control.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <returns>The font family.</returns>
+        public static FontFeatureCollection? GetFontFeatures(Control control)
+        {
+            return control.GetValue(FontFeaturesProperty);
+        }
+
+        /// <summary>
+        /// Sets the value of the attached <see cref="FontFeaturesProperty"/> on a control.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="value">The property value to set.</param>
+        public static void SetFontFeatures(Control control, FontFeatureCollection? value)
+        {
+            control.SetValue(FontFeaturesProperty, value);
+        }
+        
         /// <summary>
         /// Gets the value of the attached <see cref="FontSizeProperty"/> on a control.
         /// </summary>

+ 12 - 3
src/Avalonia.Controls/Presenters/TextPresenter.cs

@@ -152,6 +152,15 @@ namespace Avalonia.Controls.Presenters
             set => TextElement.SetFontFamily(this, value);
         }
 
+        /// <summary>
+        /// Gets or sets the font family.
+        /// </summary>
+        public FontFeatureCollection? FontFeatures
+        {
+            get => TextElement.GetFontFeatures(this);
+            set => TextElement.SetFontFeatures(this, value);
+        }
+
         /// <summary>
         /// Gets or sets the font size.
         /// </summary>
@@ -329,7 +338,7 @@ namespace Avalonia.Controls.Presenters
             var maxWidth = MathUtilities.IsZero(constraint.Width) ? double.PositiveInfinity : constraint.Width;
             var maxHeight = MathUtilities.IsZero(constraint.Height) ? double.PositiveInfinity : constraint.Height;
 
-            var textLayout = new TextLayout(text, typeface, FontSize, foreground, TextAlignment,
+            var textLayout = new TextLayout(text, typeface, FontFeatures, FontSize, foreground, TextAlignment,
                 TextWrapping, maxWidth: maxWidth, maxHeight: maxHeight, textStyleOverrides: textStyleOverrides,
                 flowDirection: FlowDirection, lineHeight: LineHeight, letterSpacing: LetterSpacing);
 
@@ -531,7 +540,7 @@ namespace Avalonia.Controls.Presenters
             if (!string.IsNullOrEmpty(preeditText))
             {
                 var preeditHighlight = new ValueSpan<TextRunProperties>(caretIndex, preeditText.Length,
-                        new GenericTextRunProperties(typeface, FontSize,
+                        new GenericTextRunProperties(typeface, FontFeatures, FontSize,
                         foregroundBrush: foreground,
                         textDecorations: TextDecorations.Underline));
 
@@ -547,7 +556,7 @@ namespace Avalonia.Controls.Presenters
                     textStyleOverrides = new[]
                     {
                         new ValueSpan<TextRunProperties>(start, length,
-                        new GenericTextRunProperties(typeface, FontSize,
+                        new GenericTextRunProperties(typeface, FontFeatures, FontSize,
                             foregroundBrush: SelectionForegroundBrush))
                     };
                 }

+ 15 - 0
src/Avalonia.Controls/Primitives/TemplatedControl.cs

@@ -52,6 +52,12 @@ namespace Avalonia.Controls.Primitives
         public static readonly StyledProperty<FontFamily> FontFamilyProperty =
             TextElement.FontFamilyProperty.AddOwner<TemplatedControl>();
 
+        /// <summary>
+        /// Defines the <see cref="FontFeaturesProperty"/> property.
+        /// </summary>
+        public static readonly StyledProperty<FontFeatureCollection?> FontFeaturesProperty =
+            TextElement.FontFeaturesProperty.AddOwner<TemplatedControl>();
+        
         /// <summary>
         /// Defines the <see cref="FontSize"/> property.
         /// </summary>
@@ -182,6 +188,15 @@ namespace Avalonia.Controls.Primitives
             set => SetValue(FontFamilyProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the font features turned on/off.
+        /// </summary>
+        public FontFeatureCollection? FontFeatures
+        {
+            get => GetValue(FontFeaturesProperty);
+            set => SetValue(FontFeaturesProperty, value);
+        }
+
         /// <summary>
         /// Gets or sets the size of the control's text in points.
         /// </summary>

+ 2 - 1
src/Avalonia.Controls/SelectableTextBlock.cs

@@ -186,6 +186,7 @@ namespace Avalonia.Controls
 
             var defaultProperties = new GenericTextRunProperties(
                 typeface,
+                FontFeatures,
                 FontSize,
                 TextDecorations,
                 Foreground);
@@ -207,7 +208,7 @@ namespace Avalonia.Controls
                 textStyleOverrides = new[]
                 {
                         new ValueSpan<TextRunProperties>(start, length,
-                        new GenericTextRunProperties(typeface, FontSize,
+                        new GenericTextRunProperties(typeface, FontFeatures, FontSize,
                             foregroundBrush: SelectionForegroundBrush))
                     };
             }

+ 17 - 0
src/Avalonia.Controls/TextBlock.cs

@@ -148,6 +148,12 @@ namespace Avalonia.Controls
         public static readonly StyledProperty<TextDecorationCollection?> TextDecorationsProperty =
             Inline.TextDecorationsProperty.AddOwner<TextBlock>();
 
+        /// <summary>
+        /// Defines the <see cref="FontFeatures"/> property.
+        /// </summary>
+        public static readonly StyledProperty<FontFeatureCollection?> FontFeaturesProperty =
+            TextElement.FontFeaturesProperty.AddOwner<TextBlock>();
+
         /// <summary>
         /// Defines the <see cref="Inlines"/> property.
         /// </summary>
@@ -339,6 +345,15 @@ namespace Avalonia.Controls
             set => SetValue(TextDecorationsProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the font features.
+        /// </summary>
+        public FontFeatureCollection? FontFeatures
+        {
+            get => GetValue(FontFeaturesProperty);
+            set => SetValue(FontFeaturesProperty, value);
+        }
+
         /// <summary>
         /// Gets or sets the inlines.
         /// </summary>
@@ -635,6 +650,7 @@ namespace Avalonia.Controls
 
             var defaultProperties = new GenericTextRunProperties(
                 typeface,
+                FontFeatures,
                 FontSize,
                 TextDecorations,
                 Foreground);
@@ -806,6 +822,7 @@ namespace Avalonia.Controls
 
                 case nameof(Text):
                 case nameof(TextDecorations):
+                case nameof(FontFeatures):
                 case nameof(Foreground):
                     {
                         InvalidateTextLayout();

+ 2 - 2
src/Avalonia.Controls/TextBox.cs

@@ -2221,7 +2221,7 @@ namespace Avalonia.Controls
                 {
                     var fontSize = FontSize;
                     var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch);
-                    var paragraphProperties = TextLayout.CreateTextParagraphProperties(typeface, fontSize, null, default, default, null, default, LineHeight, default);
+                    var paragraphProperties = TextLayout.CreateTextParagraphProperties(typeface, fontSize, null, default, default, null, default, LineHeight, default, FontFeatures);
                     var textLayout = new TextLayout(new LineTextSource(MaxLines), paragraphProperties);
                     var verticalSpace = GetVerticalSpaceBetweenScrollViewerAndPresenter();
 
@@ -2237,7 +2237,7 @@ namespace Avalonia.Controls
                 {
                     var fontSize = FontSize;
                     var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch);
-                    var paragraphProperties = TextLayout.CreateTextParagraphProperties(typeface, fontSize, null, default, default, null, default, LineHeight, default);
+                    var paragraphProperties = TextLayout.CreateTextParagraphProperties(typeface, fontSize, null, default, default, null, default, LineHeight, default, FontFeatures);
                     var textLayout = new TextLayout(new LineTextSource(MinLines), paragraphProperties);
                     var verticalSpace = GetVerticalSpaceBetweenScrollViewerAndPresenter();
 

+ 24 - 1
src/Skia/Avalonia.Skia/TextShaperImpl.cs

@@ -41,7 +41,7 @@ namespace Avalonia.Skia
 
                 var font = ((GlyphTypefaceImpl)typeface).Font;
 
-                font.Shape(buffer);
+                font.Shape(buffer, GetFeatures(options));
 
                 if (buffer.Direction == Direction.RightToLeft)
                 {
@@ -176,5 +176,28 @@ namespace Avalonia.Skia
             // should never happen
             throw new InvalidOperationException("Memory not backed by string, array or manager");
         }
+
+        private static Feature[] GetFeatures(TextShaperOptions options)
+        {
+            if (options.FontFeatures is null || options.FontFeatures.Count == 0)
+            {
+                return Array.Empty<Feature>();
+            }
+
+            var features = new Feature[options.FontFeatures.Count];
+            
+            for (var i = 0; i < options.FontFeatures.Count; i++)
+            {
+                var fontFeature = options.FontFeatures[i];
+
+                features[i] = new Feature(
+                    Tag.Parse(fontFeature.Tag), 
+                    (uint)fontFeature.Value,
+                    (uint)fontFeature.Start,
+                    (uint)fontFeature.End);
+            }
+            
+            return features;
+        }
     }
 }

+ 24 - 1
src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs

@@ -42,7 +42,7 @@ namespace Avalonia.Direct2D1.Media
 
                 var font = ((GlyphTypefaceImpl)typeface).Font;
 
-                font.Shape(buffer);
+                font.Shape(buffer, GetFeatures(options));
 
                 if (buffer.Direction == Direction.RightToLeft)
                 {
@@ -177,5 +177,28 @@ namespace Avalonia.Direct2D1.Media
             // should never happen
             throw new InvalidOperationException("Memory not backed by string, array or manager");
         }
+        
+        private static Feature[] GetFeatures(TextShaperOptions options)
+        {
+            if (options.FontFeatures is null || options.FontFeatures.Count == 0)
+            {
+                return Array.Empty<Feature>();
+            }
+
+            var features = new Feature[options.FontFeatures.Count];
+            
+            for (var i = 0; i < options.FontFeatures.Count; i++)
+            {
+                var fontFeature = options.FontFeatures[i];
+
+                features[i] = new Feature(
+                    Tag.Parse(fontFeature.Tag), 
+                    (uint)fontFeature.Value,
+                    (uint)fontFeature.Start,
+                    (uint)fontFeature.End);
+            }
+            
+            return features;
+        }
     }
 }