阅读提纲

  • 图灵社区和Stack Overflow效果不一致
  • 图灵社区改动了什么
  • \W与[^0-9a-zA-Z]
  • Stack Overflow的\W
  • 图灵社区的[^0-9a-zA-Z]
  • 聪明的Hack
    • 为什么Stack Overflow的预览效果和实际效果不一致
    • 为什么图灵社区的预览效果和实际效果一致
    • 为什么Stack Overflow的预览效果和图灵社区的实际效果一致
    • 两个表格,一目了然
    • 终极问题:怎么实现我们想要的结果
  • 把Hack进行到底
    • 图灵社区是否有必要再改回\W?
    • 添加Unicode字符编码范围
    • 继续用[^0-9a-zA-Z]区别对待所有非英文文字
  • 就这样结束了吗

先问一个最最最简单的问题:你觉得下面这行用Markdown标记的汉字,浏览器应该显示成什么样?

**北京**和**上海**是直辖市。

你以为我不懂Markdown啊?!两个星号代表加粗,结果当然应该这样:“北京上海是直辖市。”顺便告诉你,用两个下划线代替两个星号也行!

别急,都说是最最最简单的问题了!可是就在4月28日之前,以上标记在图灵社区实际显示的结果还是这样呢:

北京和*上海*是直辖市。

@#%$!^&*……嗯,怎么会这样呢?

……这个嘛,让我想想:“北京”变成了粗体,没错,怎么“上海”变成了斜体呢?Markdown里用一个星号或者一个下划线表示斜体,“上海”两边各剩下一个星号,虽然规则没错,但结果不是预期结果啊?

别急,要得到预期结果不难,可以在“和”字和它右边的星号之间加个空格,像这样:

**北京**和 **上海**是直辖市。

注意那个空格了吗,对了,这样浏览器实际的显示结果就变成了这样:

北京上海是直辖市。

好哎(鼓掌),这才是我想要的!

这真是你想要的吗?……嗯,好吧,当然不是啦,我想要的结果是不加空格也让“上海”变成粗体。那个空格,那个空格真让人讨厌,为什么要加空格,怎么回事?!

别急,先别急,深呼吸……从哪里说起呢?好吧,我也深吸一口气……如果我告诉你,这是因为图灵社区后台是使用C#开发的,因此Markdown内容管理就使用了Stack Overflow那套基于C#的开源Markdown系统(MarkdownSharp),但为了防止把英文中常见的“the_file_name”意外转换成词内强调,比如变成“thefilename”这个样子,Stack Overflow的共同创始人Jeff Atwood就重写了把星号和下划线转换成<strong><em>标签的正则表达式,判断星号和下划线两侧是不是单词字符,如果是单词字符,比如“the_file_name”中下划线两侧分别是字母e和n(都是单词字符),就保留原样不作转换,而只在“the _file_ name”这样下划线两侧都是空格(即非单词字符)的情况下才进行转换……你能跟上吗?能?好样的!(继续)可这也导致你不能再用Markdown标出这种“XML(eXtensible Markup Language,可扩展标记语言)”效果了,因为XML这几个要强调的字符两侧都是单词字符。如果想要像这样强调词内字符,只能绕过Markdown,直接使用HTML标签,即用<strong>加粗,用<em>加斜体,英文的问题到这里基本上就解决了。

图灵社区和Stack Overflow效果不一致

这是什么意思啊?我的意思是,下面这行Markdown文本

**北京**和**上海**是直辖市。

4月28日前在图灵社区会显示成这样:

北京和*上海*是直辖市。

可是在Stack Overflow呢,却会显示成这样:

*北京*和*上海*是直辖市。

仔细看一看,看谁先找到了不同……什么,你不相信?对呀,不是说图灵社区用的就是Stack Overflow的MarkdownSharp嘛,那怎么会不一样呢?不信你可以到Stack Overflow上验证一下。

什么,你还真去试了,而且发现跟图灵社区现在显示的效果一致,还有截图为证:

enter image description here

自已看,一样的吧?!别急,我也有一张截图:

enter image description here

这下相信了吧?别急,咱俩说的可能不是一回事。怎么不是一回事,到底怎么一回事?嗯嗯,稍安毋躁,是这样,图灵社区和Stack Overflow一样,都有两套Markdown解析系统:

  • PageDownhttps://code.google.com/p/pagedown),用在客户端浏览器里,随着用户在文本区输入Markdown,实时将其转换成HTML并显示在预览区,让用户看到最终效果;
  • MarkdownSharphttps://code.google.com/p/markdownsharp),用在服务器端,在服务器响应客户端请求之前,通过它把Markdown文本转换成HTML文本。

事实上,无论图灵社区还是Stack OverFlow,服务器的数据库里都保存着我们在文本区输入的Markdown源文本。我们在写文章时看到的预览,是PageDown帮我们实时生成HTML,由浏览器实时渲染的效果,你的截图截取的就是预览效果。而我们按了“保存”,单击“查看”时看到的真实效果,则是服务器端的MarkdownSharp把保存在数据库里的Markdown源文本转换成HTML之后又发给浏览器渲染出来的效果,也就是我所说的实际效果

所以,我们说的不是一回事。实际上,这里应该有一幅图,说明Markdown在前、后端的作用,回头补上。

你现在应该相信“图灵社区和Stack Overflow效果不一致”了,至于为什么……我知道,我知道,你是说又出来一个新问题:Stack Overflow的预览效果和实际效果也不一致!好吧,我承认,还有一个问题:图灵社区的预览效果和实际效果居然是一致的!啊,这也算问题吗?预览本来就该跟实际一致啊!这一段的惊叹号太多了。让我们梳理一下接下来应该搞清楚什么问题。

图灵社区改动了什么

从现在开始,我们要接触到正则表达式了。然后呢,对你——读者,也会有一点点额外的要求。别急,不是让你现在去恶补正则表达式,而是请你更加耐心一点。只要稍微耐心那么一点点就足够了。虽然接下来我们会提到正则表达式,但绝不会出现高深莫测的概念。而且即便会零星出现几个术语,我也会用最简单通俗的语言来说明它们的用途。放心,正则表达式其实非常简单。

言归正传。既然图灵社区和Stack Overflow的实际效果不一致,那一定是图灵社区改动了什么。为此,先看一看MarkdownSharp中的用于匹配、替换“**北京**和**上海**是直辖市。”的代码。以下代码中加粗的部分,就是匹配双星号或双下划线以及要加粗强调字符的正则表达式(为显示清晰,这里重排了一下格式):

private static Regex _strictBold = new Regex(
                              @"([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)",
                              RegexOptions.IgnorePatternWhitespace |
                              RegexOptions.Singleline | 
                              RegexOptions.Compiled);
...
private static Regex _strictItalic = new Regex(
                             @"([\W_]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 ([\W_]|$)",
                              RegexOptions.IgnorePatternWhitespace |
                              RegexOptions.Singleline | 
                              RegexOptions.Compiled);

代码一链接:https://code.google.com/p/markdownsharp/source/browse/MarkdownSharp/Markdown.cs#1357

以下代码中加粗的部分,就是把前面正则表达式匹配到的包围着要强调的字符的双星号或双下划线替换成<strong></strong>标签的模式:

        // <strong> must go first, then <em>
        if (_strictBoldItalic)
        {
            text = _strictBold.Replace(text, "$1<strong>$3</strong>$4");
            text = _strictItalic.Replace(text, "$1<em>$3</em>$4");
        }
        else ...

代码二链接:https://code.google.com/p/markdownsharp/source/browse/MarkdownSharp/Markdown.cs#1374

当然,这是C#代码。不过,我们要关心的,只有这个正则表达式:

([\W_]|^)(\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)

更确切地说,只有加粗的部分。最确切地说,只有

\W

其他都可以忽略。啊,真好……原来就是一个反斜杠和一个大写W啊。别介,不要这样说,应该说:原来就是\W啊。

来多说几遍:\W\W\W。就是这个\W,在图灵社区被改成了:[^0-9a-zA-Z]。换句话说,图灵社区服务器上运行的MarkdownSharp中的那个正则表达式,变成了下面这样:

([^0-9a-zA-Z]|^)(\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([^0-9a-zA-Z]|$)

正是这个改动,导致我们那句话在Stack Overflow显示为:

*北京*和*上海*是直辖市。

而到了图灵社区,却变成了:

北京和*上海*是直辖市。

\W与[^0-9a-zA-Z]

\W是正则表达式中的一个元字符,匹配“非文字字符”。所谓元字符,就是不能再分割的字符。而“非文字字符”呢,正则表达式中的定义——仅就中英文而言,是:除下划线(_)、数字0-9、英文字母a-zA-Z,以及汉字之外的所有字符。换句话说,下划线(_)、数字0-9、英文字母a-zA-Z,以及汉字都属于“文字字符”,用另一个元字符\w表示。参见下图。

enter image description here

对适用于英语及其他拉丁字母的ASCII字符集而言,\w通常是指[_0-9a-zA-Z]。因此,仅就英文而言,说\W[^0-9a-zA-Z]相当也不为过,后者只不过多了一个下划线而已。但是,包括C#在内的现代语言几乎都支持Unicode字符集,这个字符集为世界上几乎所有语言的文字都定义了编码。在使用Unicode字符集的情况下,就不能简单地认为\W等同[^0-9a-zA-Z]了。事实上,如图所示,[^0-9a-zA-Z]除了代表下划线和\W之外,至少还代表以下这些语言中的文字字符:

enter image description here 图片来源:谷歌翻译

好啦,最最关键的一个问题解决了。那我考考大家,正则表达式开头和末尾各放一个\W到底想干嘛?……你着急问什么,我还想知道方括号([])、竖线(|),还有那个上箭头符号(^)是干什么的?

噢,听我解释,很简单:^有两种用途,一种是像[^0-9a-zA-Z]这种放在方括号内开头,表示对后面的模式“取反”,你早就猜到了,对不对?另一种用途是匹配一行文字的开头位置。方括号表示里面的模式在要匹配的字符串里只出现一种就算匹配。比如说汉字的“和”字吧,它跟模式\W匹配,所以就可以忽略那个下划线了。竖线呢,用于分隔几个可以相互替代的模式,在这里呢,就表示它左侧和右侧的模式只要有一种在字符串里出现过就算匹配。

说到匹配,我们来看一看Stack Overflow为什么会显示那个结果。

Stack Overflow的\W

方便起见,我们把待匹配的字符串和正则表达式重新列出来。不过别担心,我们还是只关注两头加粗的部分:

**北京**和**上海**是直辖市。
([\W_]|^)(\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)

第一轮匹配过程如下:

  1. ([\W_]|^)匹配这行文字的行首位置(忽略\W匹配星号失败);
  2. 然后,(\*\*|__)匹配两个星号((?=\S)表示两个星号右侧不能有空格,这里没有空格,因此匹配);
  3. 接着,([^\r]*?\S[\*_]*)匹配“北京”;
  4. 继而,\2同样匹配两个星号;
  5. 最后——关键是这里,([\W_]|$)如果跟“和”字匹配,那么整个正则表达式就能完成一次匹配,即匹配到“**北京**”;然而,前面说过,在C#等现代语言里,\W只匹配“非文字字符”,因此不匹配汉字,而且这里没有下划线,也不是这一行的末尾,所以,第一轮匹配失败!

于是,正则表达式从头开始第二轮匹配,过程如下:

  1. ([\W_]|^)继续尝试匹配汉字“和”,如前所述,当然不匹配;
  2. ([\W_]|^)继续尝试匹配“和”字后面的星号,星号是“非单词字符”,匹配;
  3. 然后,(\*\*|__)尝试匹配下一个星号,不匹配;
  4. 正则表达式退回([\W_]|^),继续尝试匹配当前这个星号,匹配;
  5. 接着,(\*\*|__)尝试匹配“上”字,不匹配;
  6. 正则表达式退回([\W_]|^),继续尝试匹配“上”字,不匹配;
  7. ([\W_]|^)尝试匹配“海”字,不匹配;
  8. ([\W_]|^)尝试匹配“海”字后面的星号,匹配;
  9. 继而,(\*\*|__)尝试匹配下一个星号啦,不匹配;
  10. 正则表达式退回([\W_]|^),继续尝试匹配当前这个星号,匹配;
  11. 接着,(\*\*|__)尝试匹配“是”字,不匹配;
  12. 正则表达式退回([\W_]|^),依次尝试匹配“是”、“直”、“辖”、“市”,均不匹配;第二轮匹配失败!

看明白了吗,没有?还是根本没看?无论如何,本来想替换两对星号的正则表达式根本没有找到匹配的文本,所以也就不会发生替换了。但是,别忘了,还有一个正则表达式是用于查找和替换一对星号的(参考前面代码一):

([\W_]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 ([\W_]|$)

我们简单模拟一下匹配过程:

  1. ([\W_]|^)匹配这行文字的行首位置(忽略\W匹配星号后失败);
  2. (\*|_)尝试匹配第一个星号,匹配;
  3. ([^\r\*_]*?\S)尝试匹配下一个星号,但不匹配;
  4. 正则表达式退回([\W_]|^),尝试匹配第一个星号,星号是“非单词字符”,匹配;
  5. 然后,(\*|_)尝试匹配第二个星号,匹配;
  6. 接着,([^\r\*_]*?\S)尝试匹配“北京”,匹配;
  7. 继而,\2尝试匹配“京”后面的星号,匹配;
  8. 最后,([\W_]|$)尝试匹配下一个星号,匹配!

于是,这个正则表达式就匹配了“**北京**”。类似地,下一轮又匹配了“**上海**”。找到了匹配的字符之后,下一步就是使用下面的替换模式替换(参见前面代码二):

text = _strictItalic.Replace(text, "$1<em>$3</em>$4")

以“**北京**”为例,这里的$1$3$4与分别代表:

  1. $1([\W_]|^),即“**北京**”中的第一个星号;
  2. $3([^\r\*_]*?\S),即“北京”;
  3. $4([\W_]|$),即“**北京**”中的最后一个星号。

替换模式$1<em>$3</em>$4的意思,通俗来说,就是把“**北京**”替换成这样一个字符串:开头是一个星号($1),接着是一个<em>标签,然后是“北京”($3),继而是一个</em>标签,最后是另一个星号($4)。结果呢,就得到了HTML文本:

*<em>北京</em>*

不难想象,整个一句话,经过匹配和替换,就变成了:

*<em>北京</em>*和*<em>上海</em>*是直辖市。

经过浏览器渲染,自然就成了:

*北京*和*上海*是直辖市。

图灵社区的[^0-9a-zA-Z]

重温一下图灵社区的正则表达式:

([^0-9a-zA-Z]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([^0-9a-zA-Z]|$)

为方便参照,再给出我们的原始Markdown文本:

**北京**和**上海**是直辖市。

准备好了吗?不用害怕,这次简单多了。好,开始吧:

  1. ([^0-9a-zA-Z]|^)匹配一行文本开头(忽略[^0-9a-zA-Z]匹配失败);
  2. (\*\*|__)匹配开头的两个星号;
  3. ([^\r]*?\S[\*_]*)匹配“北京”;
  4. \2匹配接下来的两个星号;
  5. 最后,([^0-9a-zA-Z]|$)尝试匹配的还是“和”字,你觉得匹配吗?当然,参见前面那张图,[^0-9a-zA-Z]匹配汉字,所以整个正则表达式匹配成功,匹配结果是“**北京**和”——对,末尾有个“和”字

怎么样,我说简单吧,匹配就这么简单,不匹配那麻烦可就大了。Stack Overflow就是在最后一步,因为\W不匹配“和”字才功亏一篑的。

来吧,乘胜追击,继续第二轮……不用担心,这一轮虽然匹配结果是失败,但我们的解释非常简单——除了第1步不再尝试匹配“和”字——因为“和”已经在上一轮被匹配走了,其余步骤与Stack Overflow第二轮其余步骤类似,我们就不再费键盘了。

同样,因为第二轮匹配失败,也就是匹配两对星号的正则表达式只匹配到一个字符串,那么剩下的“**上海**”自然就留给了第二个匹配一对星号的正则表达式:

([^0-9a-zA-Z]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 ([^0-9a-zA-Z]|$)

好了,不啰嗦了,直接说结果吧——这个正则表达式同样找到一个匹配结果:“**上海**”。

事实上,上述两次匹配之后都跟着一次替换,参考如下代码(见代码二):

text = _strictBold.Replace(text, "$1<strong>$3</strong>$4");
text = _strictItalic.Replace(text, "$1<em>$3</em>$4");
  • 第一次匹配结果是:“**北京**和”
    • $1是行首位置
    • $3是“北京”
    • $4是“和”
    • 因此,第一次替换结果是: <strong>北京</strong>和**上海**是直辖市。
  • 第二次匹配结果是:“**上海**”
    • $1是第一个星号
    • $3是“上海”
    • $4是第二个星号
    • 因此,第二次替换结果是:<strong>北京</strong>和*<em>上海</em>*是直辖市。

那么,自然而然地,浏览器渲染结果就是:

北京和*上海*是直辖市。

聪明的Hack

我相信,在前面模拟正则表达式匹配的过程中,我已经解释清楚了:正因为图灵社区的正则表达式

([^0-9a-zA-Z]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([^0-9a-zA-Z]|$)

把MarkdownSharp正则表达式中的\W替换成[^0-9a-zA-Z],才在第一轮最后一步匹配了“和”字,所以整个正则表达式才能匹配到**北京**和,于是才有了第一次替换后的<strong>北京</strong>和**上海**是直辖市。,从而得到加粗的“北京”。

如果把这个改动比喻成一个Hack,相信你也会承认这是一个聪明的Hack吧!你肯定会。

不过,这个Hack的聪明之处远不止如此。还有?当然啦。记得我们前面说过要梳理清楚几个问题吗?

  • 图灵社区和Stack Overflow效果不一致!
  • Stack Overflow的预览效果和实际效果也不一致!
  • 图灵社区的预览效果和实际效果居然是一致的!

第一个问题,我们已经解释清楚了,没错,我相信你也一样清楚了,对吧?我对此是很有自信的。好啦,该谈一谈后面两个问题啦。等等,别闲我烦,还有一个问题,你想过没有:Stack Overflow的预览效果和图灵社区的实际效果一致!

哈哈,你又晕了?别急,事实上,看完后面的分析,你就会明白后三个问题,或者说现象背后的原因只有一个。什么原因呢?

在JavaScript的正则表达式实现中:\W匹配汉字!

好啦,下面我们依次回答后面三个问题。

为什么Stack Overflow的预览效果和实际效果不一致

很简单,因为Stack Overflow前后端的正则表达式中使用的都是\W!所以,前端预览模式下:

  • JavaScript正则表达式中的\W匹配了汉字“和”,
  • 直接导致搜索双星号的表达式匹配到**北京**和
  • 然后,另一个搜索单星号的表达式匹配到**上海**
  • 结果整句话被替换为HTML后,就显示成了:北京和*上海*是直辖市。

而在后端响应请求前转换的时候:

  • C#正则表达式中的\W不匹配汉字“和”,
  • 直接导致搜索双星号的表达式匹配不到字符串;
  • 然后,另一个搜索单星号的表达式匹配到**北京****上海**
  • 结果整句话被替换为HTML后,返回给浏览器,就显示成了:*北京*和*上海*是直辖市。

明白了这个问题,再看下一个。

为什么图灵社区的预览效果和实际效果一致

同样很简单,因为图灵社区在前后端的正则表达式中使用的都是[^0-9a-zA-Z]!所以,前端预览模式下:

  • JavaScript正则表达式中的[^0-9a-zA-Z]匹配了汉字“和”,
  • 直接导致搜索双星号的表达式匹配到**北京**和
  • 然后,另一个搜索单星号的表达式匹配到**上海**
  • 结果整句话被替换为HTML后,就显示成了:北京和*上海*是直辖市。

而在后端响应请求前转换的时候:

  • C#正则表达式中的[^0-9a-zA-Z]同样也匹配了汉字“和”,
  • 直接导致搜索双星号的表达式匹配到**北京**和
  • 然后,另一个搜索单星号的表达式匹配到**上海**
  • 结果整句话被替换为HTML后,返回给浏览器,就显示成了:北京和*上海*是直辖市。

是不是很简单?最后一个问题。

为什么Stack Overflow的预览效果和图灵社区的实际效果一致

这个问题更简单,因为前面已经分析了Stack Overflow怎么得到预览效果,以及图灵社区怎么得到实际效果,所以只要把两个过程并列在下面就可以一目了然了。首先,看Stack Overflow怎么得到预览效果:

  • JavaScript正则表达式中的\W匹配了汉字“和”,
  • 直接导致搜索双星号的表达式匹配到**北京**和
  • 然后,另一个搜索单星号的表达式匹配到**上海**
  • 结果整句话被替换为HTML后,就显示成了:北京和*上海*是直辖市。

再看图灵社区怎么得到实际效果:

  • C#正则表达式中的[^0-9a-zA-Z]同样也匹配了汉字“和”,
  • 直接导致搜索双星号的表达式匹配到**北京**和
  • 然后,另一个搜索单星号的表达式匹配到**上海**
  • 结果整句话被替换为HTML后,返回给浏览器,就显示成了:北京和*上海*是直辖市。

明白了吗?哈哈。

两个表格,一目了然

明白了原因,我们总结一下,表格比文字的表达能力强,下面两个简单的表格可以说明到目前为止的一切!

表一:Stack Overflow与图灵社区使用的表达式与两端效果

Stack Overflow图灵社区
客户端JS \W北京和*上海*是直辖市。 [^0-9a-zA-Z]北京和*上海*是直辖市。
服务器C# \W*北京和*上海*是直辖市。 [^0-9a-zA-Z]北京和*上海*是直辖市。

表二:两个表达式是否匹配汉字

表达式\W[^0-9a-zA-Z]
客户端JS
服务器C#

看懂了吗?实际上,这两个表格也说明了为什么图灵社区要改动Stack Overflow的正则表达式。很简单,因为发现\W会导致预览与实际效果不一致!为什么说这个改动还有蕴含着更大的智慧呢?因为改动之后的[^0-9a-zA-Z]不仅能够匹配汉字,事实上可以匹配任何非英文文字字符,包括中文、日文、韩文……看图,值得深思![^0-9a-zA-Z]\W匹配的范围是不可同日而语的。

enter image description here

终极问题:怎么实现我们想要的结果

这个Hack唯一的问题是没有实现“北京”和“上海”都加粗。不过,聪明的读者一定对一件事念念不忘。对了,本文一开头我们就说过,只要在“和”字和它右边的星号之间加个空格

**北京**和 **上海**是直辖市。

就可以在图灵社区实现我们想要的效果:

北京上海是直辖市。

这又是为什么呢?前面在分析正则表达式匹配步骤时曾提到,在查找两对双星号时,第一轮匹配了**北京**和,结果第二轮就没有匹配。而问题就在于第一轮匹配了“和”字!如果第一轮不匹配“和”字,第二个轮从头开始时,[^0-9a-zA-Z]就可以匹配这个“和”,然后接下来是双星号、“上海”、双星号……这样就能实现两个词都加粗了!

现在的问题就是怎么让正则表达式不匹配“和”字。既然找到了问题,办法就简单了。正则表达式里有一种结构叫环视(look around),也有人叫预匹配,但我感觉可以意译为相邻条件。没关系,叫什么名字其实不重要,关键是要知道这种结构的作用:对真正要匹配的字符左右相邻的字符给出限制条件,但这种结构本身不匹配任何字符——实际上可以认为它们匹配位置。正则表达式中有4种相邻条件:

  • 右有(?=expression):指定某字符串满足左侧模式的同时,其右侧必须有与expression匹配的字符或位置;
  • 右无(?!expression):指定某字符串满足左侧模式的同时,其右侧必须没有与expression匹配的字符或位置;
  • 左有(?<=expression):指定某字符串满足右侧模式的同时,其左侧必须有与expression匹配的字符或位置;
  • 左无(?<!expression):指定某字符串满足右侧模式的同时,其左侧必须没有与expression匹配的字符或位置。

好,套用到图灵社区的正则表达式上,就是把最后一个组件([^0-9a-zA-Z]|$)改成不匹配字符的右有相邻条件(?=[^0-9a-zA-Z]|$)。即,把

([^0-9a-zA-Z]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([^0-9a-zA-Z]|$)

改成

([^0-9a-zA-Z]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[^0-9a-zA-Z]|$)

改成这个右有相邻条件,当匹配完**北京**最后两个星号之后,只会判断接来下的“和”字是否匹配[^0-9a-zA-Z]|$,匹配就匹配,也不会占用这个“和”字。换句话说,“和”字还会作为下一轮匹配的起点!而这也是为什么我们在“和”字后面加个空格就变得正常的原因。

把Hack进行到底

中文是方块字,字与字之间没有空格……没错,但英文是有自然空格的。所以,Stack Overflow使用\W尽管不是完全没有问题,但问题也可以轻易解决……什么什么……等等,你刚才说什么“Stack Overflow使用\W也不是完全没有问题”,有什么问题?噢,好吧,很简单,拿一个英文的例子来说吧,以下Markdown标记的文本:

This book and **the** **other** book.

会显示成这样

This book and the *other* book.

有没有人觉得这个显示效果似曾相识?没错,这和本文开头的例子

**北京**和**上海**是直辖市。

会显示成这样

北京和*上海*是直辖市。

原因相同——C#中的\W**the** **other**中间的空格匹配掉了,于是造成后面不匹配。解决这个问题很简单,要么就把正则表达式最后一个组件([\W_]|$)改成不匹配字符的右有相邻条件(?=[\W_]|$)。即,把

([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)

改成

([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[\W_]|$)

这样,就可以不匹配空格了。对了,你刚才说“要么”?难道还有别的办法吗?当然,这正是英文与中文相比的差异优势啊——你看,把英文那句话换个写法

This book and **the other** book.

不就成了

This book and the other book.

还真是,英文自然空格有它的用处。没错,《老子》不是说过嘛,“有之以为利,无之以为用”啊。咱中文就不能如法炮制?你觉得呢,写成这样

**北京和上海**是直辖市。

那结果就变成这样了

北京和上海是直辖市。

说实话,这个效果很多情况下是不可以忍受的。比如,上文说“下面加粗的两个词是……”,结果下面却加粗了5个字!你说能忍受吗,坚决不能!

好了,刚才算我们了解了在英文中可以利用自然空格的一个Hack,涨了点姿势。

图灵社区是否有必要再改回\W?

受刚才例子的启发,你可能会想:对呀,如果把

([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)

改成

([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[\W_]|$)

就能保证不匹配字符,是不是图灵社区可以改回\W呢?如果可以,也算跟MardownSharp保持一致了。嗯,这个想法蛮不错的。那我们下面就测试一下吧。客户端测试很简单,在Chrome浏览器里,按F12:

enter image description here

成功喽!别高兴得太早了,还没测试服务器端呢。要测试服务器端,就要写C#代码,就得……哎呀,太麻烦啦吧。不麻烦,下载WebMatrix吧:http://www.microsoft.com/web/webmatrix/

enter image description here

下载、安装都是全自动的。然后新建一个网站,代码如下:

enter image description here

好啦,按F12测试网页,看结果:

enter image description here

不行啊,服务器端测试没通过。为什么?上图上表,大家自己看。

enter image description here

表二:两个表达式是否匹配汉字

表达式\W[^0-9a-zA-Z]
客户端JS
服务器C#

看明白了吧,如图如表所示,按常理,\W匹配“非文字字符”。既然是“非文字字符”,自然就不光排除了英文字母,而且还排除了任何语言中的文字字符。够狠的,但没办法,所有支持Unicode字符集的语言都应该这么实现。C#就是这样啊,它一视同仁。

一视同仁没问题啊,但你要注意,英文单词之间有空格啊,禁用了词内强调,还可以有词间强调啊。那其他语言,比如中日韩文呢,天生没有自然空格,所有强调都是词内强调。一视同仁地禁用了词内强调,不等于让其他语言不能强调了吗?当然也不是不能强调,就像英文的词内强调可以用<strong><em>直接标记一样,其他没有自然空格的语言,要强调也可以直接使用<strong><em>吗,这就相当于绕开Markdown了。不过,这样一来,对英文中的特殊情况,在其他语言里就成了一般情况了,太麻烦了,岂有此理!?

还有啊,JavaScript此时此刻就显得特别怪异,它的\W居然匹配汉字,把汉字当成“非文字字符”,这简直是对非英语文字的大不敬啊!请息怒,稍后会讲到JavaScript也支持Unicode字符集,因此可以用Unicode字符集来匹配东亚文字。是嘛,OK。

添加Unicode字符编码范围

为了提醒我们别忘了目的,再重申一次:我们希望找一个正则表达式,它能禁用英文的词内强调,但不会妨碍东亚文字或者说中日韩文字的词内强调——应该叫文内强调才对啊。

既然\W可以禁用(排除)英文的词内强调,那么只要添加一个选择模式,让正则表达式接受(不排除)中日韩文字不就行啦,比如把正则表达式写成这样:

([\W_]|中日韩文字|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[\W_]|中日韩文字|$)

不会吧,你让我所有中日韩文字都嵌在正则表达式里面?!当然不是,正则表达式支持直接匹配Unicode字符编码。中日韩文字的编码范围是:

好啦,闲言碎语不多讲,测试开始。客户端:

enter image description here

成功,哎不对呀,客户端测试应该把\W删掉,要不结果不准确,为什么?你懂的——JavaScript对\W实现有问题呀,不懂看前面:

enter image description here

嗯,还是没问题,确实OK。该服务器端了,代码如下:

enter image description here

看结果:

enter image description here

通过啦。没错,不用删除\W了,因为C#中的\W不匹配汉字……等一等,\W不匹配汉字,就不匹配日文和韩文了吗?这……这你都能想到?我得说,你是个好测试员啊!好吧,服务器端也删除\W

enter image description here

看好喽,没骗你吧,已经把\W除了,而且为了保持完整性,下划线还保留着呢。好,看结果:

enter image description here

结果一样。什么,你发现截图和前面是同一张?你真行,眼力太好了,你现在干什么工作呢,听我的,赶紧辞职,到图灵公司当编辑吧,什么蛛丝马迹都逃不过你的火眼金睛!请相信我,测试结果完全相同,我就省点计算机资源,不重复截图了,你说是吧。还不信,佩服,就凭你这股质疑的精神,绝对是好编辑的料儿!

继续用[^0-9a-zA-Z]区别对待所有非英文文字

可是,只支持中日韩文就够了吗?

enter image description here 图片来源:谷歌翻译

你看,世界上那么多种语言,哪里是简单的英文、中文、日文和韩文所能涵盖的呀!我们可不能像Stack Overflow那么狭隘,只考虑英文,连发个问题都只能用英文(——可以用中文吗?)。看看咱首都天安门上写的是什么:

enter image description here

对嘛:世界人民大团结万岁!不得不说,图灵社区对MarkdownSharp的改动非常巧妙,一箭双雕:既弥补了JavaScript与C#实现前后端不一致的问题,同时又解决了区别对待没有自然空格的语言所有强调都属于“词内强调”的问题。

因此,最终的解决方案还是暂时不考虑添加Unicode字符编码范围,而是在图社区原有改动基础上,把正则表达式的最后一个组件改为右有相邻条件

服务器端C#中改后的用于匹配一对双星号或双下划线的正则表达式:

([^A-Za-z0-9]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[^A-Za-z0-9]|$)

服务器端C#中改后的用于匹配一对单星号或单下划线的正则表达式:

([^A-Za-z0-9]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 (?=[^A-Za-z0-9]|$)

这是客户端JavaScript中改后的用于匹配一对双星号或双下划线的正则表达式:

/([^A-Za-z0-9]|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2(?=[^A-Za-z0-9]|$)/g

这是客户端JavaScript中改后的用于匹配一对单星号或单下划线的正则表达式:

/([^A-Za-z0-9]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2(?=[^A-Za-z0-9]|$)/g

这是前后两端用于替换的正则表达式:

$1<em>$3</em>

把Hack进行到底!让Markdown胸怀世界。Over :-)

就这样结束了吗

当然不会,我故意留一个问题给大家思考:我们最终的Hack方案假设除英文之外所有语言的文字都没有自然空格。实际上这个假设在某些情况下是站不住脚的,比如法语、德语,这些语言文字的编码同样也超出了[0-9a-zA-Z]的范围。这时候,是不是同样该像Stack Overflow一样,禁用词内强调呢?我想主要应该考虑这些语言是否存在英文“the_file_name”式的冲突。如果存在,那么我们这个Hack还要继续下去,因为[^0-9a-zA-Z]未免太宽泛了。

保险起见,前述Hack可能还是要后退一步,后退到使用Unicode编码范围仅区别对待中日韩文字的状态,即正则表达式(以匹配双星号或双下划线的正则表达式为例)服务器端应为:

([\W_]|\u4e00-\u9fff|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 (?=[\W_]|\u4e00-\u9fff|$)

客户端应为:

/([\W_]|\u4e00-\u9fff|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2(?=[\W_]|\u4e00-\u9fff|$)/g

另外,又发现了一个图灵社区客户端与服务器端显示不一致的问题:

*_北京_*和*_上海_*是直辖市

在客户端显示为:

*北京*和*上海*是直辖市

只替换了一对下划线;而服务器端呢,则根本未加任何替换:

*_北京_*和*_上海_*是直辖市

真是一波未平,一波又起呀……

5月2日又测试了一下前面的“最终解决方案”——注意,测试用例不一样了。目标文本变成了:

*_北京_*和*_上海_*是直辖市

要测试的正则表达式变成了用于匹配一对单星号或单下划线的正则表达式(客户端)

/([^A-Za-z0-9]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2(?=[^A-Za-z0-9]|$)/g

和(服务器端)

([^A-Za-z0-9]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 (?=[^A-Za-z0-9]|$)

首先看客户端测试结果:

enter image description here

没有问题!再看服务器端,代码如下:

enter image description here

运行结果如下:

enter image description here

同样没有问题!代码一切正常说明什么问题?——可能需要看看图灵社区服务器端的正则表达式才知道。截止5月16日,这个问题仍然存在。如果有一天,你发现

*_北京_*和*_上海_*是直辖市

显示成

*北京*和*上海*是直辖市

那就说明问题解决了。否则,如果你看到的还是

*_北京_*和*_上海_*是直辖市

说明问题尚未解决。

(最后修订时间:2013年5月16日下午13:53)