Drawing Rich Text with GDI+






4.95/5 (17 votes)
Render Rich Text with GDI+ by tapping into the power of API hooking
Introduction
Like many others, I've surfed the web for countless hours to find a way to render Rich Text with GDI+ without having to roll out my own RTF parser and a (fast) word-break algorithm. I've never come across a satisfying solution so I'm posting this in the hope that it will help you save same time.
Background
The typical scenario for which this project is designed is a Windows application that features a layered image editor.
Users can create a composite image, made up of pictures and text, and the editor features an RTF control that enables you to enter and edit RichText
which is then rendered with antialias via GDI+ on a 32bpp transparent image, that will represent one of the layers in the composite image.
The Antialias Issue
As you may know, there's a quick way to render Rich Text from a RichEdit control to a DC by means of the EM_FORMATRANGE message sent to the control via SendMessage. This approach is great and it's the perfect solution when all you need is to render text on a static background.
But what if you want to render your Rich Text to a transparent background?
Then all hell breaks loose since the approach described above won't work.
Standard GDI methods that render text with antialias are designed to process alpha-blending based on a background color. If you want to render on a transparent image, antialias gets lost leaving you with terrible results.
Creating a Transparent RichEdit Control
The first step for our composite editor is to implement our own RichEdit
control by inheriting from the RichTextBox
class. This will serve as the GUI input element through which our users will enter and edit Rich Text, directly on the composite image we are editing:
Friend Class TransRtb
Inherits RichTextBox
There's a simple way to create a transparent RichEdit
control (RichTextBox
in .NET), as seen posted here. All you need to do is set the WS_EX_TRANSPARENT style upon window creation and set ControlStyle
flags accordingly:
Public Sub New()
Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True)
Me.SetStyle(ControlStyles.Opaque, True)
Me.SetStyle(ControlStyles.SupportsTransparentBackColor, True)
Me.SetStyle(ControlStyles.EnableNotifyMessage, True)
MyBase.BackColor = Color.Transparent
' We don't want any scrollbars to appear
Me.ScrollBars = RichTextBoxScrollBars.None
End Sub
''' <summary>
''' Set the extended windows style transparent flag
''' </summary>
Protected Overrides ReadOnly Property CreateParams() _
As System.Windows.Forms.CreateParams
Get
Dim cp As CreateParams = MyBase.CreateParams
cp.ExStyle = cp.ExStyle Or SafeNativeMethods.WS_EX_TRANSPARENT
Return cp
End Get
End Property
This alone is a great solution for anyone who simply needs to give the user the ability to input Rich Text on top of images.
It will render text with antialias and with a bit of work, you could roll out your own moving and resizing methods.
See my Tip/Trick Resize and Rotate Shapes in GDI+. Keep in mind that you wouldn't be able to rotate a Windows control though.
Define a Strategy for GDI+ Rendering
Now, if you need to actually have Rich Text rendered on a transparent bitmap with antialias, things get tricky.
You're basically left with a couple of options:
- Write your own RTF parser and word-wrap algorithm (good luck with that).
- Keep reading this article.
I've personally walked the path of option 1 on a project I've been working on for the past 15 years and I'm still fixing bugs.
The idea here is simple: we already have a control that does it all: it measures string
s and defines coordinates, positions and renders the text. Too bad we can't tap into the power of the existing control to get a hold of that information and use it to render the text ourselves via GDI+.
Or can we?
Spy, My Name is Spy++
In Windows, all flavors of a RichEdit
control are essentially wrappers of one of the RichEditxx.dlls. By firing up Spy++ and focusing on the gdi32.dll calls, it turns out that all text bits are rendered with three API calls:
- ExtTextOutW for text and glyph rendering
- ExtTextOutA for text background rendering
- PatBlt for underline and strikethrough
By intercepting these calls, one can gather all data necessary to render the text in any other way.
All we need now is a way to intercept these calls. It turns out there's a great open source library out there called EasyHook that will enable you to do just that, without having to write your own API hooking/hijacking methods, a rather complex and delicate task.
Let's Hook
You will need to include three DLLs to your project:
- EasyHook.dll
- EasyHook32.dll and
- EasyHook64.dll
Your project will then need to reference the first one, EasyHook.dll.
In our implementation of the RichTextBox
control, we will setup hooking of these three APIs by creating a delegate
for each one of the APIs we want to intercept:
' Hook objects
Private m_DelMyTextOutW As DelExtTextOutW
Private m_DelMyTextOutA As DelExtTextOutA
Private m_DelMyPatBlt As DelPatBlt
Private m_HkMyTextOutW As LocalHook
Private m_HkMyTextOutA As LocalHook
Private m_HkMyPatBlt As LocalHook
' Delegates
Private Delegate Function DelExtTextOutW(ByVal hdc As IntPtr, ByVal x As Int32, _
ByVal y As Int32, ByVal wOptions As Int32, ByVal lpRect As IntPtr, _
ByVal lpString As IntPtr, ByVal nCount As Int32, ByVal lpDx As IntPtr) As Int32
Private Delegate Function DelExtTextOutA(ByVal hdc As IntPtr, ByVal x As Int32, _
ByVal y As Int32, ByVal wOptions As Int32, ByVal lpRect As IntPtr, _
ByVal lpString As IntPtr, ByVal nCount As Int32, ByVal lpDx As IntPtr) As Int32
Private Delegate Function DelPatBlt(ByVal hdc As IntPtr, ByVal x As Int32, _
ByVal y As Int32, ByVal nWidth As Int32, ByVal nHeight As Int32, _
ByVal dwRop As Int32) As Int32
We'll add an on/off method to start and stop hooking (please see the attached project for API declarations):
''' <summary>
''' Toggle API hooking on/off
''' </summary>
Private Sub HookGdi(State As Boolean)
Try
If State Then
' Setup delegates
m_DelMyTextOutW = AddressOf MyExtTextOutW
m_DelMyTextOutA = AddressOf MyExtTextOutA
m_DelMyPatBlt = AddressOf MyPatBlt
' Setup ExtTextOutW hooking
If m_HkMyTextOutW Is Nothing Then
Dim iPtrAddress As IntPtr = _
LocalHook.GetProcAddress("gdi32.dll", "ExtTextOutW")
m_HkMyTextOutW = LocalHook.Create(iPtrAddress, m_DelMyTextOutW, Me)
m_HkMyTextOutW.ThreadACL.SetExclusiveACL(New Int32() {1})
End If
' Setup ExtTextOutA hooking
If m_HkMyTextOutA Is Nothing Then
Dim iPtrAddress As IntPtr = _
LocalHook.GetProcAddress("gdi32.dll", "ExtTextOutA")
m_HkMyTextOutA = LocalHook.Create(iPtrAddress, m_DelMyTextOutA, Me)
m_HkMyTextOutA.ThreadACL.SetExclusiveACL(New Int32() {1})
End If
' Setup PatBlt hooking
If m_HkMyPatBlt Is Nothing Then
Dim iPtrAddress As IntPtr = _
LocalHook.GetProcAddress("gdi32.dll", "PatBlt")
m_HkMyPatBlt = LocalHook.Create(iPtrAddress, m_DelMyPatBlt, Me)
m_HkMyPatBlt.ThreadACL.SetExclusiveACL(New Int32() {1})
End If
Else
' Clean up ExtTextOutW hooking
If m_HkMyTextOutW IsNot Nothing Then
m_HkMyTextOutW.Dispose()
m_HkMyTextOutW = Nothing
End If
' Clean up ExtTextOutA hooking
If m_HkMyTextOutA IsNot Nothing Then
m_HkMyTextOutA.Dispose()
m_HkMyTextOutA = Nothing
End If
' Clean up PatBlt hooking
If m_HkMyPatBlt IsNot Nothing Then
m_HkMyPatBlt.Dispose()
m_HkMyPatBlt = Nothing
End If
' Stop hooking
LocalHook.Release()
End If
Catch ex As Exception
' Log error
End Try
End Sub
API hooking must start and end when our control is created and destroyed:
''' <summary>
''' Ensure hook is in place
''' </summary>
Private Sub TransRtb_HandleCreated(sender As Object, e As EventArgs) _
Handles Me.HandleCreated
HookGdi(True)
End Sub
''' <summary>
''' Ensure hook is terminated
''' </summary>
Private Sub TransRtb_HandleDestroyed(sender As Object, e As EventArgs) _
Handles Me.HandleDestroyed
HookGdi(False)
End Sub
Now all calls to these three APIs will first go through our own methods. For example, for the ExtTextOutW
, the intercepting method will look like this:
''' <summary>
''' Intercept text rendering calls
''' </summary>
Private Function MyExtTextOutW(ByVal hdc As IntPtr, _
ByVal x As Int32, _
ByVal y As Int32, _
ByVal wOptions As Int32, _
ByVal lpRect As IntPtr, _
ByVal lpString As IntPtr, _
ByVal nCount As Int32, _
ByVal lpDx As IntPtr) As Int32
Try
' Only intercept calls to the rendering DC
If m_hDc = hdc Then
' Catch text
pCatchText(hdc, x, y, wOptions, lpRect, lpString, nCount, True)
' Eat call
Return 0
Else
' Keep going
Return SafeNativeMethods.ExtTextOutW_
(hdc, x, y, wOptions, lpRect, lpString, nCount, lpDx)
End If
Catch ex As Exception
' Log error
Return 0
End Try
End Function
The pCatchText
method is where all data is gathered. It's stored in a temporary array of structures that contains all the fields necessary to properly render the text:
''' <summary>
''' Actual text data gathering method
''' </summary>
Private Sub pCatchText(ByVal hdc As IntPtr, _
ByVal x As Int32, _
ByVal y As Int32, _
ByVal wOptions As Int32, _
ByVal lpRect As IntPtr, _
ByVal lpString As IntPtr, _
ByVal nCount As Int32, _
ByVal bUnicode As Boolean)
' Get text passed to API
Dim sText As String = String.Empty
If (wOptions And SafeNativeMethods.ETO_GLYPH_INDEX) = False Then
If bUnicode Then
sText = String.Empty & Marshal.PtrToStringUni(lpString)
Else
sText = String.Empty & Marshal.PtrToStringAnsi(lpString)
End If
sText = sText.Substring(0, nCount)
If (sText = " ") AndAlso _
(sText <> m_sText.Substring(m_iLastStart, nCount)) Then
' Skip internal calls
Return
End If
End If
' Size data array
Dim iNewSize As Integer = 0
If m_atLastTextOut Is Nothing Then
ReDim m_atLastTextOut(iNewSize)
Else
iNewSize = m_atLastTextOut.Length
ReDim Preserve m_atLastTextOut(iNewSize)
End If
With m_atLastTextOut(iNewSize)
' Store original X for possible use in PatBlt
.SourceX = x
' Get backcolor
Dim iBackCol As Int32 = SafeNativeMethods.GetBkColor(hdc)
If wOptions And SafeNativeMethods.ETO_OPAQUE Then
' Text has a background specified
.BackColor = ColorTranslator.FromOle(iBackCol)
Else
' No background color
.BackColor = Color.Transparent
End If
' Get textcolor
Dim iTextCol As Int32 = SafeNativeMethods.GetTextColor(hdc)
.TextColor = ColorTranslator.FromOle(iTextCol)
' Get font
Dim hFnt As IntPtr = SafeNativeMethods.GetCurrentObject_
(hdc, SafeNativeMethods.OBJ_FONT)
.Font = System.Drawing.Font.FromHfont(hFnt)
' Get setting for RTL text
.IsRTL = ((SafeNativeMethods.GetTextAlign(hdc) _
And SafeNativeMethods.TA_UPDATECP) = SafeNativeMethods.TA_UPDATECP)
' Get text
If wOptions And SafeNativeMethods.ETO_GLYPH_INDEX Then
' Get string from cached text to avoid uncertain conversion
' from glyph to unicode
.Text = m_sText.Substring(m_iLastStart, nCount)
Else
' Get text passed to API (safest way)
.Text = sText
End If
' Offset text start
m_iLastStart += nCount
' Get location
.Location = New Point(x, y)
' Get line height
If lpRect.ToInt32 <> 0 Then
Dim tRc As SafeNativeMethods.RECT = _
Marshal.PtrToStructure(lpRect, New SafeNativeMethods.RECT().GetType)
.LineTop = tRc.Top
.LineBottom = tRc.Bottom
End If
End With
End Sub
We now have all the information needed to render the Rich Text contained in the control via GDI+.
Transparent Image Please
Our control will extend the RichTextBox
by adding an Image property which returns a 32bpp transparent bitmap (of the same size of the control), containing the text found in the control when the Image
method is called.
The concept is simple:
- Create a GDI+ Graphics, get the associated DC and call the
EM_FORMATRANGE
message. - While GDI processing takes place, we gather all the information we need to render the text ourselves, and by eating the calls, we'll prevent GDI rendering to actually take place.
- When
SendMessage
returns, GDI processing is done and we are now ready to render text with GDI+.
''' <summary>
''' Get a 32bpp GDI+ transparent image of the text with antialias
''' </summary>
Public ReadOnly Property Image() As Image
Get
' Init
Dim oOut As Bitmap = Nothing
Dim oGfx As Graphics = Nothing
Try
' Build the new image
oOut = New Bitmap(MyBase.ClientSize.Width, MyBase.ClientSize.Height)
' Build a Graphic object for the image
oGfx = Graphics.FromImage(oOut)
oGfx.PageUnit = GraphicsUnit.Pixel
' Turn off smoothing mode to avoid antialias on backcolor rectangles
oGfx.SmoothingMode = SmoothingMode.None
' Set text rendering and contrast
oGfx.TextRenderingHint = m_eAntiAlias
oGfx.TextContrast = m_iContrast
' Define inch factor based on current screen resolution
Dim snInchX As Single = 1440 / oGfx.DpiX
Dim snInchY As Single = 1440 / oGfx.DpiY
' Calculate the area to render.
Dim rectLayoutArea As SafeNativeMethods.RECT
rectLayoutArea.Right = CInt(oOut.Width * snInchX)
rectLayoutArea.Bottom = CInt(oOut.Height * _
snInchY * 2) ' Ensure no integral height
' Create FORMATRANGE and include the whole range of text
Dim fmtRange As SafeNativeMethods.FORMATRANGE
fmtRange.chrg.cpMax = -1
fmtRange.chrg.cpMin = 0
' Get DC of the GDI+ Graphics
' This will lock the Graphics object, so all GDI+
' rendering must take place after releasing
m_hDc = oGfx.GetHdc
' Use the same DC for measuring and rendering
fmtRange.hdc = m_hDc
fmtRange.hdcTarget = m_hDc
' Set layout area
fmtRange.rc = rectLayoutArea
' Indicate the area on page to print
fmtRange.rcPage = rectLayoutArea
' Specify that we want actual drawing
Dim wParam As New IntPtr(1)
' Get the pointer to the FORMATRANGE structure in memory
Dim lParam As IntPtr = _
Marshal.AllocCoTaskMem(Marshal.SizeOf(fmtRange))
Marshal.StructureToPtr(fmtRange, lParam, False)
' Get array of data ready
Erase m_atLastTextOut
' Cache text contents
m_sText = MyBase.Text.Replace(vbLf, String.Empty)
m_iLastStart = 0
' Actual rendering message.
' After this instruction is executed, API interception starts
' and takes place in MyExtTextOutA, MyExtTextOutW and MyPatBlt
SafeNativeMethods.SendMessage(MyBase.Handle, _
SafeNativeMethods.EM_FORMATRANGE, _
wParam, lParam)
' Done intercepting, release Graphics DC and clean up
oGfx.ReleaseHdc()
m_hDc = IntPtr.Zero
' Sanity check
If m_atLastTextOut.Length = 0 Then Return oOut
' Cycle through each piece of gathered information
For iItm As Integer = m_atLastTextOut.GetLowerBound(0) _
To m_atLastTextOut.GetUpperBound(0)
' Get item
Dim tData As MAYATEXTOUT = m_atLastTextOut(iItm)
' Check for text
If (tData.Text IsNot Nothing) _
AndAlso (tData.Text.Length > 0) Then
' Define a string format
Using oStrFormat As StringFormat = _
StringFormat.GenericTypographic
' Measure spaces
oStrFormat.FormatFlags = oStrFormat.FormatFlags Or _
StringFormatFlags.MeasureTrailingSpaces Or _
StringFormatFlags.NoWrap
' Set rtf flag as needed
If tData.IsRTL Then
oStrFormat.FormatFlags = oStrFormat.FormatFlags _
Or StringFormatFlags.DirectionRightToLeft
End If
' Get text size
' We'll use MeasureCharacterRanges
' even though it's a single range
' since it has proven to be more reliable
' then Graphics.MeasureString
If tData.Text.Length Then
Dim aoRng(0) As CharacterRange
aoRng(0).First = 0
aoRng(0).Length = tData.Text.Length
oStrFormat.SetMeasurableCharacterRanges(aoRng)
Dim oaRgn() As Region = oGfx.MeasureCharacterRanges_
(tData.Text, tData.Font, _
New RectangleF(0, 0, oOut.Width * 2, _
oOut.Height * 2), oStrFormat)
tData.Bounds = oaRgn(0).GetBounds(oGfx)
oaRgn(0).Dispose()
oaRgn(0) = Nothing
End If
' Define text rectangle
tData.Destination = New RectangleF(tData.Location.X + _
tData.Bounds.Left, tData.Location.Y, _
tData.Bounds.Width, tData.Bounds.Height)
' Define background rectangle
Dim tRcBack As New RectangleF(tData.Destination.Left + _
tData.Bounds.Left, tData.LineTop, _
tData.Destination.Width, _
tData.LineBottom - tData.LineTop)
' Check for background color
If tData.BackColor.ToArgb <> Color.Transparent.ToArgb Then
' Draw background
Using oBr As New SolidBrush(tData.BackColor)
oGfx.FillRectangle(oBr, tRcBack)
End Using
End If
' Build text color brush
Using oBr As New SolidBrush(tData.TextColor)
' Draw string
oGfx.DrawString(tData.Text, tData.Font, oBr, _
New Point(tData.Destination.Left, _
tData.Destination.Top), oStrFormat)
' Draw Underline and/or Strikethrough
If tData.Line1Height Then
oGfx.FillRectangle(oBr, New RectangleF_
(tRcBack.Left, tData.Line1Top, _
tRcBack.Width, tData.Line1Height))
End If
If tData.Line2Height Then
oGfx.FillRectangle(oBr, New RectangleF_
(tRcBack.Left, tData.Line2Top, _
tRcBack.Width, tData.Line2Height))
End If
End Using
End Using
End If
' Clean up font
If tData.Font IsNot Nothing Then
tData.Font.Dispose()
tData.Font = Nothing
End If
Next iItm
Catch ex As Exception
' Log error
Finally
' Clean up Graphics
If oGfx IsNot Nothing Then
oGfx.Dispose()
oGfx = Nothing
End If
End Try
' Return
Return oOut
End Get
End Property
GDI+ vs GDI
This is a battle as old as GDI+, which has a completely different text rendering engine. As you will see when you run the project, by double-clicking the text, the TransRtb
control becomes visible. By clicking outside its area, it disappears, displaying the text rendered with GDI+. And a few differences become evident: text length, antialias, character spacing, and so on.
You will have to figure out yourself the best way to tweak your GDI+ Graphics
object to obtain the solution that fits your needs.
Extending the Control
The TransRtb
control implements a few properties to get/set text style and effects on the current selection that seem to be missing from the original implementation.
It also exposes the Antialias
and Contrast
properties to get/set the way text is rendered in GDI+.
As this is only an example aimed at illustrating this technique, much more can be implemented. The RichEdit
control also supports images, superscript and subscript text effects which are not dealt with in this project.
Please drop a line if you add to the control, if you find any bugs or ways to make it better.
History
- 9th September, 2018: First release