WPF 简单聊聊如何使用 DrawGlyphRun 绘制文本

Posted lindexi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了WPF 简单聊聊如何使用 DrawGlyphRun 绘制文本相关的知识,希望对你有一定的参考价值。

在 WPF 里面,提供的使用底层的方法绘制文本是通过 DrawGlyphRun 的方式,此方法适合用在需要对文本进行精细控制的定制化控件上。此方法特别底层而让调用方法比较复杂,本文告诉大家一些简单的使用方法

本文也属于 WPF 渲染系列博客,更多渲染相关博客请看 渲染相关

在开始之前,我是来劝退的,如果没有特别的需求,还是不推荐使用 DrawGlyphRun 的方式进行文本绘制。本文不会告诉大家特别基础的知识,基础部分还请看官方文档: GlyphRun Class (System.Windows.Media)

如果可以的话,顺便也将 DirectWrite官方文档也读一次

使用 DrawGlyphRun 方法之前需要拿到一个 DrawingContext 对象,而在调用此方法时,重要的参数是 GlyphRun 对象,此对象包含了大量的参数,本文将来告诉大家这些的参数的用法

例子

新建一个空 WPF 项目用来做例子

在 MainWindow 的 Loaded 事件里面,创建 DrawingVisual 用来获取 DrawingContext 对象

        public MainWindow()
        {
            InitializeComponent();

            Loaded += MainWindow_Loaded;
        }

        private void MainWindow_Loaded(object sender, RoutedEventArgs e)
        {
            var drawingVisual = new DrawingVisual();
            using (var drawingContext = drawingVisual.RenderOpen())
            {

            }
            Background = new VisualBrush(drawingVisual);
        }

默认作为 Background 的 Brush 将会被撑开,为了让后续绘制的文本有指定的尺寸,绘制一个和窗口相同大小的矩形,这样就可以让 drawingVisual.Drawing.Bounds 的尺寸和窗口相同

using (var drawingContext = drawingVisual.RenderOpen())
{
    drawingContext.DrawRectangle(Brushes.Black, null, new Rect(0, 0, ActualWidth, ActualHeight));
}

准备

在使用 DrawGlyphRun 绘制需要创建 GlyphRun 对象,需要有以下参数才能构建出绘制的文本内容

  • 字体
  • 字号
  • 文本内容
  • 文本绘制画刷
  • 文本绘制的坐标

尽管 GlyphRun 对象需要的参数很多,然而很多参数都是可以默认获取的

字体

在 GlyphRun 里面需要的字体不是 FontFamily 而是需要传入的是 GlyphTypeface 对象。好在 GlyphTypeface 对象就是可以从 FontFamily 获取的

每个字体都相当于有一族,多个 Typeface 对象,如下面代码可以获取第一个 Typeface 对象

var fontFamily = new FontFamily("微软雅黑");
Typeface typeface = fontFamily.GetTypefaces().First();

如果此字体是成功安装的,清真的字体,那么可以通过如下代码获取到 GlyphTypeface 对象

bool success = typeface.TryGetGlyphTypeface(out GlyphTypeface glyphTypeface);

大部分字体都能成功拿到,如果不能成功那么,那么就需要自己走字体 Fallback 换个字体啦,或者炸掉。自己决定如果给定的字体创建失败了,则使用什么字体代替的方法叫做字体 Fallback 算法

关于如何做字体的回滚策略,还请参阅下文 字体回滚策略 内容

文字编号

每个文字在字体里面都可以有自己的编号,需要通过 CharacterToGlyphMap 获取对应的值

var text = "林德熙abc123ATdVACC";

List<ushort> glyphIndices = new List<ushort>();

for (var i = 0; i < text.Length; i++)
{
    var c = text[i];
    var glyphIndex = glyphTypeface.CharacterToGlyphMap[c];
    glyphIndices.Add(glyphIndex);
}

需要同时在 GlyphRun 传入编号和 Unicode 的值

设置字号

在 GlyphRun 里面,支持输入多个文字和单个文字,在输入时,可以给每个文字指定字号。字号其实是一个上层的概念,而在 GlyphRun 需要使用底层的文本渲染概念,也就是字符的 AdvanceWidth 的值。简单的获取 AdvanceWidth 的方法如下

List<double> advanceWidths = new List<double>();

for (var i = 0; i < text.Length; i++)
{
    var c = text[i];

    var width = glyphTypeface.AdvanceWidths[glyphIndex] * fontSize;
    advanceWidths.Add(width);
}

以上代码将字符串每个文字都设置相同的字号,但是大家可以根据需求,给每个文字都设置字号。对于等宽字符来说,每个字符的 AdvanceWidths 对应的值都应该是相同的。对于非等宽字符,可以在特殊排版需求的时候,强行设置为等宽的值

字符都是等比的,因此只需要设置宽度即可,设置字宽等于设置字号

设置字体偏移

在 GlyphRun 的高级用法里面,是允许设置文字的偏移量。文字的偏移量是一个文字的排版的基础值,推荐大家写一点代码去摸索一下他的规则

List<Point> glyphOffsets = new List<Point>();
var fontSize = 30;

for (var i = 0; i < text.Length; i++)
{
    var c = text[i];

    // 只是决定每个字的偏移量,记得加上 i 乘以哦。字符最好是叠加上 fontSize 的值,使用 fontSize 的倍数
    glyphOffsets.Add(new Point(fontSize * i, 0));
}

在 GlyphRun 里面,文字的偏移量非必须的,可以传入为空值,因此以上代码是非必须的,只有需要控制每个字的偏移量的时候才需要用到。此偏移量不是相对坐标值,只是偏移量而已,相对来说比较绕

文本偏移

在 DrawGlyphRun 方法里面是不包含文本的坐标的参数的,需要在 GlyphRun 对象里面设置整个文本的起始坐标,如下面代码准备好文本的 X 和 Y 坐标值

    var location = new Point(10, 100);

上面代码只是例子而已,还请替换为你的业务代码的需要绘制的文本坐标

但是需要知道的是在 GlyphRun 里面传入的是 BaseLine 而不是 Location 的值,相互转换的逻辑需要根据 FontFamily 的 Baseline 的值才能计算,代码如下

        /// <summary>
        /// 获取指定字体的baseline
        /// </summary>
        /// <param name="fontFamily"></param>
        /// <param name="fontRenderingEmSize"></param>
        /// <returns></returns>
        public static double GetBaseline(this FontFamily fontFamily, double fontRenderingEmSize)
        {
            var baseline = fontFamily.Baseline;

            var renderingEmSize = fontRenderingEmSize;

            var value = baseline * renderingEmSize;
            return value;
        }

        location = new Point(location.X, location.Y + fontFamily.GetBaseline(fontSize));

以上代码是将 GetBaseline 的返回值给到 location 的 Y 值,这适合用在水平布局文本上。如果是垂直排版的文本,自然就需要放在水平方向。请根据你的业务代码修改以上逻辑

语言文化

如果需要支持特殊的文本内容,就需要设置特别的语言文化,默认使用 IetfLanguageTag 即可

                XmlLanguage defaultXmlLanguage =
                    XmlLanguage.GetLanguage(CultureInfo.CurrentUICulture.IetfLanguageTag);

DPI

在新的 GlyphRun 的构造里面要求传入 DPI 的值用于清晰化显示,在旧版本的,如 .NET Framework 4.5 版本是不需要的

官方推荐的获取 DPI 的方法是根据当前文本将要渲染出来的控件获取控件的 DPI 的值,通过此方法可以支持多屏幕不同 DPI 的感知。本文提供的方法是获取主窗口,因为本文的例子是在主窗口绘制文本

    var pixelsPerDip = (float) VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip;

绘制文本

在准备完成之后,即可创建 GlyphRun 用来绘制

  var glyphRun = new GlyphRun
  (
      glyphTypeface,
      bidiLevel: 0,
      isSideways: false,
      renderingEmSize: fontSize,
      pixelsPerDip: pixelsPerDip,   // 只有在高版本的 .NET 才有此参数
      glyphIndices: glyphIndices,
      baselineOrigin: location,     // 设置文本的偏移量
      advanceWidths: advanceWidths, // 设置每个字符的字宽,也就是字号
      glyphOffsets: null,           // 设置每个字符的偏移量,可以为空
      characters: text.ToCharArray(),
      deviceFontName: null,
      clusterMap: null,
      caretStops: null,
      language: defaultXmlLanguage
  );

  drawingContext.DrawGlyphRun(Brushes.White, glyphRun);

请将 Brushes.White 替换为字体前景色的画刷

以上即可完成文本的绘制,这是一个底层的方式,看起来也很简单

创建成本

创建一个 GlyphRun 对象的成本有多高?是否需要申请很多资源?其实创建时仅仅只是创建了一个 CLR 对象而已,里面也只有很多的字段,成本非常低。在创建时不会用到任何非托管的资源,只是一个对象而已

只有在被绘制的时候,才会申请 DirectWrite 的相关资源

获取几何对象

通过 BuildGeometry 方法可以从 GlyphRun 对象创建几何对象,如下面代码

var geometry = glyphRun.BuildGeometry();

获取几何对象可以用此几何对象做特殊的逻辑,如文字描边等

需要小心的是调用 BuildGeometry 方法是有一定成本的,底层将需要从文本渲染为 Geometry 对象,中间需要经过 MIL 层。建议是能复用就复用,而不要每次都创建

但是在复用时,需要了解的是,不同的字号,创建出来的 Geometry 对象,不一定是相同的,这是为了清晰化显示的考虑。如字体比较小的时候,将会删减一些笔画等

获取文本的渲染尺寸

可以通过如下代码获取文本的渲染尺寸,也可以通过如下方法获取单个字符的渲染尺寸

  var computeInkBoundingBox = glyphRun.ComputeInkBoundingBox();
  var matrix = new Matrix();
  matrix.Translate(location.X, location.Y);
  computeInkBoundingBox.Transform(matrix);
  //相对于run.BuildGeometry().Bounds方法,run.ComputeInkBoundingBox()会多出一个厚度为1的框框,所以要减去
  if (computeInkBoundingBox.Width >= 2 && computeInkBoundingBox.Height >= 2)
  {
      computeInkBoundingBox.Inflate(-1, -1);
  }

以上的 computeInkBoundingBox 就是文本的绘制的尺寸,相对的坐标是文本的左上角,因此需要通过 location 叠加变换才能让此矩形和文本渲染重叠

     drawingContext.DrawRectangle(Brushes.Blue, null, computeInkBoundingBox);

文本的渲染尺寸也就是文本的字墨尺寸,此概念是文本排版概念

获取文本的文字布局尺寸

可以通过以上代码的 width 获取文本的字面的布局宽度,而布局高度则需要根据 BaseLine 等属性获取,代码如下

        /// <summary>
        /// 获取<see cref="GlyphRun"/>的Size
        /// </summary>
        /// <param name="run"></param>
        /// <param name="lineSpacing"></param>
        /// <returns></returns>
        public static Size GetSize(this GlyphRun run, double lineSpacing)
        {
            var renderingEmSize = run.FontRenderingEmSize;
            var height = lineSpacing * renderingEmSize;
            double width = 0;
            foreach (var index in run.GlyphIndices)
            {
                width += run.GlyphTypeface.AdvanceWidths[index];
            }

            width = width * renderingEmSize;
            return new Size(width, height);
        }

调用方法是 var glyphSize = glyphRun.GetSize(fontFamily.LineSpacing); 即可拿到文字的布局尺寸

字体回滚策略

字体的回滚策略可以比较佛系,毕竟是找不到字体了,此时就是从已安装的字体找到一个还能用的字体代替上去

在 WPF 源代码里面,可以看到底层的 Fallback 字体是 #GLOBAL USER INTERFACE 这个特殊的字体,为了保持和 TextBlock 差不多的逻辑,可以使用如下方法作为字体回滚

    /// <summary>
    /// 用于回滚的字体对象<see cref="FontFamily"/>
    /// </summary>
    public class FallBackFontFamily
    {
        private const string FallBackFontFamilyName = "#GLOBAL USER INTERFACE";
        private FontFamily FallBack { get; } = new FontFamily(FallBackFontFamilyName);

        private FallBackFontFamily(CultureInfo culture)
        {
            FontFamilyItems = FallBack.FamilyMaps
                .Where(map => map.Language == null || map.Language.MatchCulture(culture))
                .Select(map => new FontFamilyMapItem(map)).ToList();
        }

        private IEnumerable<FontFamilyMapItem> FontFamilyItems { get; }

        /// <summary>
        /// 获取<see cref="FallBackFontFamily"/>对象的单例
        /// </summary>
        public static FallBackFontFamily Instance => FallBackFontFamilyLazy.Value;

        private static readonly Lazy<FallBackFontFamily> FallBackFontFamilyLazy =
            new Lazy<FallBackFontFamily>(() => new FallBackFontFamily(CultureInfo.CurrentCulture));

        /// <summary>
        /// 尝试获取fallback的字体名称
        /// </summary>
        /// <param name="unicodeChar"></param>
        /// <param name="familyName"></param>
        /// <returns></returns>
        public bool TryGetFallBackFontFamily(char unicodeChar, out string familyName)
        {
            var mapItem = FontFamilyItems.FirstOrDefault(item => item.InRange(unicodeChar));
            familyName = null;

            if (mapItem !=null)
            {
                familyName = mapItem.Target;
                return true;
            }
            return false;
        }
    }

以上字体也就是 FontFamily.FontFamilyGlobalUI 属性的值,请看以下的 WPF 框架源代码

        internal const string GlobalUI = "#GLOBAL USER INTERFACE";

        internal static FontFamily FontFamilyGlobalUI = new FontFamily(GlobalUI);

默认在 WPF 的 Typeface 创建就包含了此逻辑,请看 Typeface 的源代码

        public Typeface(
            FontFamily      fontFamily,
            FontStyle       style,
            FontWeight      weight,
            FontStretch     stretch
            )
            : this(
                fontFamily,
                style,
                weight,
                stretch,
                FontFamily.FontFamilyGlobalUI
                )
        {}

因此以上的回滚代码的意义其实不大,不过可以通过以上代码添加自己期望的字体回滚列表,如自己在应用程序里面带了特殊的字体,期望在找不到字体的时候使用自己的字体,就可以使用上面提供的回滚策略代码,使用方法如下

            if (typeface.TryGetGlyphTypeface(out var glyph))
            {
                // 忽略代码
            }
            else if (FallBackFontFamily.Instance.TryGetFallBackFontFamily(unicodeChar, out var familyName))
            {
            	// 上面代码的 unicodeChar 就是传入的文本的字符
            	// 通过上面代码可以拿到回滚的字体是否包含此字符的定义
            }
            else
            {
                // 没有可以支持此字符的字体,那就看业务逻辑的处理啦
            }

代码

例子

本文所有代码放在 githubgitee 欢迎访问

可以通过如下方式获取本文的源代码,先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 581ea123df0d1067ec1ed3527e8b85edb2fd082e

以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

获取代码之后,进入 NiwejabainelFehargaye 文件夹

轻文本

实现一个和 TextBox 差很多的单行轻文本最简代码如下

    class Foo : UIElement
    {
        public string Text { set; get; } = string.Empty;

        protected override void OnRender(DrawingContext drawingContext)
        {
            var fontFamily = new FontFamily("微软雅黑");

            var fontSize = 15;
            var y = 0;
            drawingContext.PushOpacity(0.3);
            foreach (var typeface in fontFamily.GetTypefaces().Skip(1).Take(1))
            {
                double offset = 3;

                var baseLine = fontFamily.GetBaseline(fontSize);

                if (typeface.TryGetGlyphTypeface(out var glyphTypeface))
                {
                    foreach (var c in Text)
                    {
                        if (glyphTypeface.CharacterToGlyphMap.TryGetValue(c, out var glyphIndex))
                        {
                            // 在排版,不适合将每个字符的宽度独立进行计算。有很多字符是需要重叠布局的
                            var width = glyphTypeface.AdvanceWidths[glyphIndex] * fontSize;
                            width = GlyphExtension.RefineValue(width);

#pragma warning disable 618 // 忽略调用废弃构造函数
                            var glyphRun = new GlyphRun(
#pragma warning restore 618
                                glyphTypeface,
                                0,
                                false,
                                fontSize,
                                new[] { glyphIndex },
                                new Point(offset, baseLine + y),
                                new[] { width },
                                DefaultGlyphOffsetArray,
                                new char[] { c },
                                null,
                                null,
                                null, DefaultXmlLanguage);

                            drawingContext.DrawLine(new Pen(Brushes.Black, 2), new Point(offset, y), new Point(offset + width, y));

                            drawingContext.DrawGlyphRun(Brushes.Coral, glyphRun);

                            var glyphSize = glyphRun.GetSize(fontFamily.LineSpacing);

                            drawingContext.DrawRectangle(null, new Pen(Brushes.Black, 2), new Rect(new Point(offset, y), glyphSize));

                            // 布局的字符宽度
                            offset += width;
                        }
                    }
                }

                y += fontSize;
            }
            drawingContext.Pop();
        }

        private static readonly Point[] DefaultGlyphOffsetArray = new Point[] { new Point() };

        private static readonly XmlLanguage DefaultXmlLanguage =
            XmlLanguage.GetLanguage(CultureInfo.CurrentUICulture.IetfLanguageTag);
    }

以上代码只是单个字符进行绘制,用于了解每个字符对应的布局值,也就是如上的 DrawRectangle 绘制的内容

上面代码的 GetBaseline 等都是辅助方法,可以从本文上面找到代码,也可以通过如下方式获取代码

先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin fe704afdd32edb05005b1f35bcc87dc59c900040

以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

获取代码之后,进入 NiwejabainelFehargaye 文件夹

博客园博客只做备份,博客发布就不再更新,如果想看最新博客,请到 https://blog.lindexi.com/


本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含链接:http://blog.csdn.net/lindexi_gd ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我[联系](mailto:lindexi_gd@163.com)。

dotnet 读 WPF 源代码笔记 简单聊聊文本布局换行逻辑

在 WPF 里面,带了基础的文本库功能,如 TextBlock 等。文本库排版的重点是在文本的分行逻辑,也就是换行逻辑,如何计算当前的文本字符串到达哪个字符就需要换到下一行的逻辑就是文本布局的重点模块。本文来简单聊聊 WPF 的文本布局逻辑

先写给不想阅读细节的大佬们了解 WPF 文本模块的布局逻辑: 文本的排版和渲染是分开的两个模块。 文本逻辑在排版里面,核心都会调用到 TextFormatterImp 里面,在这里将会通过 SimpleTextLine 尝试进行布局排版,在 SimpleTextLine 里面将会判断当前的文本字符串是否刚好一行能放下,如果可以放下,那么就使用当行方式显示。这是最为简单的,实现逻辑就是通过 Typeface 的 GlyphMetrics 的 AdvanceWidth 列表获取每个字符的排版宽度,将排版宽度乘以渲染字号即可获取每个字符占用的渲染布局宽度,将所有字符的占用布局框架之和 与可用行宽度进行比较,如果小于行宽度则进行单行布局

如果超过单行布局的能力,则进入 TextMetrics 的 FullTextLine 方法。此方法将使用到没有开源的 PresentationNative.dll 提供的 LoCreateLine 方法进行文本排版逻辑。在 PresentationNative 里面将会调用系统多语言处理 (也许是叫 TFS 但如果叫错了还请大佬们教教我)进行文本的复杂排版行为,包括进行合写字如蒙文藏文的排版逻辑。这部分复杂排版是需要系统层多语言的支持的,包含了复杂的语言文化规则

下面就是细节部分的逻辑

在 TextBlock 等的底层也是用到了 TextFormatterImp 的文本排版功能进行排版,然后进行渲染。渲染部分本文就不聊了

如在 TextBlock 的 OnRender 或 MeasureOverride 方法里面,都会调用 CreateLine 方法创建 Line 对象,接着通过 Line 对象的 Format 方法层层调用到 TextFormatterImp 里面,大概代码如下

    [ContentProperty("Inlines")]
    [Localizability(LocalizationCategory.Text)]
    public class TextBlock : FrameworkElement, IContentHost, IAddChildInternal, IServiceProvider
    {
        protected sealed override Size MeasureOverride(Size constraint)
        {
        	// 忽略逻辑
                // Create and format lines until end of paragraph is reached.
                // Since we are disposing line object, it can be reused to format following lines.
                Line line = CreateLine(lineProperties);
                while (!endOfParagraph)
                {
                    using(line)
                    {
                        // Format line. Set showParagraphEllipsis flag to false because we do not know whether or not the line will have
                        // paragraph ellipsis at this time. Since TextBlock is auto-sized we do not know the RenderSize until we finish Measure
                        line.Format(dcp, contentSize.Width, GetLineProperties(dcp == 0, lineProperties), textLineBreakIn, _textBlockCache._textRunCache, /*Show paragraph ellipsis*/ false);

                        // 忽略其他逻辑
                    }
                }
        }
    }

    // ----------------------------------------------------------------------
    // Text line formatter.
    // ----------------------------------------------------------------------
    internal abstract class Line : TextSource, IDisposable
    {
        // ------------------------------------------------------------------
        // Create and format text line.
        //
        //      lineStartIndex - index of the first character in the line
        //      width - wrapping width of the line
        //      lineProperties - properties of the line
        //      textRunCache - run cache used by text formatter
        //      showParagraphEllipsis - true if paragraph ellipsis is shown 
        //                              at the end of the line
        // ------------------------------------------------------------------
        internal void Format(int dcp, double width, TextParagraphProperties lineProperties, TextLineBreak textLineBreak, TextRunCache textRunCache, bool showParagraphEllipsis)
        {
        	// 忽略代码
            _line = _owner.TextFormatter.FormatLine(this, dcp, width, lineProperties, textLineBreak, textRunCache);
        }
    }

    internal sealed class TextFormatterImp : TextFormatter
    {
        public override TextLine FormatLine(
            TextSource                  textSource,
            int                         firstCharIndex,
            double                      paragraphWidth,
            TextParagraphProperties     paragraphProperties,
            TextLineBreak               previousLineBreak,
            TextRunCache                textRunCache
            )
        {
            return FormatLineInternal(
                textSource,
                firstCharIndex,
                0,   // lineLength
                paragraphWidth,
                paragraphProperties,
                previousLineBreak,
                textRunCache
                );
        }

        /// <summary>
        /// Format and produce a text line either with or without previously known
        /// line break point.
        /// </summary>
        private TextLine FormatLineInternal(
            TextSource                  textSource,
            int                         firstCharIndex,
            int                         lineLength,
            double                      paragraphWidth,
            TextParagraphProperties     paragraphProperties,
            TextLineBreak               previousLineBreak,
            TextRunCache                textRunCache
            )
        {
        	// 忽略代码
        }
    }

通过上面代码可以看到在 WPF 框架,核心的文本排版逻辑是在 FormatLineInternal 方法里面

在 FormatLineInternal 里面将会先使用 SimpleTextLine 尝试作为一行进行布局,假设文本一行能放下,也就不需要复杂的排版逻辑,可以提升很大的性能。如果一行放不下,那就通过 TextMetrics 的 FullTextLine 进行复杂的排版逻辑

        /// <summary>
        /// Format and produce a text line either with or without previously known
        /// line break point.
        /// </summary>
        private TextLine FormatLineInternal(
            TextSource                  textSource,
            int                         firstCharIndex,
            int                         lineLength,
            double                      paragraphWidth,
            TextParagraphProperties     paragraphProperties,
            TextLineBreak               previousLineBreak,
            TextRunCache                textRunCache
            )
        {
            // prepare formatting settings
            FormatSettings settings = PrepareFormatSettings(/*忽略传入参数*/);

            TextLine textLine = null;

            if ( /*可以进行单行排版的文本*/ )
            {
                // simple text line.
                textLine = SimpleTextLine.Create(/*忽略传入参数*/);
            }

            if (textLine == null)
            {
                // content is complex, creating complex line
                textLine = new TextMetrics.FullTextLine(/*忽略传入参数*/);
            }

            return textLine;
        }

在文本进行复杂排版,就需要用到没有开源的 PresentationNative.dll 提供的和系统层的多语言对接的功能。本文就仅来了解 SimpleTextLine 的实现

在 SimpleTextLine 里面,实现的逻辑是将当前的文本在传入的宽度内进行一行布局,如果能在一行进行布局,那就返回值,否则返回空

文本里面有段落和行和 TextRun 的三个概念,在开始了解 WPF 的代码之前,咱先定义这三个不同的概念。一个文本里面包含有多段,默认采用换行符作为分段。也就是说在一段里面是不会存在多个换行符的。一个段落里面将会因为文本框的宽度限制而存在多行。一行文本里面,将会因为文本属性的不同将文本分为多个 TextRun 对象

也就是最简单的文本就是一个字符,一个字符是一个 TextRun 放在一行里面,这一行放在一段里面

在 SimpleTextLine 的 Create 方法将层层调用进入到 CreateSimpleTextRun 方法里面,也就是说在一行里面将会一个个 TextRun 进行创建,创建的时候同时判断当前的文本剩余宽度是否足够

在 CreateSimpleTextRun 方法里面将会调用 Typeface.CheckFastPathNominalGlyphs 方法进行快速的创建,这个方法是没有开放出来给开发者使用的,调用这个方法可以绕过很多判断逻辑,性能很高

在 CheckFastPathNominalGlyphs 方法里面,将会使用 Typeface 的 TypefaceMetrics 属性作为 GlyphTypeface 类型的对象。此对象依然可以使用到没有开放给开发者使用的 GetGlyphMetricsOptimized 方法。如方法命名可以看到,这是一个有很多性能优化的方法。此方法将拿到文本字符串对应的 glyphIndices 和 glyphMetrics 两个数组,分别表示的是字符对应在 Glyph 的序号以及 Glyph 的信息,代码如下

            ushort[] glyphIndices = BufferCache.GetUShorts(charBufferRange.Length);
            MS.Internal.Text.TextInterface.GlyphMetrics[] glyphMetrics = ignoreWidths ? null : BufferCache.GetGlyphMetrics(charBufferRange.Length);

            glyphTypeface.GetGlyphMetricsOptimized(charBufferRange, 
                                                   emSize,
                                                   pixelsPerDip,
                                                   glyphIndices,
                                                   glyphMetrics,
                                                   textFormattingMode,
                                                   isSideways
                                                   );

以上的 glyphIndices 变量和 glyphMetrics 都是从 BufferCache 获取的,大部分排版逻辑都需要额外申请内存。此方法对比开放给开发者使用的版本的优势在于可以批量获取,给开发者使用的版本只能一个个字符获取,性能上远远不如调用此方法获取。更多关于开发者使用文本排版,请看 WPF 简单聊聊如何使用 DrawGlyphRun 绘制文本

在拿到以上两个变量之后,即可进行计算每个字符的排版宽度,此计算方法将会让计算出来的值和实际渲染尺寸有一些误差。然而此排版方法只是计算是否在一行里面足够放下文本,有一些误差不会影响到结果。因为如果能一行进行排版,那就走以上的方法,是高性能模式。如果一行不能排版,那就通过系统层的语言文化进行排版,可以符合业务的需求

大概的计算逻辑如下

            //
            // This block will advance until one of:
            // 1. The end of the charBufferRange is reached
            // 2. The charFlags have some of the charFlagsMask values
            // 3. Glyph index is 0 (unless symbol font)
            // 4. totalWidth > widthMax
            //
            
            while(
                    i < charBufferRange.Length // charBufferRange 就是文本的 Char 列表
                &&  (ignoreWidths || totalWidth <= widthMax) // totalWidth 是当前文本已排版的字符的宽度之和
                &&  ((charFlags & charFlagsMask) == 0)
                &&  (glyph != 0 || symbolTypeface) // 在 glyph 是 0 时,表示的是当前没有字符,相当于 \\0 字符。但是符号字体不在此范围
                )
            {
                char ch = charBufferRange[i++];
                if (ch == TextStore.CharLineFeed || ch == TextStore.CharCarriageReturn || (breakOnTabs && ch == TextStore.CharTab))
                {
                    --i;
                    break;
                }
                else
                {
                    int charClass = (int)Classification.GetUnicodeClassUTF16(ch);
                    charFlags = Classification.CharAttributeOf(charClass).Flags;
                    charFastTextCheck &= charFlags;

                    glyph = glyphIndices[i-1];
                    if (!ignoreWidths)
                    {
                        totalWidth += TextFormatterImp.RoundDip(glyphMetrics[i - 1].AdvanceWidth * designToEm, pixelsPerDip, textFormattingMode) * scalingFactor;
                    }
                }
            }

上面逻辑核心就是 totalWidth <= widthMax 判断,判断当前布局的字符宽度之和是否小于可以使用的宽度。如果大于那就表示这一行放不下此字符串

计算单个字符占用的宽度使用的是 glyphMetrics[i - 1].AdvanceWidth * designToEm 进行计算,而 RoundDip 只是加上 Dpi 的辅助计算而已。以上的 AdvanceWidth 将是字符的宽度比例,可以乘以 designToEm 设计时的字号计算出 WPF 单位的宽度

也就是文本的单行排版里面就是通过各个字符的设计时宽度计算是否可以在一行排列,如果可以那就采用此优化,不再进行复杂文本排版,进入渲染逻辑

更多渲染相关博客请看 渲染相关

博客园博客只做备份,博客发布就不再更新,如果想看最新博客,请到 https://blog.lindexi.com/


本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含链接:http://blog.csdn.net/lindexi_gd ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我[联系](mailto:lindexi_gd@163.com)。

以上是关于WPF 简单聊聊如何使用 DrawGlyphRun 绘制文本的主要内容,如果未能解决你的问题,请参考以下文章

dotnet 读 WPF 源代码 聊聊 DispatcherTimer 的实现

dotnet 读 WPF 源代码 聊聊 DispatcherTimer 的实现

简单聊聊VisualStudio的断点调试

WPF 如何实现简单放大镜

如何在WPF控件上应用简单的褪色透明效果?

简单聊聊SOA和微服务