在处理用户输入的长文本字段时,经常需要将其中的URL地址转换为HTML中的"A"元素,即实现所谓的"链接化"。本文将介绍选择的处理方式,以及为何主要忽略了URL的正式规范,而专注于文本中寻找URL的其他因素。
如果需要在文本中找到URL,可能会在网上搜索现有的解决方案。会发现许多文章和示例展示了如何使用正则表达式模式来找到URL。找到的解决方案似乎都不适合应用程序。
发现的解决方案更关注URL的正式规范,而不是在用户输入的文本中找到URL的实际问题。多位作者描述了一个基本问题:URL是复杂的,很难在纯文本中检测到它们,因为决定URL中可以和不可以包含哪些字符的规则,往往与最终用户在文本中输入它们的方式不一致。
有效的URL可能包含广泛的字符,对于URL的域、路径、查询和片段部分有特定的字符规则。因此,匹配URL的正则表达式是长且复杂的。找到的模式从几十个字符到2000个字符不等,而且没有一个声称能够准确匹配任何给定文本中的所有有效URL。
一些示例模式排除了作者想要忽略的URL,例如"http://localhost"等。移除或修改这些排除项需要逆向工程这些模式,虽然可能不得不在将模式包含在应用程序之前这样做,但并不期待这样做!
发现的模式的更重要的问题是未能准确检测URL的结尾。例如,URL可能包括括号,这在维基百科上很常见,这证明是具有挑战性的,因为人们经常将URL包裹在括号中,即"(http://www.example.com)"。发现的模式将尾随的)包含在URL中。
在需要处理的文本中,结尾标点经常跟随URL,像这样:"Is it http://example.com?"大多数标点符号在URL中是有效的,包括常见的句子结尾标点,如问号、逗号、句号和感叹号,但当这些标点符号后面跟着空格或文本结束时,标点符号通常不是URL的一部分。发现的模式没有考虑到这些情况。
与其使用一个不适合要求的复杂正则表达式模式,或者尝试修改这些模式以满足要求,选择了一种不同的方法。
对于方法,决定专注于URL候选项周围的文本,并定义了一组相对简单的规则:
理解这些规则不会检测到所有URL(假阴性),并且会检测到无效的URL(假阳性)。对于应用程序,可以容忍这些失败。可以指导用户如何调整他们的文本以避免有问题的结构问题。
ReplaceUrls()是一个字符串扩展,它接受一个委托方法。它找到了URL,但留给委托来提供替换文本。通常,委托会将URL包装在HTML中以链接化它。根据其自身的逻辑,委托可能会选择忽略URL,通过返回URL文本不变。
ReplaceUrls()使用一个简单的正则表达式来找到URL的开始,并且根据URL是否被分隔符对包围来使用两种方法之一来找到每个URL的结尾。
GetUrlDelimiter()方法检查文本以确定URL是否被分隔符对包围。有效的分隔符是(和)、[和]、«和»以及单引号或双引号。如果URL以分隔符对的初始字符开始,ReplaceUrls()使用FindEndOfDelimitedUrl()来找到匹配的字符。否则,ReplaceUrls()使用FindEndOfUrl()来找到结尾。
以下是使用ReplaceUrls()的示例:
string text = text.ReplaceUrls(LinkifyUrl);
其中LinkifyUrl是一个方法,用于将找到的URL转换为HTML链接:
public string LinkifyUrl(string url) {
return String.Format("", url);
}
using System.Text;
using System.Text.RegularExpressions;
namespace JohnCardinal.Html {
public delegate string UrlEvaluator(string url);
internal static class UrlExtensions {
private const char kLeftPointingDoubleAngle = '\u00AB';
private const char kRightPointingDoubleAngle = '\u00BB';
private const char kNoDelimiter = '\0';
private static Regex UrlPrefix = new Regex("(https?|ftp)://", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string ReplaceUrls(this string text, UrlEvaluator evaluator) {
var matches = UrlPrefix.Matches(text);
if (matches.Count == 0) {
return text;
}
int copied = 0;
var sb = new StringBuilder();
foreach (Match match in matches) {
if (match.Index > copied) {
sb.Append(text, copied, match.Index - copied);
}
var delimiter = GetUrlDelimiter(text, match);
var end = (delimiter == kNoDelimiter) ? FindEndOfUrl(text, match) : FindEndOfDelimitedUrl(text, match, delimiter);
var url = text.Substring(match.Index, end - match.Index + 1);
if (url.Length > match.Length) {
sb.Append(evaluator(url));
} else {
sb.Append(url);
}
copied = end + 1;
}
if (text.Length > copied) {
sb.Append(text, copied, text.Length - copied);
}
return sb.ToString();
}
private static int FindEndOfUrl(string text, Match match) {
const string kWhitespace = "\r\n\t";
var index = match.Index;
while (index < text.Length) {
switch (text[index]) {
case '\'':
case '\r':
case '\n':
case '\t':
// whitespace ends the URL
return index - 1;
case '.':
case ',':
case '!':
case '?':
case ':':
case ';':
// common punctuation followed by whitespace
// ends the URL
if (index < text.Length - 1) {
if (kWhitespace.IndexOf(text[index + 1]) != -1) {
return index - 1;
}
}
// common punctuation at the end of the text
// ends the URL
else if (index == text.Length - 1) {
return index - 1;
}
break;
}
index++;
}
return index - 1;
}
private static int FindEndOfDelimitedUrl(string text, Match match, char delimiter) {
var nested = 1;
var index = match.Index;
while (index < text.Length) {
switch (text[index]) {
case '\'':
case '\r':
case '\n':
case '\t':
// whitespace ends the URL
return index - 1;
case '\"':
if (delimiter == '\"') {
return index - 1;
}
break;
case '\'':
if (delimiter == '\'') {
return index - 1;
}
break;
case '(':
if (delimiter == '(') nested++;
break;
case ')':
if (delimiter == '(') {
nested--;
if (nested == 0) {
return index - 1;
}
}
break;
case kRightPointingDoubleAngle:
if (delimiter == kLeftPointingDoubleAngle) {
return index - 1;
}
break;
case ']':
if (delimiter == '[') {
return index - 1;
}
break;
}
index++;
}
return index - 1;
}
private static char GetUrlDelimiter(string text, Match match) {
const string kDelimiters = "\"'([\u00AB";
if (match.Index > 0) {
var index = match.Index - 1;
if (kDelimiters.IndexOf(text[index]) != -1) {
return text[index];
}
}
return kNoDelimiter;
}
}
}