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 DrawingVisual
s, RenderTargetBitmap
s, and
Bitmap
s.
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 Bitmap
s all the time or caching Bitmap
s 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.