使用 GDI+ 绘制富文本






4.95/5 (17投票s)
通过利用 API Hooking 的强大功能,
引言
和许多人一样,我曾在网上花费无数小时寻找一种方法,能够 **使用 GDI+ 渲染富文本**,而无需自己编写 RTF 解析器和(快速)单词断行算法。我从未找到令人满意的解决方案,因此我在此发布此文,希望它能帮助您节省时间。
背景
该项目设计的典型场景是具有分层图像编辑器的 Windows 应用程序。
用户可以创建由图片和文本组成的复合图像,编辑器中包含一个 RTF 控件,允许您输入和编辑 RichText
,然后该文本将 **通过 GDI+ 进行抗锯齿渲染** 到一个 **32bpp 透明图像**上,该图像将代表复合图像中的一个图层。
抗锯齿问题
您可能知道,通过向控件发送 EM_FORMATRANGE
消息,然后使用 SendMessage
,有一种快速将 Rich Text 从 RichEdit 控件渲染到 DC 的方法。这种方法很棒,当您只需要在静态背景上渲染文本时,它是完美的解决方案。
但如果您想将富文本渲染到 **透明背景** 上呢?
那么事情就会变得很麻烦,因为上述方法将不起作用。
标准的 GDI 文本抗锯齿渲染方法旨在基于背景颜色进行 alpha 混合。如果您想在透明图像上渲染,**抗锯齿会丢失**,导致您得到可怕的 **结果**。
创建透明 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
对于任何只需要让用户能够在图像之上输入富文本能力的人来说,这本身就是一个很棒的解决方案。
它将以抗锯齿方式渲染文本,并且经过一些工作,您可以实现自己的移动和调整大小方法。
请参阅我的技巧/窍门 使用 GDI+ 调整和旋转形状。请注意,您无法旋转 Windows 控件。
定义 GDI+ 渲染策略
现在,如果您需要实际 **将富文本渲染到带有抗锯齿效果的透明位图上**,事情就会变得棘手。
您基本上只有几个选择:
- 编写自己的 RTF 解析器和单词换行算法(祝你好运)。
- 继续阅读本文。
我个人在过去 15 年的项目中选择了选项 1 的道路,并且仍在修复错误。
这里的想法很简单:**我们已经有一个功能齐全的控件**:它测量 string
并定义坐标、位置和渲染文本。可惜我们无法利用现有控件的功能来获取这些信息,并使用它们通过 GDI+ 自身渲染文本。
或者我们可以吗?
间谍,我的名字是 Spy++
在 Windows 中,各种版本的 RichEdit
控件本质上是 RichEditxx.dll 的包装器。通过启动 Spy++ 并关注 gdi32.dll 调用,我们发现所有文本位图都是通过三个 API 调用渲染的:
- ExtTextOutW 用于文本和字形渲染。
- ExtTextOutA 用于文本背景渲染。
- PatBlt 用于下划线和删除线。
通过拦截这些调用,可以收集所有必要的数据,以其他任何方式渲染文本。
现在我们需要一种方法来拦截这些调用。事实证明,有一个很棒的开源库叫做 EasyHook,它可以让我们做到这一点,而无需编写自己的 API 挂钩/劫持方法,这是一项相当复杂而精细的任务。
开始挂钩
您需要将三个 DLL 添加到您的项目中:
- EasyHook.dll
- EasyHook32.dll 和
- 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
控件实现了一些属性,用于获取/设置当前选中文本的样式和效果,这些似乎在原始实现中缺失。
它还公开了 Antialias
和 Contrast
属性,用于获取/设置文本在 GDI+ 中的渲染方式。
由于这只是一个旨在说明该技术的示例,因此还有很多可以实现的。RichEdit
控件还支持图像、上标和下标文本效果,这些在本项目中并未涉及。
如果您对控件进行了改进,发现了任何错误或提出了更好的方法,请留下评论。
历史
- 2018年9月9日:首次发布