Introduction
Recently I came across the need to render some simple markup text (such as Lorem <b>ipsum</b> dolor sit amet, <u><i>consectetur adipisicing</u> elit,
sed do eiusmod</i> tempor incididunt) into a column of a .NET DataGridView. So I decided to split the task into
two: parsing the text
for formatting information, and writing a custom DataGridViewCell with a custom Paint method. For the latter,
I had to render the formatted text into the provided Graphics object and that's what this
article is about.
Background
Drawing multiline formatted text into a Graphics object turned out not to be as simple as it sounds like. There is nothing in the .NET platform
to do this out of the box. I found some solutions using a RTF control but that was way too much overhead. Next I looked into coding it manually using font metrics,
string measuring, etc., provided by the Font and Graphics classes, but quickly decided not to go that way - to do a good job using
that probably would have taken weeks. Searching for a solution, I also came across the System.Windows.Media.FormattedText class,
which was exactly what I needed, but it is WPF and cannot be drawn directly on a Graphics object. So I went back there and tried to figure
out how I could make it work for me and here is what I came up with.
How it works
The FormattedText class takes a string of characters and allows you to format arbitrary character ranges in a very simple way.
Then you can provide MaxTextWidth and MaxTextHeight settings to define a layout rectangle for multiline rendering.
If the text doesn't fit into the rectangle, the FormattedText object will display ellipsis as needed.
In this article, the following steps are taken to draw that output into a Graphics object:
On the WPF side:
- Create and configure a
FormattedText as needed
- Draw the
FormattedText on a DrawingVisual
- Render the
DrawingVisual into a RenderTargetBitmap
Interfacing between WPF and Windows Forms:
- Create a
System.Drawing.Bitmap
- Copy the
RenderTargetBitmap pixels into the bitmap's pixel buffer
On the Windows Forms side:
- Draw the bitmap on the
Graphics object
Step by step
We begin with a given font and foreground color, usually taken from the form or control where we want to draw the formatted text.
We also have a graphics and a layout rectangle to draw into.
Dim font As System.Drawing.Font
Dim color As System.Drawing.Color
Dim rectangle As System.Drawing.Rectangle
Dim graphics As System.Drawing.Graphics
Step 1: Create and configure a FormattedText
The FormattedText constructor takes the following arguments:
Dim textToFormat As String
Dim culture As System.Globalization.CultureInfo
Dim flowDirection As System.Windows.FlowDirection
Dim typeface As System.Windows.Media.Typeface
Dim emSize As Double
Dim foreground As System.Windows.Media.Brush
As a text to format, we use Lorem ipsum:
textToFormat = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " +
"sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, " +
"quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " +
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore " +
"eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, " +
"sunt in culpa qui officia deserunt mollit anim id est laborum."
Then we have to derive the other arguments from our Windows Forms arguments.
For the culture, we just take the culture info from the current thread:
culture = System.Globalization.CultureInfo.CurrentCulture
Take the flow direction from the culture:
If culture.TextInfo.IsRightToLeft Then
flowDirection = System.Windows.FlowDirection.RightToLeft
Else
flowDirection = System.Windows.FlowDirection.LeftToRight
End If
Create a typeface based on the font:
typeface = New System.Windows.Media.Typeface(font.FontFamily.Name)
For the emSize, we have to convert: FormattedText expects emSize to be in device independent units, which is 1/96 inches.
The font provides us a size in points, each point is 1/72 inches. So:
emSize = font.SizeInPoints * 96.0 / 72.0
The foreground brush can be easily created using the ARGB bytes from our color:
foreground = New System.Windows.Media.SolidColorBrush( _
System.Windows.Media.Color.FromArgb( _
color.A, color.R, color.G, color.B))
Now we have enough information to create a FormattedText object:
Dim formattedText = New System.Windows.Media.FormattedText( _
textToFormat, culture, flowDirection, typeface, emSize, foreground)
Now we can apply the font object's style information to our formatted text: the WPF equivalents to the
FontStyle properties bold, italic, underline, and strikethrough.
Dim fontStyle As System.Windows.FontStyle
Dim fontWeight As System.Windows.FontWeight
Dim textDecorations = New System.Windows.TextDecorationCollection
If (font.Style And System.Drawing.FontStyle.Italic) <> 0 Then
fontStyle = System.Windows.FontStyles.Italic
Else
fontStyle = System.Windows.FontStyles.Normal
End If
If (font.Style And System.Drawing.FontStyle.Bold) <> 0 Then
fontWeight = System.Windows.FontWeights.Bold
Else
fontWeight = System.Windows.FontWeights.Normal
End If
If (font.Style And System.Drawing.FontStyle.Underline) <> 0 Then
textDecorations.Add(System.Windows.TextDecorations.Underline)
End If
If (font.Style And System.Drawing.FontStyle.Strikeout) <> 0 Then
textDecorations.Add(System.Windows.TextDecorations.Strikethrough)
End If
formattedText.SetFontStyle(fontStyle)
formattedText.SetFontWeight(fontWeight)
formattedText.SetTextDecorations(textDecorations)
Now we have a formatted text corresponding to our initial font and color. Let's apply some span formatting to the text: 50 characters italic starting at character index 5,
30 characters extrabold starting at character index 20, and 20 characters overline starting at character index 0.
formattedText.SetFontStyle(System.Windows.FontStyles.Italic, 5, 50)
formattedText.SetFontWeight(System.Windows.FontWeights.ExtraBold, 20, 30)
formattedText.SetTextDecorations(System.Windows.TextDecorations.OverLine, 0, 20)
Step 2: Draw the FormattedText on a Drawing Visual
Before we draw the formatted text we have to apply the layout rectangle to the formatted text using its MaxTextWidth
and MaxTextHeight properties. Those properties expect values in device independent units, but our rectangle is in pixels.
So we have to convert pixels into device independent units (1/96 inches) using the current Graphics object's DPI settings:
formattedText.MaxTextWidth = rectangle.Width / (graphics.DpiX / 96.0)
formattedText.MaxTextHeight = rectangle.Height / (graphics.DpiY / 96.0)
More details about DPI, points, device independent units, etc.,
can be found here: http://msdn.microsoft.com/en-us/library/windows/desktop/ff684173%28v=vs.85%29.aspx.
With the MaxTextWidth and MaxTextHeight properties set, the FormattedText object will now layout itself inside
the given rectangle, performing word wrap and applying ellipses as needed. So now we can draw the formatted text on a WPF DrawingVisual.
Dim drawingVisual = New System.Windows.Media.DrawingVisual
Using drawingContext = drawingVisual.RenderOpen()
drawingContext.DrawText(formattedText, New System.Windows.Point(0, 0))
End Using
Step 3: Render the DrawingVisual into a RenderTargetBitmap
At this point we have to obtain the metrics for our formatted text - Width and Height - and convert them into pixels to create a RenderTargetBitmap.
First we have to measure the formatted text, using width and height properties. Again, those properties are in device independent units and have to be converted into pixel.
Then we can create the RenderTargetBitmap.
Dim pixelWidth = System.Convert.ToInt32( _
System.Math.Ceiling(formattedText.Width * (graphics.DpiX / 96.0)))
Dim pixelHeight = System.Convert.ToInt32( _
System.Math.Ceiling(formattedText.Height * (graphics.DpiY / 96.0)))
Dim rtb = New System.Windows.Media.Imaging.RenderTargetBitmap( _
pixelWidth, pixelHeight, graphics.DpiX, graphics.DpiY, _
System.Windows.Media.PixelFormats.Pbgra32)
Step 4: Create a System.Drawing.Bitmap
The Windows Forms equivalent PixelFormat for Pbgra32 is Format32bppPArgb;
the width and height of the bitmap are the same as the source
RenderTargetBitmap:
Dim bitmap = New System.Drawing.Bitmap(rtb.PixelWidth, rtb.PixelHeight, _
System.Drawing.Imaging.PixelFormat.Format32bppPArgb)
Step 5: Copy the RenderTargetBitmap pixels into the bitmap's pixel buffer
We get access to the Bitmap object's pixel buffer using the LockBits function, then we can use the RenderTargetBitmap.CopyPixels method:
Dim pdata = bitmap.LockBits(New System.Drawing.Rectangle( _
0, 0, bitmap.Width, bitmap.Height), _
System.Drawing.Imaging.ImageLockMode.WriteOnly, bitmap.PixelFormat)
rtb.CopyPixels(System.Windows.Int32Rect.Empty, _
pdata.Scan0, pdata.Stride * pdata.Height, pdata.Stride)
bitmap.UnlockBits(pdata)
Step 6: Draw the Bitmap on the Graphics object
We are almost done, the last step is to draw the Bitmap object on the Graphics object, at the origin of the layout rectangle:
graphics.DrawImage(bitmap, rectangle.Location)
Performance
The goal of this is to draw formatted text directly on forms and controls, so performance is important for the speed and responsiveness of the Windows Forms
application's GUI.
I used the profiler in the SharpDevelop IDE with following results:
The more expensive operations are:
- Drawing the text on the
DrawingVisual - Rendering the
DrawingVisual into the RenderTargetBitmap - The
Bitmap constructor
- Drawing the
Bitmap on the Graphics object - Measuring the
FormattedText (Width and Height properties) before drawing
Less expensive operations are:
- Copying the pixels from the
RenderTargetBitmap to the Bitmap - Setting text formatting parameters
- Measuring the
FormattedText (Width and Height properties)
after drawing
The performance difference in the text measuring exists because FormattedText uses cached metrics. Those metrics are cached whenever the text is drawn or measured,
and invalidated whenever text/span properties are changed. If you access measuring properties and there are no valid cached metrics,
the FormattedText object will internally draw the text in order to obtain the metrics.
Taking those results into account, I wrote a class of reusable code I called
WindowsFormsFormattedText, trying to maximize performance
by carefully caching DrawingVisuals, RenderTargetBitmaps, and
Bitmaps.
For instance, to avoid the Bitmap constructor, it uses only one
single Bitmap shared at class level - a new Bitmap is only created if the cached
Bitmap is smaller than the RenderTargetBitmap.
If it is equal or bigger, then it is reused. This requires synchronization on the code that uses the cached bitmap, but that's much
cheaper than creating Bitmaps all the time or caching Bitmaps at object level.
For the caching to work efficiently, I also needed
to know if there are cached metrics in the FormattedText object or not. Since that information is private to
FormattedText, Reflection was used to obtain it.
For implementation details, you can refer to the WindowsFormsFormattedText class attached to this article.
A performance comparison between Graphics.DrawString and WindowsFormsFormattedText.Draw using the same profiler shows
that Graphics.DrawString is two to four times faster than WindowsFormsFormattedText.Draw in the worst case (no caching
of DrawingVisual and RenderTargetBitmap can be done), but WindowsFormsFormattedText is still fast enough
to be used in Windows Forms Applications with excellent GUI performance.
Using the code
The attached Zip contains two classes:
WindowsFormsFormattedText does all the work and FormattedTextLabel is a control derived from Label and can be used on forms etc.
Note for C# users: the code should be easy to convert, just pass it through the code conversion tool from Developer Fusion,
freely available on the web and in the SharpDevelop IDE.
Add the classes to your project and reference two assemblies: FoundationCore and WindowsBase.
Those assemblies are included in the framework since version 3.0, so no external dependencies are needed.
The WindowsFormsFormattedText class maintains internally a FormattedText object and exposes all of its members regarding text formatting.Those members are documented here: http://msdn.microsoft.com/en-us/library/system.windows.media.formattedtext.aspx
Aditionally, it adds methods and properties with arguments from the Windows Forms world to automate the needed conversions. For example, in addition to the TextWidth property there is a TextPixelWidth(dpiX) property. For the built-in SetTypeface methods there are equivalent SetFont methods and so on.
The SetStyle(System.Drawing.FontStyle) method sets all of the Windows Forms font styles properties (bold, italic, underline, and strikethrough) at once. So if you do, for example, SetStyle(FontStyle.Bold Or FontStyle.Italic, 20, 30), it will remove underline and strikethrough for the given character range, which might not be what you have expected. To set individual style properties without affecting other style properties, you have to use the built-in methods SetFontWeight, SetFontStyle and SetTextDecorations.
Note: The SetStyle(System.Drawing.FontStyle) method was a SetFontStyle overload in the first uploaded version, but I renamed it because it was missleading: in FormattedText that method applies only to normal, italic or oblique.
It also exposes a Draw(Graphics, ...)
method which automates the drawing of the formatted text on a Graphics object.
FormattedTextLabel exposes a FormattedText property providing access to an internal
WindowsFormsFormattedText object allowing
to format the text to be displayed on the label. It is derived from Label for convenience, a better implementation probably would
be derived directly from Control. But the label was not my goal, I wrote it just for testing.
Flicker: Since the drawing on the Graphics object is done with a
Bitmap, flicker may occur under some circumstances.
If you experience flicker, consider to double buffer the flickering controls.
Here is some sample code. To use it, create a form
and drop two buttons on it, one called PerformanceTestButton and the other LabelTestButton.
- The code used for the performance test:
Private endPerformanceTest As Boolean
Private performanceTestForm As Form
Private Sub PerformanceTestButton_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles PerformanceTestButton.Click
performanceTestForm = New Form
performanceTestForm.Text = "Performance test - close form to finish."
performanceTestForm.CreateControl()
performanceTestForm.Font = New Font(performanceTestForm.Font.FontFamily, 12.0!)
Dim t = New System.Threading.Thread(AddressOf PerformanceTest)
t.Start()
performanceTestForm.ShowDialog()
endPerformanceTest = True
End Sub
Sub PerformanceTest()
Dim rnd = New System.Random
Dim textSample = "Lorem ipsum dolor sit amet, consectetur adipisicing elit," & _
" sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " & _
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " & _
"nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in " & _
"reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " & _
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " & _
"officia deserunt mollit anim id est laborum."
Dim fText = New WindowsFormsFormattedText(textSample, performanceTestForm.Font)
endPerformanceTest = False
Do
Dim x = rnd.Next() Mod 10
Dim y = rnd.Next() Mod 10
Dim w = 10 + (rnd.Next() Mod (performanceTestForm.Width - 20))
Dim h = 10 + (rnd.Next() Mod (performanceTestForm.Height - 20))
Using g = performanceTestForm.CreateGraphics()
fText.MaxTextPixelWidth(g.DpiX) = w
fText.MaxTextPixelHeight(g.DpiY) = h
Try
fText.Draw(g, x, y)
Catch ex As Exception
System.Diagnostics.Debug.Print(ex.Message)
End Try
Try
g.DrawString(textSample, performanceTestForm.Font, Brushes.Black, _
New RectangleF(x, y, w, h), System.Drawing.StringFormat.GenericDefault)
Catch ex As Exception
System.Diagnostics.Debug.Print(ex.Message)
End Try
End Using
Loop Until endPerformanceTest
End Sub
- The code used to test the
FormattedTextLabel:
Private Sub LabelTestButton_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles LabelTestButton.Click
Dim f = New Form()
f.Text = "FormattedTextLabel Test"
f.Width = 600
f.Height = 400
Dim l = New FormattedTextLabel
l.Text = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " & _
"sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " & _
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi " & _
"ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit " & _
"in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " & _
"Excepteur sint occaecat cupidatat non proident, sunt in culpa " & _
"qui officia deserunt mollit anim id est laborum."
l.FormattedText.SetFontStyle(FontStyle.Bold Or FontStyle.Italic, 0, 30)
l.FormattedText.SetFontSizeInPoints(18.0, 200, 60)
l.FormattedText.SetForegroundColor(Color.Aquamarine, 100, 120)
l.BackColor = Color.White
l.BorderStyle = BorderStyle.FixedSingle
l.SetBounds(50, 50, 500, 300)
l.Anchor = AnchorStyles.Bottom Or AnchorStyles.Left Or AnchorStyles.Right Or AnchorStyles.Top
f.Controls.Add(l)
f.ShowDialog()
End Sub
This is the result:
I hope this code is useful for your project. In my next article I am going to write about the development of a
DataGridViewFormattedTextCell and a DataGridViewFormattedTextColumn
based on the WindowsFormsFormattedText and a helper class I called
SimpleMarkupText which parses text with simple markup like <b>,
<u>, <font color...> etc. into a WindowsFormsFormattedText object.
History
- Article submitted: August 2012.
- Updated text and downloads: August 22
- Renamed
SetStyle(System.Drawing.FontStyle) to SetStyle(System.Drawing.FontStyle) - Added
Draw(graphics, ..., clipBounds) overloads: taking into account the clipBounds (or clipRectangle) rectangles usually provides in OnPaint overrides or Paint events, performance can be improved when the drawing surface is partially covered by other windows or out of screen.