Search code examples
c#vb.netwinformsrichtextboxrtf

Some hyperlinks don't trigger the LinkClicked event when loading a file into RichTextBox


In a simple Windows Form Application in .NET 4.7, I only have a RichTextBox on my form. I'm loading a *.rtf file from my local that has been created in MS Word 2016. The hyperlinks have been set in Word. The issue is that not all the links trigger the LinkClicked event when clicking the hyperlink in the application.

The behaviour is as follows: If the hyperlink is followed by enough characters (which varies), it's be triggered by the LinkClicked event. If I remove the characters that follow the hyperlink, it won't trigger the event.

After doing some testing, the number of characters that need to inserted after the last URL are equivalent to the total characters of all the URLs in the *.rtf file being loaded.

I can't post an image, the words in brackets are the hyperlink

Doesn't work: [Click here] for more information.

{\rtf1\ansi\ansicpg1252\deff0\nouicompat\deflang4105{\fonttbl{\f0\fnil\fcharset0 Calibri;}}
{\*\generator Riched20 10.0.17134}\viewkind4\uc1 
{\field{\*\fldinst { HYPERLINK "http://www.google.com" }}{\fldrslt {Click here}}}
\pard\sa200\sl276\slmult1\f0\fs22\lang9  for more information.\par
}

Works: [Click here] for more information. Lorem ipsum

{\rtf1\ansi\ansicpg1252\deff0\nouicompat\deflang4105{\fonttbl{\f0\fnil\fcharset0 Calibri;}}
{\*\generator Riched20 10.0.17134}\viewkind4\uc1 
{\field{\*\fldinst { HYPERLINK "http://www.google.com" }}{\fldrslt {Click here}}}
\pard\sa200\sl276\slmult1\f0\fs22\lang9  for more information. Lorem ipsum\par
}

The number of characters needed for the link to work vary between approximately 20 and approximately 100 characters.

I created a small project to make sure the issue didn't stem from anywhere else in the main project. The project only contains a RichTextBox. I have set the DetectUrls to True, which made no difference. I've also tried creating the *.rtf file in Google Docs to check if the version of Word might be the issue. I also tested with WordPad, including the URLs manually in Notepad++. The issue doesn't occur in .NET Framework 4.6, but I have a requirement to use .NET 4.7. If I'm adding the link dynamically, the issue also doesn't occur, but I can't do that per my requirement.

Public Sub Form1_Load(ByVal eventSender As System.Object, ByVal eventArgs As System.EventArgs) Handles MyBase.Load

        Dim LoadFileName As Object

        LoadFileName = "C:\Users\anononym\source\repos\WindowsApp1\Test.rtf"

        RichTextBox1.LoadFile(LoadFileName, RichTextBoxStreamType.RichText)

End Sub

Private Sub RichTextBox_LinkClicked(sender As Object, e As LinkClickedEventArgs) Handles RichTextBox1.LinkClicked
        System.Diagnostics.Process.Start(e.LinkText)
End Sub

The expected result is for the hyperlink to redirect to the website set in Word in all cases, I used www.google.com for testing.


Solution

  • Starting with .NET 4.7, the RichTextBox uses the RichEdit50 control; prior versions used the RichEdit20 control. I do not know the reason for differences in the handling of hyperlinks between the control versions, but there evidently are some differences.

    A work-around is to configure your .NET 4.7 application to use the older control. This is done by adding the following to your App.config file.

    <runtime>
      <AppContextSwitchOverrides value="Switch.System.Windows.Forms.DoNotLoadLatestRichEditControl=true" />
    </runtime>
    

    The source of the problem appears to be a hack in the original RichTextBox.CharRangeToString method.

            //Windows bug: 64-bit windows returns a bad range for us.  VSWhidbey 504502.  
            //Putting in a hack to avoid an unhandled exception.
            if (c.cpMax > Text.Length || c.cpMax-c.cpMin <= 0) {
                return string.Empty;
            }
    

    When using the Friendly Name Hyperlinks available in the RichEdit50 control, the RichTextBox.Text.Length property can be less than the c.cpMax value as the link is not included in the returned property value. This causes the method to return String.Empty to the calling RichTextBox.EnLinkMsgHandler method that in turn will not raise the LickClicked event if a Empty.String is returned.

                case NativeMethods.WM_LBUTTONDOWN:
                    string linktext = CharRangeToString(enlink.charrange);
                    if (!string.IsNullOrEmpty(linktext))
                    {
                        OnLinkClicked(new LinkClickedEventArgs(linktext));
                    }
                    m.Result = (IntPtr)1;
                    return;
    

    To deal with this bug, a custom RichTextBox class is defined below to modify the logic of the CharRangeToString method. This modified logic is invoked in the WndProc procedure to bypass the default logic.

    Imports System.Runtime.InteropServices
    Imports WindowsApp2.NativeMthods ' *** change WindowsApp2 to match your project
    
    Public Class RichTextBoxFixedForFriendlyLinks : Inherits RichTextBox
    
      Friend Function ConvertFromENLINK64(es64 As ENLINK64) As ENLINK
        ' Note: the RichTextBox.ConvertFromENLINK64 method is written using C# unsafe code
        ' this is version uses a GCHandle to pin the byte array so that 
        ' the same Marshal.Read_Xyz methods can be used
    
        Dim es As New ENLINK()
        Dim hndl As GCHandle
        Try
          hndl = GCHandle.Alloc(es64.contents, GCHandleType.Pinned)
    
          Dim es64p As IntPtr = hndl.AddrOfPinnedObject
          es.nmhdr = New NMHDR()
          es.charrange = New CHARRANGE()
    
          es.nmhdr.hwndFrom = Marshal.ReadIntPtr(es64p)
          es.nmhdr.idFrom = Marshal.ReadIntPtr(es64p + 8)
          es.nmhdr.code = Marshal.ReadInt32(es64p + 16)
          es.msg = Marshal.ReadInt32(es64p + 24)
          es.wParam = Marshal.ReadIntPtr(es64p + 28)
          es.lParam = Marshal.ReadIntPtr(es64p + 36)
          es.charrange.cpMin = Marshal.ReadInt32(es64p + 44)
          es.charrange.cpMax = Marshal.ReadInt32(es64p + 48)
        Finally
          hndl.Free()
        End Try
    
        Return es
      End Function
    
      Protected Overrides Sub WndProc(ByRef m As Message)
        If m.Msg = WM_ReflectNotify Then
          Dim hdr As NMHDR = CType(m.GetLParam(GetType(NMHDR)), NMHDR)
          If hdr.code = EN_Link Then
    
            Dim lnk As ENLINK
    
            If IntPtr.Size = 4 Then
              lnk = CType(m.GetLParam(GetType(ENLINK)), ENLINK)
            Else
              lnk = ConvertFromENLINK64(CType(m.GetLParam(GetType(ENLINK64)), ENLINK64))
            End If
    
            If lnk.msg = WM_LBUTTONDOWN Then
              Dim linkUrl As String = CharRangeToString(lnk.charrange)
              ' Still check if linkUrl is not empty
              If Not String.IsNullOrEmpty(linkUrl) Then
                OnLinkClicked(New LinkClickedEventArgs(linkUrl))
              End If
              m.Result = New IntPtr(1)
              Exit Sub
            End If
    
          End If
        End If
    
        MyBase.WndProc(m)
      End Sub
    
      Private Function CharRangeToString(ByVal c As CHARRANGE) As String
        Dim ret As String = String.Empty
        Dim txrg As New TEXTRANGE With {.chrg = c}
    
        ''Windows bug: 64-bit windows returns a bad range for us.  VSWhidbey 504502.  
        ''Putting in a hack to avoid an unhandled exception.
        'If c.cpMax > Text.Length OrElse c.cpMax - c.cpMin <= 0 Then
        '  Return String.Empty
        'End If
    
        ' *********
        ' c.cpMax can be greater than Text.Length if using friendly links
        ' with RichEdit50. so that check is not valid.  
    
        ' instead of the hack above, first check that the number of characters is positive 
        ' and then use the result of sending EM_GETTEXTRANGE  to handle the 
        ' possibilty of Text.Length < c.cpMax
        ' *********
    
        Dim numCharacters As Int32 = (c.cpMax - c.cpMin) + 1 ' +1 for null termination
        If numCharacters > 0 Then
          Dim charBuffer As CharBuffer
    
          charBuffer = CharBuffer.CreateBuffer(numCharacters)
          Dim unmanagedBuffer As IntPtr
    
          Try
            unmanagedBuffer = charBuffer.AllocCoTaskMem()
            If unmanagedBuffer = IntPtr.Zero Then
              Throw New OutOfMemoryException()
            End If
    
            txrg.lpstrText = unmanagedBuffer
            Dim len As Int32 = CInt(SendMessage(New HandleRef(Me, Handle), EM_GETTEXTRANGE, 0, txrg))
    
            If len > 0 Then
              charBuffer.PutCoTaskMem(unmanagedBuffer)
              ret = charBuffer.GetString()
            End If
          Finally
            If txrg.lpstrText <> IntPtr.Zero Then
              Marshal.FreeCoTaskMem(unmanagedBuffer)
            End If
          End Try
        End If
    
        Return ret
      End Function
    End Class
    

    While the above code is not that substantial, it requires several methods/structures from the base implementation that are not publicly accessible. A VB version of the methods is presented below. Most are direct conversions from the original C# source.

    Imports System.Runtime.InteropServices
    Imports System.Text
    
    Public Class NativeMthods
    
      Friend Const EN_Link As Int32 = &H70B
      Friend Const WM_NOTIFY As Int32 = &H4E
      Friend Const WM_User As Int32 = &H400
      Friend Const WM_REFLECT As Int32 = WM_User + &H1C00
      Friend Const WM_ReflectNotify As Int32 = WM_REFLECT Or WM_NOTIFY
      Friend Const WM_LBUTTONDOWN As Int32 = &H201
      Friend Const EM_GETTEXTRANGE As Int32 = WM_User + 75
    
      Public Structure NMHDR
        Public hwndFrom As IntPtr
        Public idFrom As IntPtr 'This is declared as UINT_PTR in winuser.h
        Public code As Int32
      End Structure
    
      <StructLayout(LayoutKind.Sequential)>
      Public Class ENLINK
        Public nmhdr As NMHDR
        Public msg As Int32 = 0
        Public wParam As IntPtr = IntPtr.Zero
        Public lParam As IntPtr = IntPtr.Zero
        Public charrange As CHARRANGE = Nothing
      End Class
    
      <StructLayout(LayoutKind.Sequential)>
      Public Class ENLINK64
        <MarshalAs(UnmanagedType.ByValArray, SizeConst:=56)>
        Public contents(0 To 55) As Byte
      End Class
    
      <StructLayout(LayoutKind.Sequential)>
      Public Class CHARRANGE
        Public cpMin As Int32
        Public cpMax As Int32
      End Class
    
      <StructLayout(LayoutKind.Sequential)>
      Public Class TEXTRANGE
        Public chrg As CHARRANGE
        Public lpstrText As IntPtr ' allocated by caller, zero terminated by RichEdit
      End Class
    
      Public MustInherit Class CharBuffer
        Public Shared Function CreateBuffer(ByVal size As Int32) As CharBuffer
          If Marshal.SystemDefaultCharSize = 1 Then
            Return New AnsiCharBuffer(size)
          End If
          Return New UnicodeCharBuffer(size)
        End Function
    
        Public MustOverride Function AllocCoTaskMem() As IntPtr
        Public MustOverride Function GetString() As String
        Public MustOverride Sub PutCoTaskMem(ByVal ptr As IntPtr)
        Public MustOverride Sub PutString(ByVal s As String)
      End Class
    
      Public Class AnsiCharBuffer : Inherits CharBuffer
        Friend buffer() As Byte
        Friend offset As Int32
    
        Public Sub New(ByVal size As Int32)
          buffer = New Byte(0 To size - 1) {}
        End Sub
    
        Public Overrides Function AllocCoTaskMem() As IntPtr
          Dim result As IntPtr = Marshal.AllocCoTaskMem(buffer.Length)
          Marshal.Copy(buffer, 0, result, buffer.Length)
          Return result
        End Function
    
        Public Overrides Function GetString() As String
          Dim i As Int32 = offset
          Do While i < buffer.Length AndAlso buffer(i) <> 0
            i += 1
          Loop
          Dim result As String = Encoding.Default.GetString(buffer, offset, i - offset)
          If i < buffer.Length Then
            i += 1
          End If
          offset = i
          Return result
        End Function
    
        Public Overrides Sub PutCoTaskMem(ByVal ptr As IntPtr)
          Marshal.Copy(ptr, buffer, 0, buffer.Length)
          offset = 0
        End Sub
    
        Public Overrides Sub PutString(ByVal s As String)
          Dim bytes() As Byte = Encoding.Default.GetBytes(s)
          Dim count As Int32 = Math.Min(bytes.Length, buffer.Length - offset)
          Array.Copy(bytes, 0, buffer, offset, count)
          offset += count
          If offset < buffer.Length Then
            buffer(offset) = 0
            offset += 1
          End If
        End Sub
      End Class
    
      Public Class UnicodeCharBuffer : Inherits CharBuffer
        Friend buffer() As Char
        Friend offset As Int32
    
        Public Sub New(ByVal size As Int32)
          buffer = New Char(size - 1) {}
        End Sub
    
        Public Overrides Function AllocCoTaskMem() As IntPtr
          Dim result As IntPtr = Marshal.AllocCoTaskMem(buffer.Length * 2)
          Marshal.Copy(buffer, 0, result, buffer.Length)
          Return result
        End Function
    
        Public Overrides Function GetString() As String
          Dim i As Int32 = offset
          Do While i < buffer.Length AndAlso AscW(buffer(i)) <> 0
            i += 1
          Loop
          Dim result As New String(buffer, offset, i - offset)
          If i < buffer.Length Then
            i += 1
          End If
          offset = i
          Return result
        End Function
    
        Public Overrides Sub PutCoTaskMem(ByVal ptr As IntPtr)
          Marshal.Copy(ptr, buffer, 0, buffer.Length)
          offset = 0
        End Sub
    
        Public Overrides Sub PutString(ByVal s As String)
          Dim count As Int32 = Math.Min(s.Length, buffer.Length - offset)
          s.CopyTo(0, buffer, offset, count)
          offset += count
          If offset < buffer.Length Then
            buffer(offset) = ChrW(0)
            offset += 1
          End If
        End Sub
      End Class
    
      <DllImport("user32.dll", CharSet:=CharSet.Auto)>
      Public Shared Function SendMessage(ByVal hWnd As HandleRef, ByVal msg As Int32, ByVal wParam As Int32, ByVal lParam As TEXTRANGE) As IntPtr
      End Function
    
    End Class
    

    Add these classes to our project and perform a build. RichTextBoxFixedForFriendlyLinks should be available in the Toolbox. You can use it where you would normally use the RichTextBox control.


    This issue has been posted on MS Developer Community as: WinForm RichTextBox LinkClicked event fails to fire when control loaded with RTF containing a friendly name hyperlink