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.
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
Here's a C# conversion of @TnTinMn's VB implementation which is working for me. Thanks to https://github.com/icsharpcode/CodeConverter for doing most of the conversion.
Obviously, change the namespace below as required. Thanks again, @TnTinMn.
RichTextBoxFixedForFriendlyLinks.cs
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using static TestRtf.NativeMthods;
namespace TestRtf
{
public partial class RichTextBoxFixedForFriendlyLinks : RichTextBox
{
internal ENLINK ConvertFromENLINK64(ENLINK64 es64)
{
// 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
var es = new ENLINK();
GCHandle? hndl = null;
try
{
hndl = GCHandle.Alloc(es64.contents, GCHandleType.Pinned);
var es64p = hndl.Value.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
{
if (hndl.HasValue)
hndl.Value.Free();
}
return es;
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_ReflectNotify)
{
NMHDR hdr = (NMHDR)m.GetLParam(typeof(NMHDR));
if (hdr.code == EN_Link)
{
ENLINK lnk;
if (IntPtr.Size == 4)
{
lnk = (ENLINK)m.GetLParam(typeof(ENLINK));
}
else
{
lnk = ConvertFromENLINK64((ENLINK64)m.GetLParam(typeof(ENLINK64)));
}
if (lnk.msg == WM_LBUTTONDOWN)
{
string linkUrl = CharRangeToString(lnk.charrange);
// Still check if linkUrl is not empty
if (!string.IsNullOrEmpty(linkUrl))
{
OnLinkClicked(new LinkClickedEventArgs(linkUrl));
}
m.Result = new IntPtr(1);
return;
}
}
}
base.WndProc(ref m);
}
private string CharRangeToString(CHARRANGE c)
{
string ret = string.Empty;
var txrg = new TEXTRANGE() { 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
// *********
int numCharacters = c.cpMax - c.cpMin + 1; // +1 for null termination
if (numCharacters > 0)
{
var charBuffer = default(CharBuffer);
charBuffer = CharBuffer.CreateBuffer(numCharacters);
IntPtr unmanagedBuffer = IntPtr.Zero;
try
{
unmanagedBuffer = charBuffer.AllocCoTaskMem();
if (unmanagedBuffer == IntPtr.Zero)
{
throw new OutOfMemoryException();
}
txrg.lpstrText = unmanagedBuffer;
IntPtr len = SendMessage(new HandleRef(this, Handle), EM_GETTEXTRANGE, 0, txrg);
if (len != IntPtr.Zero)
{
charBuffer.PutCoTaskMem(unmanagedBuffer);
ret = charBuffer.GetString();
}
}
finally
{
if (txrg.lpstrText != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(unmanagedBuffer);
}
}
}
return ret;
}
}
}
NativeMthods.cs:
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace TestRtf
{
public partial class NativeMthods
{
internal const int EN_Link = 0x70B;
internal const int WM_NOTIFY = 0x4E;
internal const int WM_User = 0x400;
internal const int WM_REFLECT = WM_User + 0x1C00;
internal const int WM_ReflectNotify = WM_REFLECT | WM_NOTIFY;
internal const int WM_LBUTTONDOWN = 0x201;
internal const int EM_GETTEXTRANGE = WM_User + 75;
public partial struct NMHDR
{
public IntPtr hwndFrom;
public IntPtr idFrom; // This is declared as UINT_PTR in winuser.h
public int code;
}
[StructLayout(LayoutKind.Sequential)]
public partial class ENLINK
{
public NMHDR nmhdr;
public int msg = 0;
public IntPtr wParam = IntPtr.Zero;
public IntPtr lParam = IntPtr.Zero;
public CHARRANGE charrange = null;
}
[StructLayout(LayoutKind.Sequential)]
public partial class ENLINK64
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 56)]
public byte[] contents = new byte[56];
}
[StructLayout(LayoutKind.Sequential)]
public partial class CHARRANGE
{
public int cpMin;
public int cpMax;
}
[StructLayout(LayoutKind.Sequential)]
public partial class TEXTRANGE
{
public CHARRANGE chrg;
public IntPtr lpstrText; // allocated by caller, zero terminated by RichEdit
}
public abstract partial class CharBuffer
{
public static CharBuffer CreateBuffer(int size)
{
if (Marshal.SystemDefaultCharSize == 1)
{
return new AnsiCharBuffer(size);
}
return new UnicodeCharBuffer(size);
}
public abstract IntPtr AllocCoTaskMem();
public abstract string GetString();
public abstract void PutCoTaskMem(IntPtr ptr);
public abstract void PutString(string s);
}
public partial class AnsiCharBuffer : CharBuffer
{
internal byte[] buffer;
internal int offset;
public AnsiCharBuffer(int size)
{
buffer = new byte[size];
}
public override IntPtr AllocCoTaskMem()
{
var result = Marshal.AllocCoTaskMem(buffer.Length);
Marshal.Copy(buffer, 0, result, buffer.Length);
return result;
}
public override string GetString()
{
int i = offset;
while (i < buffer.Length && buffer[i] != 0)
i += 1;
string result = Encoding.Default.GetString(buffer, offset, i - offset);
if (i < buffer.Length)
{
i += 1;
}
offset = i;
return result;
}
public override void PutCoTaskMem(IntPtr ptr)
{
Marshal.Copy(ptr, buffer, 0, buffer.Length);
offset = 0;
}
public override void PutString(string s)
{
var bytes = Encoding.Default.GetBytes(s);
int count = Math.Min(bytes.Length, buffer.Length - offset);
Array.Copy(bytes, 0, buffer, offset, count);
offset += count;
if (offset < buffer.Length)
{
buffer[offset] = 0;
offset += 1;
}
}
}
public partial class UnicodeCharBuffer : CharBuffer
{
internal char[] buffer;
internal int offset;
public UnicodeCharBuffer(int size)
{
buffer = new char[size];
}
public override IntPtr AllocCoTaskMem()
{
var result = Marshal.AllocCoTaskMem(buffer.Length * 2);
Marshal.Copy(buffer, 0, result, buffer.Length);
return result;
}
public override string GetString()
{
int i = offset;
while (i < buffer.Length && buffer[i] != 0)
i += 1;
string result = new string(buffer, offset, i - offset);
if (i < buffer.Length)
{
i += 1;
}
offset = i;
return result;
}
public override void PutCoTaskMem(IntPtr ptr)
{
Marshal.Copy(ptr, buffer, 0, buffer.Length);
offset = 0;
}
public override void PutString(string s)
{
int count = Math.Min(s.Length, buffer.Length - offset);
s.CopyTo(0, buffer, offset, count);
offset += count;
if (offset < buffer.Length)
{
buffer[offset] = '\0';
offset += 1;
}
}
}
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern IntPtr SendMessage(HandleRef hWnd, int msg, int wParam, TEXTRANGE lParam);
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With