Click here to Skip to main content
Click here to Skip to main content

GMarkupLabel - A C# Windows Forms control to display XML-formatted text

, 25 Nov 2008 CPOL
Rate this:
Please Sign up or sign in to vote.
A framework, and a WinForms control which enables .NET 2.0 users to visualize XML-formatted rich text.

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

A brief model of the main project parts.

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:

  • GTextView
    • GParagraph
      • GTextBlock
        • GTextLine
          • GWord
          • GWord
          • GWord

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)
{
        //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();
}

We have two Stacks 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 Stacks (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):

Demonstrates the padding calculation of a word.

Here is a snippet from the MeasureWord method:

//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;

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:

//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;
    }
}

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<gword /> 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;
    }
}

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.

Font with different sizes, rendered in a same text line

The following code demonstrates how we calculate the font baseline:

LinkedListNode<gword /> 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;

    //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;
}

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;

//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);
}

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.

A sample with stroked and shadowed text

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 Smile | :) .

Credits

Many thanks to q123456789 and his useful article.

History

  • November 26, 2008 - Initial release.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Georgi Atanasov
Team Leader Telerik Corp.
Bulgaria Bulgaria
.NET & C# addicted. Win8 & WinRT enthusiast and researcher @Telerik.
Follow on   Twitter

Comments and Discussions

 
QuestionWhat about images? [modified] PinmemberJackie0010020-Nov-12 1:32 
GeneralMy vote of 5 Pinmemberst1410-Feb-11 14:16 
GeneralBug w/ adjacent anchor tags PinmemberChiPlastique1-Oct-09 4:28 
GeneralBugs when painting GTextView away from the origin PinmemberChiPlastique22-Jul-09 8:03 
GeneralRe: Bugs when painting GTextView away from the origin PinmemberChiPlastique22-Jul-09 8:37 
GeneralRe: Bugs when painting GTextView away from the origin PinmemberGeorgi Atanasov24-Jul-09 11:55 
GeneralRe: Bugs when painting GTextView away from the origin PinmemberF.Phoenix17-Aug-09 7:39 
GeneralAdded Highlight markup PinmemberChiPlastique21-Jul-09 13:12 
GeneralLine Breaks... Pinmemberreflex@codeproject5-May-09 4:50 
GeneralRe: Line Breaks... PinmemberGeorgi Atanasov5-May-09 6:18 
GeneralRe: Line Breaks... Pinmemberreflex@codeproject5-May-09 7:27 
GeneralRe: Line Breaks... PinmemberGeorgi Atanasov6-May-09 8:36 
QuestionHints? PinmemberDmitri Nesteruk4-Apr-09 9:53 
AnswerRe: Hints? PinmemberGeorgi Atanasov6-Apr-09 21:13 
GeneralWhen update? [modified] Pinmembertodna27-Feb-09 6:42 
GeneralRe: When update? PinmemberGeorgi Atanasov6-Apr-09 21:14 
GeneralRe: When update? Pinmembertodna7-Apr-09 2:42 
GeneralRe: When update? PinmemberF.Phoenix27-Mar-10 15:05 
GeneralDetermining minimal height needed to show some html text Pinmemberanna.novikova20-Feb-09 1:46 
GeneralRe: Determining minimal height needed to show some html text PinmemberGeorgi Atanasov20-Feb-09 8:12 
GeneralRe: Determining minimal height needed to show some html text Pinmemberanna.novikova23-Feb-09 3:25 
GeneralRe: Determining minimal height needed to show some html text Pinmemberanna.novikova23-Feb-09 19:06 
GeneralText Rendering PinmemberLaserson17-Dec-08 21:48 
GeneralRe: Text Rendering PinmemberGeorgi Atanasov18-Dec-08 2:34 
GeneralLooking for this for a long time Pinmembertom456117-Dec-08 12:36 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.141022.2 | Last Updated 25 Nov 2008
Article Copyright 2008 by Georgi Atanasov
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid