在 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 = https://www.cnblogs.com/lindexi/archive/2021/10/09/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
{
// 沒有可以支持此字符的字體,那就看業務邏輯的處理啦
}
代碼
例子
本文所有代碼放在 github 和 gitee 歡迎訪問
可以通過如下方式獲取本文的源代碼,先創建一個空檔案夾,接著使用命令列 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:[email protected]),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/307089.html
標籤:.NET技术
上一篇:C# Winform 進度條
下一篇:C# 玩轉MongoDB(二)
