Click here to Skip to main content
15,867,488 members
Articles / Multimedia / GDI+

Drawing Rich Text with GDI+

Rate me:
Please Sign up or sign in to vote.
4.95/5 (17 votes)
14 Oct 2022CPOL6 min read 28.1K   1.5K   25   9
Render Rich Text with GDI+ by tapping into the power of API hooking
GDI+ does not support rendering of Rich Text and this has always represented a challenge for developers. The approach described in this article offers a solution to such limitation 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:

VB.NET
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:

VB.NET
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:

VB.NET
' 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):

VB.NET
''' <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:

VB.NET
''' <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:

VB.NET
''' <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:

VB.NET
''' <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+.
VB.NET
''' <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

License

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


Written By
Software Developer (Senior)
Spain Spain
Developing Windows desktop applications since 1995.
Works for Delivery Tech Corp. San Diego - USA

Projects:
http://www.labeljoy.com
http://www.newslettercreator.com

Comments and Discussions

 
PraiseThank you for VB.NET Pin
Jeff Bowman17-Oct-22 10:11
professionalJeff Bowman17-Oct-22 10:11 
QuestionMuy Interesante Pin
FAFILI17-Oct-22 5:57
FAFILI17-Oct-22 5:57 
QuestionAnother in the looong line.... Pin
Jeff Rowe13-Oct-22 3:28
Jeff Rowe13-Oct-22 3:28 
AnswerRe: Another in the looong line.... Pin
Andy De Filippo14-Oct-22 1:18
Andy De Filippo14-Oct-22 1:18 
Praiseexcellent! Pin
Southmountain22-May-22 14:08
Southmountain22-May-22 14:08 
QuestionAmazing Pin
B2kguga12-Nov-20 6:11
B2kguga12-Nov-20 6:11 
QuestionCompile Error: could not find Resources\Background.jpg and a dozen other resources Pin
computan12-May-20 11:49
computan12-May-20 11:49 
QuestionScrolling Pin
computan10-Feb-19 12:08
computan10-Feb-19 12:08 
In edit mode after entering more than one page of text, a click outside the edit box causes the text to jump back to the top.
Any idea how to maintain the "virtual" scroll position in the rendered image so that it works in per monitor DPI aware applications?
QuestionHow can this be made per Monitor DPI Aware? Pin
computan10-Feb-19 12:02
computan10-Feb-19 12:02 

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

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