Introduction
I believe that almost every .NET developer, assigned a task to display text, has experienced hard times with precise text measuring and drawing. The System.Drawing.Graphics
MeasureString
and DrawString
methods have several limitations, worst of all being not so accurate measuring and positioning of the desired text. Since .NET 2.0, Microsoft has introduced the TextRenderer
class that provides more precise text manipulation, but also has its cons – may render only with solid colors, no transparency, etc. Neither of the above described approaches gives you the opportunity to have mixed font texts, extended paragraph layouts like justify, or additional text effects like stroke or shadow. All that said, I thought it would be a nice exercise to create a custom text rendering solution that both solves the standard problems and also adds some nice features. Having solid experience with GDI+ and Windows Forms (I was a GUI developer for over 4 years), I started this project about a month ago, and had contributed to it for an hour or two almost every day. There are many areas that one may or may not find useful, and many parts I consider tricky, so I do hope this article will be useful for many folks.
Who May be Interested in this Project
This project mainly targets the Windows Forms platform. Although, in theory, it may be used to create off-screen graphics for the ASP.NET engine, I doubt that web developers will rely on this solution, having the Web Browser (and all its HTML formatting capabilities) as their main visual surface. The included control provides basic text formatting features, and may be used as a light-weight substitution for the heavy IE ActiveX control.
Using the Code
There are two main aspects of this solution:
- An abstract implementation –
GTextView
- that is completely detached from a certain platform (Windows Forms vs. ASP.NET). You may use this abstraction to display text on any Graphics
surface. For example, you may extend a ListBox
whose items will be rich-text enabled.
- A Windows Forms control –
GMarkupLabel
- that composes the above class. What that control does is simply delegates paint and mouse events to the internal text view.
This is how we create and utilize a GTextView
instance:
m_TextView = new GTextView();
m_TextView.Invalidated += OnTextViewInvalidated;
m_TextView.PropertyChanged += new GEventHandler(OnTextViewPropertyChanged);
m_TextView.AnchorClicked += new GEventHandler(OnTextViewAnchorClicked);
And, here is how we paint the view:
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();
}
As the GMarkupLabel
extends the Windows Forms control, you may simply add it to your toolbox and instantiate it at design-time. Its Text
property will update the internal text view.
Supported Tags and Attributes
Following is a table of the supported tags and their corresponding attributes:
Element |
Tag |
Attributes |
Usage |
Anchor |
<a> |
href |
<a href="myhref"> |
Bold |
<b> |
- |
<b> |
Font |
<font> |
face , size , color |
<font face="Tahoma" size="10" color="red"> |
Italic |
<i> |
- |
<i> |
Line Break |
<br> |
- |
<br> |
Paragraph |
<p> |
padding , align , wrap |
<p align="justify" padding="5" wrap="none"> |
Shadow |
<shadow> |
color , offset , style , strength |
<shadow color="100,0,0,0" offset="2,2" style="blurred" strength="1,1"> |
Stroke |
<stroke> |
color , width |
<stroke color="gray" width="2"> |
Underline |
<u> |
- |
<u> |
Whitespace |
<whitespace> |
length |
<whitespace length="10"> |
Please note that you may find some other attributes declared in the source, but those are not yet implemented.
Brief Model Overview
Behind the GMarkupLabel
lies a framework of abstract hierarchies that form a light-weight DOM tree. There is a strict distinction between the Model and the View layers. The System.Xml.XmlDocument
class is used to parse the provided raw text to an XML tree, and a model DOM is built on top of the already parsed text. The model structure is used by a GTextView
instance to populate its child visuals (like GParagraph
, GTextBlock
) and layout atoms (like GTextLine
, GWord
). I will not cover every aspect of the framework, and will focus on some interesting and tricky parts instead.
GTextView Inside
Overview
The GTextView
class is an abstract implementation that is completely detached from any specific platform (e.g., Windows Forms or ASP.NET), and provides the core logic for parsing a GTextDocument
and organizing it into paragraphs, text blocks, lines, and words. Once parsed, the internal element structure looks like:
There are three different aspects of the View – how it is parsed, the way it is laid-out, and how it is visualized on the screen. The main approach is to split the entire text (excluding tags) into separate words, and to organize them in a composite structure. On top of the layout tree is the paragraph which consists of one or more text blocks (each block indicates a line break within the paragraph). A text block knows how many words it contains, and lays them out in lines, where each line contains one or more words, and calculates each word’s location. While paragraphs, text blocks, and words are parsed only once, lines are built dynamically, upon each layout request. So far, we have words that are laid out on the screen. Each word is associated with certain string, metric members and a style object which carries all the information needed by the view to display the word on the screen.
Parsing
Once we have the System.Xml.XmlDocument
, we create our DOM tree from this document. Each XML element has its light-weight DOM equivalent, and each XML attribute is mapped to a property of that DOM element. Further on, we populate our text view with paragraphs, text blocks, and text lines. Following is a code snippet from the GTextDocumentParser
:
GTextStyle currentStyle = m_Styles.Peek();
GTextStyle newStyle = null;
bool newParagraph = false;
bool newAnchor = false;
switch (element.TagName)
{
case GTextDocument.AnchorNodeName:
newStyle = OpenAnchor((GAnchorElement)element, currentStyle);
newAnchor = true;
break;
case GTextDocument.ParagraphNodeName:
newParagraph = true;
break;
case GTextDocument.FontNodeName:
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;
case GTextDocument.LineBreakNodeName:
BreakLine();
break;
case GTextDocument.TextNodeName:
string text = ((GStringElement)element).Text;
ProcessText(text);
break;
}
if (newStyle != null)
{
m_Styles.Push(newStyle);
}
if (newParagraph)
{
PushParagraph(element);
}
ProcessCollection(element.m_Children);
if (newStyle != null)
{
m_Styles.Pop();
}
if (newParagraph)
{
PopParagraph();
}
if (newAnchor)
{
CloseAnchor();
}
We have two Stack
s here – one is used to Push
and Pop
paragraphs (nested paragraphs are supported), and the other is used to track the GTextStyle
of each word. Before the recursive call, we check whether a new paragraph and style needs to be pushed, and after the recursion is finished, we restore the state of the Stack
s (if modified).
Measuring
In order to provide precise layout, we need a way to tell how many pixels a word is going to occupy. As mentioned in the introduction, a simple Graphics.MeasureString
call will not do the job – it will always add few or more additional pixels on the returned value. What I am using as an approach here is to first render the word on an off-screen bitmap, then perform some per-pixel operations on this bitmap to determine the render padding from edges, and then calculate the so called "Black Box" (the smallest rectangle that completely encloses the displayed text):
Here is a snippet from the MeasureWord
method:
GWordMetric metric = new GWordMetric();
Font nativeFont = GetNativeFont(word.m_Style.m_Font);
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;
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);
bmp.Dispose();
word.m_Metric = metric;
Whitespaces are special case. Instead of measuring each whitespace’s size, I simply keep a value named WhitespaceWidth
in each GFontDeviceMetric
. This ensures that we measure the width of a whitespace only once, during initialization of each font’s device metrics.
The algorithm which calculates the padding is pretty simple – we loop through each pixel of the bitmap, starting from each edge, and look for a pixel that is different from the background color. The following snippet demonstrates how we calculate left edge's padding:
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;
}
}
Now is the time to say my thanks to the author of this article. The GBitmap
uses some approaches and tricks from q123456789's FastBitmap
. For example, manipulating a bitmap’s data directly by unsafe code speeds-up pixel information retrieval drastically. Using .NET’s Bitmap.GetPixel
is slow as hell, and would simply not work in this case, where each word is painted off-screen and quite many GetPixel
calls are made.
Layout
So far, we have words whose exact size is measured. Now, we need to calculate the location of each word in the view. Here come paragraphs, text blocks, and text lines. Let's have a look at the GTextBlock
class and how it organizes its words in lines (a snippet from the BuildLines
method):
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;
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;
}
}
Once we have built all the lines, we need to calculate each word's location. This calculation depends on several things: padding, alignment, and mixed font baseline. The interesting part here is how we determine the font baseline. Given the fact that we may want to display words in different fonts on the same text line, we should align them in such a way that they all lie on a logical line which separates each word's Ascent and Descent. What I am using as an approach here is to find the word with the highest Ascent and then align each word with this value.
The following code demonstrates how we calculate the font baseline:
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);
}
And, this snippet shows how we determine the Y-coordinate of each word, according to the already calculated font baseline:
LinkedListNode<gword > firstNode = m_Words.First;
LinkedListNode<gword > node = firstNode;
GWord currWord = node.Value;
while (node != null)
{
currWord = node.Value;
node = node.Next;
x -= currWord.m_Metric.Padding.Left;
y = context.Y + (m_Baseline - currWord.m_FontMetric.TextMetric.tmAscent);
currWord.m_Location = new PointF(x, y);
x += currWord.m_Metric.Size.Width - currWord.m_Metric.Padding.Right;
x += m_SpaceToDistribute;
}
An interesting thing to point to is the fact that I am using the GDI TEXTMETRIC
and its tmAscent
value. At first, I played with the EmHeight
value returned by the FontFamily.GetEmHeight
method. The problem was that this value is a single-precision floating-point number, and sometimes (most probably due to an internal rounding by the rendering engine), words were not precisely aligned – a one-pixel error occurred. On the other hand, GDI and TEXTMETRIC
returns an integer number, with a rounding already applied, which turned to be much more precise.
Painting
Painting is the easiest part. Once we have laid-out all the words, we need to simply enumerate and paint them. I will open a bracket here and explain a bit more about the paint logic used in the underlying framework. Instead of working directly with GDI+ objects such as Pens and Brushes (all associated with a Handle, cannot be edited at design-time), I have their abstract counter-parts such as GBrush
and GPen
. In parallel, I have an abstract hierarchy of GDeviceContext
interfaces. Currently, the only concrete implementation is the GGdiPlusDeviceContext
which is associated with a System.Drawing.Graphics
instance. The idea behind is to have as detached from a specific platform paint logic as possible. It is the Device implementation that knows how to map a GDrawingAttribute
to a native device paint object – e.g., an abstract GSolidBrush
is mapped to a concrete System.Drawing.SolidBrush
. The GDI+ Device keeps a cache of native drawing primitives in a Hashtable
, where each key is the abstract drawing attribute and the value is its native counterpart. This speeds up the rendering logic significantly.
Let's have a look at how a word is painted:
ValidateGraphics();
PointF location = word.m_Location;
Font nativeFont = GetNativeFont(word.m_Style.m_Font);
Brush nativeBrush = GetNativeBrush(word.m_Style.m_Brush);
if (word.m_Style.m_Shadow != null)
{
RectangleF bounds = new RectangleF(location, word.m_Metric.Size);
PaintWordShadow(word, bounds, nativeFont);
}
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);
}
Decorations – Stroke and Shadow
What one may find as nice and useful features are the Stroke
and Shadow
support per word. These two decorations are defined by the <stroke>
and <shadow>
tags, respectively. As you may have already guessed, I am using a System.Drawing.Drawing2D.GraphicsPath
to implement the stroke logic. As creating a path by a string is generally an expensive operation, each word (if stroked) keeps a cached bitmap with its visual representation. This bitmap is once created when the word is measured. The same is true for the shadow - it uses the Blur mechanism of q123456789's FastBitmap
. Blurring is quite an expensive per-pixel operation, and caching the result on a bitmap is almost a must.
Future Improvements and New Features
I am planning to add the following features in the near future:
- Image support -
<img>
tag.
- Tables -
<table>
tag. At first, only simple tables will be supported (without column and row span).
- Divs and spans -
<div>
and <span>
tags.
- Paint styles per element - background and border.
- More to be thought of :).
Credits
Many thanks to q123456789 and his useful article.
History
- November 26, 2008 - Initial release.