Click here to Skip to main content
14,025,167 members
Click here to Skip to main content
Add your own
alternative version

Stats

4.3K views
321 downloads
8 bookmarked
Posted 9 Sep 2018
Licenced CPOL

Drawing Rich Text with GDI+

, 10 Sep 2018
Rate this:
Please Sign up or sign in to vote.
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 vis 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 3 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 3 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 3 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 3 API 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

  • September 2018: First release

License

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

Share

About the Author

Andy De Filippo
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

You may also be interested in...

Pro

Comments and Discussions

 
QuestionScrolling Pin
computan10-Feb-19 12:08
membercomputan10-Feb-19 12:08 
QuestionHow can this be made per Monitor DPI Aware? Pin
computan10-Feb-19 12:02
membercomputan10-Feb-19 12:02 
QuestionHow can this be made per Monitor DPI Aware? Pin
computan10-Feb-19 12:02
membercomputan10-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.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web06 | 2.8.190419.4 | Last Updated 10 Sep 2018
Article Copyright 2018 by Andy De Filippo
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid