65.9K
CodeProject is changing. Read more.
Home

Drawing Rich Text with GDI+

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (17 votes)

Sep 9, 2018

CPOL

6 min read

viewsIcon

30988

downloadIcon

1575

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.

RTF rendering project screenshot

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.

GDI rendered text

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.

Transparent RichTextBox on top of an image

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:

  1. Write your own RTF parser and word-wrap algorithm (good luck with that).
  2. 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 strings 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:

  1. ExtTextOutW for text and glyph rendering
  2. ExtTextOutA for text background rendering
  3. 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:

  1. EasyHook.dll
  2. EasyHook32.dll and
  3. 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