65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 GDI+ 绘制富文本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (17投票s)

2018年9月9日

CPOL

6分钟阅读

viewsIcon

32001

downloadIcon

1649

通过利用 API Hooking 的强大功能,使用 GDI+ 渲染富文本

引言

和许多人一样,我曾在网上花费无数小时寻找一种方法,能够 **使用 GDI+ 渲染富文本**,而无需自己编写 RTF 解析器和(快速)单词断行算法。我从未找到令人满意的解决方案,因此我在此发布此文,希望它能帮助您节省时间。

背景

该项目设计的典型场景是具有分层图像编辑器的 Windows 应用程序。

用户可以创建由图片和文本组成的复合图像,编辑器中包含一个 RTF 控件,允许您输入和编辑 RichText,然后该文本将 **通过 GDI+ 进行抗锯齿渲染** 到一个 **32bpp 透明图像**上,该图像将代表复合图像中的一个图层。

RTF rendering project screenshot

抗锯齿问题

您可能知道,通过向控件发送 EM_FORMATRANGE 消息,然后使用 SendMessage,有一种快速将 Rich Text 从 RichEdit 控件渲染到 DC 的方法。这种方法很棒,当您只需要在静态背景上渲染文本时,它是完美的解决方案。

但如果您想将富文本渲染到 **透明背景** 上呢?
那么事情就会变得很麻烦,因为上述方法将不起作用。

标准的 GDI 文本抗锯齿渲染方法旨在基于背景颜色进行 alpha 混合。如果您想在透明图像上渲染,**抗锯齿会丢失**,导致您得到可怕的 **结果**。

GDI rendered text

创建透明 RichEdit 控件

对于我们的复合编辑器,第一步是继承 RichTextBox 类来实现我们自己的 RichEdit 控件。这将作为 GUI 输入元素,用户将通过它直接在我们正在编辑的复合图像上输入和编辑 Rich Text。

Friend Class TransRtb
    Inherits RichTextBox

此处所示,有一种简单的方法可以创建透明的 RichEdit 控件(.NET 中的 RichTextBox)。您需要做的就是在创建窗口时设置 WS_EX_TRANSPARENT 样式,并相应地设置 ControlStyle 标志。

    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

对于任何只需要让用户能够在图像之上输入富文本能力的人来说,这本身就是一个很棒的解决方案。

Transparent RichTextBox on top of an image

它将以抗锯齿方式渲染文本,并且经过一些工作,您可以实现自己的移动和调整大小方法。
请参阅我的技巧/窍门 使用 GDI+ 调整和旋转形状。请注意,您无法旋转 Windows 控件。

定义 GDI+ 渲染策略

现在,如果您需要实际 **将富文本渲染到带有抗锯齿效果的透明位图上**,事情就会变得棘手。
您基本上只有几个选择:

  1. 编写自己的 RTF 解析器和单词换行算法(祝你好运)。
  2. 继续阅读本文。

我个人在过去 15 年的项目中选择了选项 1 的道路,并且仍在修复错误。

这里的想法很简单:**我们已经有一个功能齐全的控件**:它测量 string 并定义坐标、位置和渲染文本。可惜我们无法利用现有控件的功能来获取这些信息,并使用它们通过 GDI+ 自身渲染文本。

或者我们可以吗?

间谍,我的名字是 Spy++

在 Windows 中,各种版本的 RichEdit 控件本质上是 RichEditxx.dll 的包装器。通过启动 Spy++ 并关注 gdi32.dll 调用,我们发现所有文本位图都是通过三个 API 调用渲染的:

  1. ExtTextOutW 用于文本和字形渲染。
  2. ExtTextOutA 用于文本背景渲染。
  3. PatBlt 用于下划线和删除线。

通过拦截这些调用,可以收集所有必要的数据,以其他任何方式渲染文本。

现在我们需要一种方法来拦截这些调用。事实证明,有一个很棒的开源库叫做 EasyHook,它可以让我们做到这一点,而无需编写自己的 API 挂钩/劫持方法,这是一项相当复杂而精细的任务。

开始挂钩

您需要将三个 DLL 添加到您的项目中:

  1. EasyHook.dll
  2. EasyHook32.dll
  3. EasyHook64.dll

然后,您的项目将需要引用第一个 EasyHook.dll

在我们实现的 RichTextBox 控件中,我们将通过为每个要拦截的 API 创建一个 delegate 来设置对这三个 API 的挂钩。

    ' 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

我们将添加一个开/关方法来启动和停止挂钩(请参阅附件项目中的 API 声明)。

    ''' <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 挂钩必须在我们控件创建和销毁时开始和结束。

    ''' <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

现在,所有对这三个 API 的调用将首先通过我们自己的方法。例如,对于 ExtTextOutW,拦截方法将如下所示:

    ''' <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

pCatchText 方法是收集所有数据的地方。它存储在一个临时结构数组中,该数组包含正确渲染文本所需的所有字段。

    ''' <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

我们现在拥有了通过 GDI+ 渲染控件中 Rich Text 所需的所有信息。

请提供透明图像

我们的控件将通过添加一个 **Image 属性** 来扩展 RichTextBox,该属性返回一个 32bpp 透明位图(大小与控件相同),其中包含在调用 Image 方法时控件中的文本。

这个概念很简单:

  • 创建一个 GDI+ Graphics 对象,获取关联的 DC,然后调用 EM_FORMATRANGE 消息。
  • 在 GDI 处理进行时,我们收集所有需要的信息以自行渲染文本,并且通过“吞掉”调用,我们将阻止 GDI 渲染的实际发生。
  • SendMessage 返回时,GDI 处理完成,我们现在可以准备使用 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+ 与 GDI

这是 GDI+ 诞生以来就存在的斗争,GDI+ 拥有完全不同的文本渲染引擎。当您运行项目时,您会看到,双击文本,TransRtb 控件就会变得可见。通过点击其区域外,它就会消失,显示使用 GDI+ 渲染的文本。一些差异就会显现出来:文本长度、抗锯齿、字符间距等。

您需要自己找出调整 GDI+ Graphics 对象以获得最适合您需求的解决方案的最佳方法。

扩展控件

TransRtb 控件实现了一些属性,用于获取/设置当前选中文本的样式和效果,这些似乎在原始实现中缺失。

它还公开了 AntialiasContrast 属性,用于获取/设置文本在 GDI+ 中的渲染方式。

由于这只是一个旨在说明该技术的示例,因此还有很多可以实现的。RichEdit 控件还支持图像、上标和下标文本效果,这些在本项目中并未涉及。
如果您对控件进行了改进,发现了任何错误或提出了更好的方法,请留下评论。

历史

  • 2018年9月9日:首次发布
© . All rights reserved.