GMarkupLabel - 用于显示 XML 格式文本的 C# Windows Forms 控件






4.95/5 (77投票s)
一个框架和一个 WinForms 控件,使 .NET 2.0 用户能够可视化 XML 格式的富文本。
引言
我相信几乎每个被分配显示文本任务的 .NET 开发人员都曾经历过精确文本测量和绘制的困难。System.Drawing.Graphics
的 MeasureString
和 DrawString
方法有几个限制,最糟糕的是对所需文本的测量和定位不够准确。自 .NET 2.0 以来,微软引入了 TextRenderer
类,它提供了更精确的文本操作,但也有其缺点——可能只能以纯色渲染,不支持透明度等。上述任何方法都无法让您拥有混合字体文本、扩展段落布局(如两端对齐)或额外的文本效果(如描边或阴影)。有鉴于此,我认为创建一个自定义文本渲染解决方案将是一个不错的练习,它既能解决标准问题,又能添加一些不错的功能。凭借在 GDI+ 和 Windows Forms 方面扎实的经验(我曾是一名 GUI 开发人员,工作了四年多),我大约一个月前启动了这个项目,并且几乎每天都会投入一两个小时。其中有许多方面可能有用也可能没用,也有许多部分我认为很棘手,所以我真心希望这篇文章能对许多人有用。
对本项目感兴趣的人
本项目主要针对 Windows Forms 平台。虽然理论上它可以用于为 ASP.NET 引擎创建离屏图形,但我怀疑 Web 开发人员会依赖此解决方案,因为他们主要的可视化表面是 Web 浏览器(及其所有 HTML 格式化功能)。所包含的控件提供了基本的文本格式化功能,可以作为笨重的 IE ActiveX 控件的轻量级替代品。
Using the Code
此解决方案有两个主要方面
- 一个抽象实现——
GTextView
——它完全独立于特定的平台(Windows Forms vs. ASP.NET)。您可以使用这个抽象来在任何Graphics
表面上显示文本。例如,您可以扩展一个ListBox
,其项目将支持富文本。 - 一个 Windows Forms 控件——
GMarkupLabel
——它组合了上述类。这个控件所做的只是简单地将绘图和鼠标事件委托给内部文本视图。
下面是我们如何创建和使用 GTextView
实例
m_TextView = new GTextView();
m_TextView.Invalidated += OnTextViewInvalidated;
m_TextView.PropertyChanged += new GEventHandler(OnTextViewPropertyChanged);
m_TextView.AnchorClicked += new GEventHandler(OnTextViewAnchorClicked);
接下来,我们如何绘制视图
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
Graphics g = e.Graphics;
Globals.GdiPlusDevice.Attach(g);
GPaintContext context = new GPaintContext(Globals.GdiPlusDevice);
m_TextView.Paint(context);
Globals.GdiPlusDevice.Detach();
}
由于 GMarkupLabel
扩展了 Windows Forms 控件,您可以简单地将其添加到工具箱并在设计时实例化它。它的 Text
属性将更新内部文本视图。
支持的标签和属性
下表列出了支持的标签及其对应的属性
元素 | Tag | 属性 | 用法 |
Anchor | <a> |
href |
<a href="myhref"> |
加粗 | <b> |
- | <b> |
字体 | <font> |
face , size , color |
<font face="Tahoma" size="10" color="red"> |
斜体 | <i> |
- | <i> |
换行 | <br> |
- | <br> |
段落 | <p> |
padding , align , wrap |
<p align="justify" padding="5" wrap="none"> |
阴影 | <shadow> |
color , offset , style , strength |
<shadow color="100,0,0,0" offset="2,2" style="blurred" strength="1,1"> |
描边 | <stroke> |
color , width |
<stroke color="gray" width="2"> |
下划线 | <u> |
- | <u> |
空白 | <whitespace> |
length |
<whitespace length="10"> |
请注意,您可能会在源代码中发现其他一些已声明但尚未实现的属性。
简要模型概述
在 GMarkupLabel
背后是一个抽象层次结构框架,它形成了一个轻量级的 DOM 树。模型层和视图层之间有着严格的区别。System.Xml.XmlDocument
类用于将提供的原始文本解析为 XML 树,并在已解析的文本之上构建一个模型 DOM。模型结构由 GTextView
实例用于填充其子视觉元素(如 GParagraph
、GTextBlock
)和布局原子(如 GTextLine
、GWord
)。我不会涵盖框架的每个方面,而是将重点放在一些有趣和棘手的部分。
GTextView 内部
概述
GTextView
类是一个抽象实现,它完全独立于任何特定平台(例如 Windows Forms 或 ASP.NET),并提供解析 GTextDocument
并将其组织成段落、文本块、行和单词的核心逻辑。解析后,内部元素结构如下所示:
GTextView
GParagraph
GTextBlock
GTextLine
GWord
GWord
GWord
视图有三个不同的方面——如何解析它,如何布局它,以及如何在屏幕上可视化它。主要方法是将整个文本(不包括标签)分成单独的单词,并将它们组织成复合结构。布局树的顶部是段落,它由一个或多个文本块组成(每个文本块表示段落中的换行符)。文本块知道它包含多少个单词,并将其布局成行,每行包含一个或多个单词,并计算每个单词的位置。段落、文本块和单词只解析一次,而行是动态构建的,每次布局请求时都会重新构建。到目前为止,我们已经将单词布局在屏幕上。每个单词都关联着特定的字符串、度量成员和一个样式对象,该对象包含视图在屏幕上显示单词所需的所有信息。
解析
一旦我们有了 System.Xml.XmlDocument
,我们就从这个文档创建我们的 DOM 树。每个 XML 元素都有其轻量级的 DOM 等效项,并且每个 XML 属性都映射到该 DOM 元素的属性。接下来,我们用段落、文本块和文本行填充我们的文本视图。下面是 GTextDocumentParser
的代码片段
GTextStyle currentStyle = m_Styles.Peek();
GTextStyle newStyle = null;
bool newParagraph = false;
bool newAnchor = false;
switch (element.TagName)
{
//anchor element
case GTextDocument.AnchorNodeName:
//open anchor and create new style for all anchor words
newStyle = OpenAnchor((GAnchorElement)element, currentStyle);
newAnchor = true;
break;
//paragraph element
case GTextDocument.ParagraphNodeName:
newParagraph = true;
break;
//bold element
case GTextDocument.FontNodeName:
//we need a new style to reflect the "Font" element
newStyle = new GTextStyle(currentStyle);
GFontElement fontElement = (GFontElement)element;
newStyle.m_Font = NewFont(fontElement, currentStyle.m_Font);
if (fontElement.ContainsLocalProperty(GFontElement.ColorPropertyKey))
{
newStyle.m_Brush = new GSolidBrush(fontElement.Color);
}
newStyle.m_ScaleX = fontElement.ScaleX;
newStyle.m_ScaleY = fontElement.ScaleY;
break;
//outline
case GTextDocument.LineBreakNodeName:
BreakLine();
break;
//whitespace
case GTextDocument.TextNodeName:
string text = ((GStringElement)element).Text;
ProcessText(text);
break;
}
//push the new style (if any)
if (newStyle != null)
{
m_Styles.Push(newStyle);
}
if (newParagraph)
{
PushParagraph(element);
}
ProcessCollection(element.m_Children);
//pop previuosly pushed style
if (newStyle != null)
{
m_Styles.Pop();
}
if (newParagraph)
{
PopParagraph();
}
if (newAnchor)
{
CloseAnchor();
}
这里我们有两个 Stack
——一个用于 Push
和 Pop
段落(支持嵌套段落),另一个用于跟踪每个单词的 GTextStyle
。在递归调用之前,我们检查是否需要推入新段落和样式,并在递归完成后,我们恢复 Stack
的状态(如果已修改)。
测量
为了提供精确的布局,我们需要一种方法来确定一个单词将占用多少像素。正如引言中提到的,简单的 Graphics.MeasureString
调用无法完成这项工作——它总是会在返回值上增加几个或更多的额外像素。我这里使用的方法是:首先将单词渲染到离屏位图上,然后对该位图执行一些逐像素操作,以确定渲染的边缘填充,然后计算所谓的“黑盒”(完全包围显示文本的最小矩形)
这是 MeasureWord
方法中的一个片段
//create new metric object to pass to the measured word
GWordMetric metric = new GWordMetric();
Font nativeFont = GetNativeFont(word.m_Style.m_Font);
//get word size
SizeF textSize = m_Graphics.MeasureString(word.m_Text, nativeFont,
PointF.Empty, StringFormat.GenericDefault);
Size sz = new Size((int)(textSize.Width + .5F) + clearTypeOffset,
(int)textSize.Height);
metric.Size = sz;
//measure the internal padding (used for providing pixel-perfect layout)
GBitmap bmp = new GBitmap(sz.Width, sz.Height);
Brush nativeBrush = GetNativeBrush(word.m_Style.m_Brush);
Padding padding = bmp.GetTextPadding(word.m_Text, nativeFont,
m_Graphics.TextRenderingHint);
metric.Padding = padding;
metric.BlackBox = new SizeF(sz.Width - padding.Horizontal,
textSize.Height - padding.Vertical);
//clean-up bitmap resources
bmp.Dispose();
//assign the metric to the word
word.m_Metric = metric;
空白符是一种特殊情况。我没有测量每个空白符的大小,而是在每个 GFontDeviceMetric
中简单地保留一个名为 WhitespaceWidth
的值。这确保了我们只在每个字体设备度量初始化期间测量一次空白符的宽度。
计算填充的算法非常简单——我们从每个边缘开始,遍历位图的每个像素,寻找与背景颜色不同的像素。以下代码片段演示了我们如何计算左边缘的填充
//left padding - examine columns, starting from left one
int left = -1;
int match = Color.Magenta.ToArgb();
for (int x = 0; x < m_Width; x++)
{
for (int y = 0; y < m_Height; y++)
{
if (GetPixel(x, y).ToArgb() != match)
{
left = x;
break;
}
}
if (left != -1)
{
break;
}
}
现在,我要感谢 这篇文章 的作者。GBitmap
使用了 q123456789 的 FastBitmap
中的一些方法和技巧。例如,通过不安全代码直接操作位图数据可以大幅加快像素信息检索。使用 .NET 的 Bitmap.GetPixel
速度慢如蜗牛,在这种情况下根本无法工作,因为每个单词都在屏幕外绘制,并且进行了大量的 GetPixel
调用。
布局
到目前为止,我们已经测量了单词的精确大小。现在,我们需要计算视图中每个单词的位置。接下来是段落、文本块和文本行。让我们看看 GTextBlock
类以及它如何将单词组织成行(BuildLines
方法中的一个片段)
GTextLine currLine = new GTextLine();
m_Lines.AddFirst(currLine);
float lineWidth = 0;
float wordWidth = 0;
LinkedListNode currNode = m_Words.First;
GWord currWord;
while (currNode != null)
{
currWord = currNode.Value;
wordWidth = currWord.m_Metric.Size.Width -
currWord.m_Metric.Padding.Horizontal;
lineWidth += wordWidth;
//check whether we need a line break
if (lineWidth > m_MaxWidth && context.Wrap ==
TextWrap.Word && currLine.m_Words.Count > 0)
{
currLine = new GTextLine();
m_Lines.AddLast(currLine);
lineWidth = wordWidth;
}
currLine.AddWord(currWord);
currNode = currNode.Next;
if (currNode == null)
{
currLine.m_IsLastLine = true;
}
}
一旦我们构建了所有行,我们需要计算每个单词的位置。这个计算取决于几个因素:填充、对齐和混合字体基线。这里有趣的部分是如何确定字体基线。考虑到我们可能希望在同一文本行中显示不同字体的单词,我们应该以这样一种方式对齐它们,使它们都位于一条逻辑线上,这条线将每个单词的 Ascent 和 Descent 分开。我这里使用的方法是找到具有最高 Ascent 的单词,然后将每个单词与这个值对齐。
以下代码演示了我们如何计算字体基线
LinkedListNode currNode = m_Words.First;
GWord currWord;
while (currNode != null)
{
currWord = currNode.Value;
currNode = currNode.Next;
m_WordsWidth += currWord.m_Metric.BlackBox.Width;
m_WordsHeight = Math.Max(m_WordsHeight, currWord.m_Metric.Size.Height);
m_Baseline = Math.Max(m_Baseline, currWord.m_FontMetric.TextMetric.tmAscent);
}
此代码片段显示了我们如何根据已计算的字体基线确定每个单词的 Y 坐标
LinkedListNode<gword > firstNode = m_Words.First;
LinkedListNode<gword > node = firstNode;
GWord currWord = node.Value;
while (node != null)
{
currWord = node.Value;
node = node.Next;
//subtract the render padding on the left
x -= currWord.m_Metric.Padding.Left;
//the y coordinate of the word is baseline minus word's EmHeight
y = context.Y + (m_Baseline - currWord.m_FontMetric.TextMetric.tmAscent);
//remember the calculated location
currWord.m_Location = new PointF(x, y);
//advance the x value
x += currWord.m_Metric.Size.Width - currWord.m_Metric.Padding.Right;
x += m_SpaceToDistribute;
}
一个值得注意的有趣之处在于,我使用的是 GDI TEXTMETRIC
及其 tmAscent
值。起初,我尝试使用 FontFamily.GetEmHeight
方法返回的 EmHeight
值。问题是这个值是一个单精度浮点数,有时(很可能是由于渲染引擎的内部舍入),单词没有精确对齐——出现了一个像素的误差。另一方面,GDI 和 TEXTMETRIC
返回一个整数值,已经应用了舍入,这被证明要精确得多。
绘制
绘图是最简单的部分。一旦我们排布好所有单词,我们只需要简单地枚举并绘制它们。我在这里插入一段,更详细地解释一下底层框架中使用的绘图逻辑。我没有直接使用 GDI+ 对象,如 Pen 和 Brush(它们都与句柄关联,无法在设计时编辑),而是使用了它们的抽象对应物,如 GBrush
和 GPen
。同时,我还有一个抽象的 GDeviceContext
接口层次结构。目前,唯一的具体实现是 GGdiPlusDeviceContext
,它与 System.Drawing.Graphics
实例关联。其理念是尽可能地将绘图逻辑与特定平台分离。是 Device 实现知道如何将 GDrawingAttribute
映射到本机设备绘图对象——例如,抽象的 GSolidBrush
被映射到具体的 System.Drawing.SolidBrush
。GDI+ Device 将本机绘图基元缓存到一个 Hashtable
中,其中每个键是抽象绘图属性,值是其本机对应物。这显著加快了渲染逻辑。
让我们看看一个单词是如何绘制的
ValidateGraphics();
PointF location = word.m_Location;
//get native drawing primitives
Font nativeFont = GetNativeFont(word.m_Style.m_Font);
Brush nativeBrush = GetNativeBrush(word.m_Style.m_Brush);
//check whether shadow is needed
if (word.m_Style.m_Shadow != null)
{
RectangleF bounds = new RectangleF(location, word.m_Metric.Size);
PaintWordShadow(word, bounds, nativeFont);
}
//we have a stroked text, paint it using a graphics path
if (word.m_Style.m_Pen != null)
{
m_Graphics.DrawImage(word.m_PathBitmap, Point.Round(location));
}
else
{
m_Graphics.DrawString(word.m_Text, nativeFont,
nativeBrush, location, StringFormat.GenericDefault);
}
装饰——描边和阴影
人们可能认为有用且不错的功能是每个单词的 Stroke
和 Shadow
支持。这两种装饰分别由 <stroke>
和 <shadow>
标签定义。正如您可能已经猜到的,我正在使用 System.Drawing.Drawing2D.GraphicsPath
来实现描边逻辑。由于通过字符串创建路径通常是一个昂贵的操作,因此每个单词(如果描边)都保留一个带有其视觉表示的缓存位图。该位图在测量单词时创建一次。阴影也是如此——它使用了 q123456789 的 FastBitmap
的模糊机制。模糊是一种相当昂贵的逐像素操作,因此将结果缓存到位图上几乎是必须的。
未来改进和新功能
我计划在近期添加以下功能
- 图片支持 -
<img>
标签。 - 表格 -
<table>
标签。最初,只支持简单表格(没有列和行跨度)。 - Div 和 span -
<div>
和<span>
标签。 - 每个元素的绘制样式 - 背景和边框。
- 更多有待思考的功能 :)。
致谢
非常感谢 q123456789 及其 有用的文章。
历史
- 2008 年 11 月 26 日 - 首次发布。