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
{
// 沒有可以支援此字元的字型,那就看業務邏輯的處理啦
}
程式碼
例子
可以通過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 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])。