昨天李松峰老师在微博中写道:

程序员一般特别在意一个细节:中文与西文、中文与数字之间要有间距。这样写:HTML5定义了29个新标签。他们觉得不爽,改成这样:HTML5_定义了_29_个新标签。就舒服了。如果你在翻译书,千万不要人为敲这么多半字空。因为排版软件一般都有设置选项,你加了,别人再删很麻烦。

Microsoft Word-段落-中文版式

图1:Microsoft Word-段落-中文版式

【注】:上文中三个下划线的位置原为空格,这样处理只是为突出显示空格,因为本文及相关代码都与这些空格有着不解之缘 :D

接着俺回复:

确实,在Microsoft Word中可以很方便地控制版式。不过在HTML代码中,要想控制“中文与西文、中文与数字之间的间距”就不那么方便了,请问李老师有啥好办法没?现在能想到的是JS+CSS,这样原文会比较干净。

接下来是一段微博对话,正是这段对话引出了最后的代码,急性子的童鞋可跳过对话,直接看末尾的格式化代码就可以了。

【李】JS+CSS要是能控制就可以呀,可是怎么控制呢?
【俺】初步想法是,用正则扫描正文文本,插入样式标签,即处理前“HTML5定义了29个新标签”,处理后“<span class='gap'>HTML5</span>定义了<span class='gap'>29</span>个新标签”。
【李】能看懂这个正则吗:“([!-~]) ([^!-~])”为“$1$2” ,这是别人写的。
【俺】两个小括号是模式匹配,[!-~]字符集合中包含哪些字符还真不知道。

问:不知道字符集合[!-~]里有啥怎么?
答:动手做实验。以下是实验代码,有兴趣的童鞋可将代码保存为.html格式文件,并在浏览器中查看执行结果(此代码已在32位Win7-IE9下调试通过):

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
</head>
<body>
<script type="text/javascript">
<!--
document.write("西文可见字符列表<br />");
var str = "!-~"
var westCharList = document.body.appendChild(document.createElement("ol"));
var westCharItem;
for (var i=str.charCodeAt(0); i<=str.charCodeAt(2); i++)
{
    westCharItem = document.createElement("li");
    westCharItem.innerText = "Char Code: " + i + "; Char: " + String.fromCharCode(i);
    westCharList.appendChild(westCharItem);
}
//-->
</script>  
</body>
</html>

代码1:显示 [!-~] 字符集合字符列表

【俺】 [!-~] 匹配的是从 Char Code: 33; Char: ! 至 Char Code: 126; Char: ~ 的全部字符,即英文键盘上的94个可见字符。即 [!-~] 是西文和数字字符集合。
【李】这样用JS就能控制了。

问:虽然 [!-~] 的含义清楚了,但是 /([!-~]) ([^!-~])/ 到底能匹配什么内容?
答:继续实验。代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
</head>
<body>
<script type="text/javascript">
<!--
var reg = /([!-~]) ([^!-~])/g;
var str = "Start 在西班牙123“The rain in Spain ”雨水 falls 主要——mainly 集中在out 《in》the plain.集wuwu中在平原地区。End";
var matchResult = str.match(reg);
for (var i=0; i<matchResult.length; i++)
{
    document.write(matchResult[i]+"<br />");
}    
//-->
</script>  
</body>
</html>

代码2:显示正则表达式 /([!-~]) ([^!-~])/g 匹配内容

输出结果如下:

t 在
n ”
s 主
y 集
t 《

根据实验结果可知,正则表达式 /([!-~]) ([^!-~])/g 用于检查西文字符(在前)与非西文字符(在后)之间的空格,如果配合使用字符串的 replace 方法可实现去除空格的目的。

慢着慢着,不是说要插入空格么?
别急别急,既然能删除空格了,那么只需稍加改造就能插入空格。代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
   <title>插入空格实验代码1</title>
</head>
<body>
<script type="text/javascript">
<!--
var reg = /([!-~])([^!-~])/g;
var str = "Start在西班牙123“The rain in Spain”雨水 falls主要——mainly集中在out《in》the plain.集wuwu中在平原地区。End";
var redSpace = "<span style='background-color: red;'> </span>";
var matchResult = str.match(reg);
for (var i=0; i<matchResult.length; i++)
{
    document.write(matchResult[i]+"<br />");
}
document.write(str.replace(reg, "$1"+redSpace+"$2")+"<br />");
//-->
</script>  
</body>
</html>

代码3:在西文尾部插入空格

在西文尾部插入空格输出结果

图2:代码3输出结果

虽然插入了空格,但是问题也不少:

  1. 西文之间的空格被替换了。希望不要替换,原样保留。
  2. 西文与后面的中文标点之间被插入了空格。希望不要插入空格,原样保留。
  3. 西文与前面的中文之间尚未插入空格。希望插入空格,若前面是标点,则不要插入空格,原样保留。

常言道,一口吃不成个胖子。问题要一个一个地解决。

  1. 西文之间的空格被替换了。希望不要替换,原样保留。

这个不难,只需将空格加入集合,修改后的正则表达式如下(请自行实验):

var reg = /([!-~ ])([^!-~ ])/g;

去除西文之间空格的输出结果

图3:基于代码3修正问题1后的输出结果

问题2是中文标点判断问题,而且问题3也涉及此问题,不过俺想先解决问题3“中西文与前面的中文之间尚未插入空格”的问题,因为它与问题1很相似,仅仅是位置变化了。代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
</head>
<body>
<script type="text/javascript">
<!--
var reg = /([^!-~ ])([!-~ ]+)([^!-~ ])/g;
var str = "Start在西班牙123“The rain in Spain”雨水 falls主要——mainly集中在out《in》the plain.集wuwu中在平原地区。End";
var redSpace = "<span style='background-color: red;'> </span>";
var matchResult = str.match(reg);
for (var i=0; i<matchResult.length; i++)
{
    document.write(matchResult[i]+"<br />");
}
document.write(str.replace(reg, "$1"+redSpace+"$2"+redSpace+"$3")+"<br />");
//-->
</script>  
</body>
</html>

代码4:在西文头、尾部同时插入空格

代码4输出结果

图4:代码4的输出结果

仔细观察不难发现,几处本该插入空格的地方并没有空格,显然正则表达式仍需要调整,修正后正则表达式如下:

var reg = /([^!-~ ]*)([!-~ ]+)([^!-~ ]?)/g;

代码4修正后的输出结果

图5:代码4修正后的输出结果

至此,尚未解决的问题是:当西文头、尾部出现中文标点符号时不应插入空格,保留原样即可。

通过仔细分析图5的输出结果不难看出,中文符号的问题在一次匹配中无法成功解决,因为有些情况下要依赖于前次匹配末尾字符来辅助判断,如“中在out《”与“in》”就属于此类情况。

尽管用正则表达式难以处理,但是还有一招,即 replace 方法,因为该方法的第二参数可以接受“返回替换文本的函数”,这就好办了。代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
</head>
<body>
<script type="text/javascript">
<!--
String.prototype.Trim= function(){
    return this.replace(/(^\s*)|(\s*$)/g, "");
};
String.prototype.GetLastChar= function(){
    return this.charAt(this.length-1);
};
String.prototype.IsNullOrEmpty= function(){
    return (this==null) || (this=="");
};
function IsChinesePunctuation(givenChar) {
    var regCnPunctuations = /[!¥……()——【】;:‘’“”,。、《》?]/;
    return regCnPunctuations.test(givenChar);
}

var reg = /([^!-~ ]*)([!-~ ]+)([^!-~ ]?)/g;
var str = "Start在西班牙123“The rain in Spain”雨水 falls主要——mainly集中在out《in》the plain.集wuwu中在平原地区。End";
var redSpace = "<span style='background-color: red;'> </span>";
var matchResult = str.match(reg);
for (var i=0; i<matchResult.length; i++)
{
    document.write(matchResult[i]+"<br />");
}
var previousTailIsCnPunc = false;
document.write(str.replace(reg,
    function($0,$1,$2,$3) {
            var output = $1;
            var redSpace = "<span style='background-color: red;'> </span>";
            // $1为西文前面的0-n个中文字符,当$1的最后一个中文字符为标点符号,或$1为空字符串且前此匹配尾部为标点符号时,则不插入空格,直接拼接字符串即可。
            if (IsChinesePunctuation($1.GetLastChar()) 
                || ($1.IsNullOrEmpty() && previousTailIsCnPunc))
                output += $2.Trim();
            else
                output += redSpace + $2.Trim();

            if (IsChinesePunctuation($3))
                output += $3;
            else
                output += redSpace + $3;

            previousTailIsCnPunc = IsChinesePunctuation($3);

            return output;
        }
    ));    
//-->
</script>
</body>
</html>

代码5:当西文与中文符号相邻时不插入空格

代码5输出结果

图6:代码5的输出结果

到此为止,在西文与中文之间插入空格的问题已基本解决了(欢迎大家捉虫)。

不过俺更喜欢进一步控制样式,且中文、西文分别用不同样式来显示,最终代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>《为在网页中插入「空格」编写的JS脚本》</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="generator" content="editplus" />
    <meta name="author" content="http://weibo.com/yixianggao" />
    <meta name="keywords" content="JavaScript RegExp typography CSS style space" />
    <meta name="description" content="" />
    <style type="text/css">
span.chineseText {
    font-family: "微软雅黑", "宋体";
    font-size: 1em;
    letter-spacing: .1em;
    color: #004040;
}
span.westText {
    font-family: "Segoe UI", Verdana, Helvetica, Calibri, Tahoma;
    font-size: 1em;
    color: #0080c0;
}
span.leftGap {
    margin-left: .2em;
}
span.rightGap {
    margin-right: .2em;
}
    </style>
</head>
<body>
<h3>《为在网页中插入「空格」编写的JS脚本》</h3>
<script type="text/javascript">
<!--
String.prototype.Trim= function(){
    return this.replace(/(^\s*)|(\s*$)/g, "");
};
String.prototype.GetLastChar= function(){
    return this.charAt(this.length-1);
};
String.prototype.IsNullOrEmpty= function(){
    return (this==null) || (this=="");
};
function WriteLine(outputs) {
    for (var i=0; i<arguments.length; i++)
    {
        if (arguments[i] instanceof Array)
        {
            for (var j=0; j<arguments[i].length; j++)
                WriteLine(arguments[i][j]);
        }
        else
            document.write(arguments[i]);
    }
    document.write("<br />");
}
function IsChinesePunctuation(givenChar) {
    var regCnPunctuations = /[!¥……()——【】;:‘’“”,。、《》?]/;
    return regCnPunctuations.test(givenChar);
}
function SpliceWestTextClass(headChineseChar, tailChineseChar, prevTailIsCnPunc) {
    var westTextClass = "westText ";

    if ((!headChineseChar.IsNullOrEmpty() && !IsChinesePunctuation(headChineseChar))
        || (headChineseChar.IsNullOrEmpty() && !prevTailIsCnPunc))
        westTextClass += "leftGap ";

    if (!IsChinesePunctuation(tailChineseChar))
        westTextClass += "rightGap ";

    return westTextClass;
}
function BuildTextWithClassName(plainText, className) {
    if (!plainText.IsNullOrEmpty())
        return "<span class='" + className + "'>" + plainText.Trim() + "</span>";
    else
        return "";
}

var strTest = "Netscape在最初将其脚本语言命名为LiveScript,后来Netscape在与Sun合作之后将其改名为JavaScript。JavaScript最初受Java启发而开始设计的,目的之一就是“看上去像Java”,因此语法上有类似之处,一些名称和命名规范也借自Java。但JavaScript的主要设计原则源自Self和Scheme。JavaScript与Java名称上的近似,是当时网景为了营销考虑与太阳微系统达成协议的结果。为了取得技术优势,微软推出了JScript来迎战JavaScript的脚本语言。为了互用性,Ecma国际(前身为欧洲计算机制造商协会)创建了ECMA-262标准(ECMAScript)。现在两者都属于ECMAScript的实现。尽管JavaScript作为给非程序人员的脚本语言,而非作为给程序人员的编程语言来推广和宣传,但是JavaScript具有非常丰富的特性。";

// (0-n中文字符)(1-n西文字符)(0-1中文字符)
var regWestChars = /([^!-~]*)([!-~ ]*)([^!-~]?)/g;
var previousTailIsCnPunc = true;

WriteLine("<hr />");
WriteLine("<b>原文直接输出</b>");
WriteLine(strTest);

/* For debug.
WriteLine("<hr />");
WriteLine(strTest.match(regWestChars));
//*/

WriteLine("<hr />");
WriteLine("<b>动态样式输出</b>");
WriteLine(strTest.replace(regWestChars,
    function($0,$1,$2,$3) {
            var westTextClass = SpliceWestTextClass($1.GetLastChar(), $3, previousTailIsCnPunc);
            previousTailIsCnPunc = IsChinesePunctuation($3);
            return BuildTextWithClassName($1, "chineseText") + BuildTextWithClassName($2, westTextClass) + BuildTextWithClassName($3, "chineseText");
        }
    ));

//-->
</script>
</body>
</html>

代码6:动态设置纯文本中西文显示样式

动态设置纯文本中西文显示样式输出结果

图7:代码6的输出结果

【注】以上文本节选自维基百科JavaScript词条。

谢谢大家能看到这里,任何意见和建议请不吝赐教 :D