中文网页排版优化之字距调整和首尾字符控制

已于2015年9月2日14:00重新修订了源代码

难以标准的中文排版

在编写 WordPress 主题的时候,我重新认识到网络标准对中文排版的轻视,几乎每个细节都会出现各种问题。这对于略有强逼症的我来说,简直不能忍啊。

起初我只是想实现首行缩进,但发现只有部分段落缩进了,查询得知是 br 标签不支持缩进。好吧,这个问题很好解决,~~把 br 标签全局替换成 p 标签可以解决掉。~~用 li 标签来实现零行距的缩进。但接踵而来的是标点换行,标点会在换行后出现在行首,这明显不符合中文的排版标准,在 CSS3 里找到个似乎相关的属性,结果只有 IE 才支持。最后是行对齐,理想的排版应该是两端对齐,但 CSS 里只支持英文两端对齐。

来回研究,在排版方面,居然是最为人唾弃的 IE 做得最好,标点能正确换行,也可动态调整中文的字距来实现两端对齐,只能说,不愧是做了 Office 的微软啊……

网上搜索了一通,没发现有什么好的解决办法,还是大神都藏了一手?但看了好些网站的排版,其中不乏知名文学类的,确实也没解决掉我发现的问题。

直到前几天在浏览 HTML 5 标准的时候,无意中看到了个标签 wbr,忽然灵机一动——或许这就是解决问题的一个关键。没想到接着脑洞大开,把问题一个接一个地解决了。我的方法可能不是独创,但确实有其价值,值得单独开一篇文章,以下是我的独家分享。

字体大小和行距

正文的字体大小,一般从 12~16px 中选择。我选择在文章正文中使用 16px,摘要则是 14px,并设置了 1.5 倍行距。

这只是全凭我个人喜好的选择,具体要选择怎样的字体大小,还要看内容和网站定位而定。

段首缩进和段间距

曾经看到一个说法,段首缩进在网页中并不是必要的。段首缩进和段间距都是用来区分段落,在空一行和缩进两字宽之间对比,似乎空一行的视觉效果更好。

翻开我手上不少的图书,有很多是不设置段间距,只靠段首缩进来区分段落。而同样的版面,在网页上居然会感觉有点密密麻麻,没那么舒服。

排除媒体方面的作用,这可能是因为我们看网页是快速阅读,而阅读书本是一种慢阅读,心态决定一切,我们更愿意在网页上直观地看到清晰明确的逻辑关系。

当然,段首缩进和段间距并不是二选一的关系,而是可以同时采用,这样既有清晰的段落逻辑,也兼顾出版的中文标准。

实现段首缩进的方式有几种,一是打两个全格空格,这个方法的好处是对 br 标签也生效,缺点是有些编辑器会吞掉这些空格,比如 WordPress。二是在 CSS 里实现,好处是兼容性高,缺点是可能把一下不想缩进的标题或图片也缩进了,也不对 br 标签起作用。第三种是手动在需要缩进的段落插入设置了缩进的 div 或 p 标签,好处是排版准确度高,缺点就是需要手动。本人采用的是第三种方法。

~~前面多次提及,br 标签,即硬回车换行是不会对缩进起作用的,所以我通过代码把它替换成 p 标签,又因为 br 本身是没有段间距的,所以我又给它添加了一个 间距为 0 的 CSS 类。~~改变了想法,br 还是有保留它的必要,不能这样把它替换掉,改用 li 标签来实现无行距的换行,效果会更好。问题是,br 可以由编辑器自己生成,li 就需要你去手动插入了。

在 css 里添加样式


/* 段首缩进两个字 */
.indent{text-indent:2em}

WordPress 也可以把以下代码添加到主题的函数模板 functions.php 里来添加按钮。

// 替换 br 为 无段间距的 p
function Replace_Html($html) {
  $html=str_replace('<br />','</p><p class="br">',$html);
  return $html;
}
add_filter('the_content','Replace_Html');

//在文本编辑器中添加按钮
add_action('admin_print_footer_scripts','wpjam_add_quicktags');
function wpjam_add_quicktags() {
  if(wp_script_is('quicktags')) {
?>
<script type="text/javascript">
QTags.addButton('indent2em','缩进','\n\n','\n\n');
</script>
<?php
}}

[coll title="旧方法,已弃用"]

在 css 里添加样式


/* 将 br 的段间距设置为 0 */
.br{margin-bottom:0!important}
/* 段首缩进两个字 */
.indent{text-indent:2em}

jQuery通用代码,替换 br 标签为无段间距的 p


$('.the_content').html(function() {
  return $(this).html().replace(/
/ig,'

'); });

除了 jQuery 之外,WordPress 也可以把以下代码添加到主题的函数模板 functions.php 里来实现。

// 替换 br 为 无段间距的 p
function Replace_Html($html){
  $html=str_replace('<br />','</p><p class="br">',$html);
  return $html;
}
add_filter('the_content','Replace_Html');

//在文本编辑器中添加按钮
add_action('admin_print_footer_scripts','wpjam_add_quicktags');
function wpjam_add_quicktags() {
  if(wp_script_is('quicktags')){
  ?>
<script type="text/javascript">
QTags.addButton('indent2em','缩进','\n\n','\n\n');
QTags.addButton('pbr','0行距','<p class="br">\n','\n</p>');
</script>
<?php
  }
}

[/coll]

WordPress 请在 jQuery 和函数模板两种方式中任选一个,两者有本质区别,函数模板里的代码是在服务器段上运行,会将修改直接输出到 HTML 源代码中;而 jQuery 是在本地浏览器中执行,jQuery 修改的内容不会体现在 HTML 源代码中,只能在浏览器的开发者工具中查看。如果是增量修改的话,前者会增加 HTML 的体积,增加传输成本,而 jQuery 则不会对传输有任何影响。后面优化排版的代码可能会使 HTML 的体积成倍增加,因此全部改由 jQuery 实现。

中文标点

首先,在 Windows 里,相信不少网站都是使用了微软雅黑,但雅黑的部分全角标点很奇怪,一点也不适合中文排版,所以我选择调用另一个字体来做标点。为节省资源,我们需要使用一些工具对字体进行压缩,使它只保留我们需要使用的标点,网上有很多网站能做到这种效果,这里推荐一些。

阿里妈妈webfont平台 和图标字体一样,推荐它的原因是完全免费以及提供了字体托管,操作简单,甚至连注册也不需要,但阿里这个站的缺点也很明显,就是只有思源黑体这个字体,没有其他选择。

有字库 功能非常强大,有多种字体可供选择,如果你要的字体没有授权,甚至可以自己上传,也有托管服务。缺点是这个网站的操作比较繁复,需要注册才能使用,只提供调用字体的代码,而不能下载压缩好的字体本身。总之因为太复杂,我没做过多的尝试。

字蛛 这个不是网站服务,更是一个工具,可以在本地压缩字体,优点是无限制。确定是无托管服务,虽然把字体上传到自己的空间或[七牛云存储](https://portal.qiniu.com/signup?code=3ln23sakgyv6a)等地方,使用起来也比较复杂。

百度字体编辑器 这是一个在线工具,可以上传字体进行编辑和转换格式,它的编辑功能相当强大,但它本身又不能简单地压缩字体,所以我是在需要修改已压缩的字体的时候使用它。

除了 Internet Explorer 之外,其他浏览器都不能正确地换行中文标点,常常出现把标点换行到行首的情况。我在随意翻阅 HTML 标准的时候,看到 wbr 这个标签。

如果单词太长,或者您担心浏览器会在错误的位置换行,那么您可以使用 wbr 元素来添加 Word Break Opportunity(单词换行时机)。

当我看到它的时候,第一时间想到,如果把 wbr 添加到中文标点后面,不就可以正确地换行吗?经过测试,它的确能达到我的要求,只是为什么我没看到有什么网站去使用它呢?从兼容性来说,只有 IE 浏览器不支持 wbr,但 IE 本身就能正确对标点换行,因此也就没关系了。

两端对齐和字距调整

网页默认的排版是靠左对齐,所以在中文网页里,经常会看到右边会有空缺。特别在中英混排中,如果设置了不在单词内换行,有时这空缺会更明显,影响观感。

虽然可以在 CSS 里改成两端对齐,然而并没有什么卵用。如果是纯中文,实际效果和靠左对齐是一样的,只有在中英混排时体现出不同,特别是段落中含有空格的时候,这些空格有时就会被拉伸得非常宽广,看起来奇怪极了。这虽然是实现了两端对齐,但无疑又带来了更奇怪的排版。

那么正确是怎样排版的呢,可以去看看 IE 或者 Word 的做法,它们是通过压缩或拉伸每个字符(包括中文、英文、标点等)的字距来实现两端对齐的,和只能拉伸空格的 Chrome、Firefox、Safari、Opera 有本质上的差别。

慢着,既然它们是只对空格进行处理,那么实际上并不是不支持中文,而是只对空格起作用呢?这时我脑海里又浮现出某些港台网站在每个字中间加空格的做法,这实际上除了不让繁体字看起来密密麻麻之外,也是为了实现两端对齐?

经过验证,确实就是这样的道理,在文字中间加空格就能解决问题了,但在简体中文中加空格并不好看,所以我又找来 kbd 标签来嵌套这些空格,并添加 CSS 样式来调整宽度和字距。经过一番修改,终于离我理想中的效果越来越近了。

最终代码

本文的 JavaScript 代码基于 jQuery 库,我不太懂 JavaScript,因此无法提供无需 jQuery 的代码。本文的全部代码已经在所有主流浏览器中测试通过,如有有异常,应该是你的浏览器版本太旧了。

[coll title="CSS 样式"]


/* 这处用的是阿里妈妈 webfont 平台的字体,只含有部分标点 */
@font-face {font-family: 'webfont';
	src: url('//at.alicdn.com/t/b6a4gx5uj2xrbe29.eot'); /* IE9*/
	src: url('//at.alicdn.com/t/b6a4gx5uj2xrbe29.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
	url('//at.alicdn.com/t/b6a4gx5uj2xrbe29.woff') format('woff'), /* chrome、firefox */
	url('//at.alicdn.com/t/b6a4gx5uj2xrbe29.ttf') format('truetype'), /* chrome、firefox、opera、Safari, Android, iOS 4.2+*/
	url('//at.alicdn.com/t/b6a4gx5uj2xrbe29.svg#NotoSansHans-DemiLight') format('svg'); /* iOS 4.1- */
	}
/* 在标点的样式上应用 webfont,注意这里别设置字体大小 */
font{font-family:'webfont'!important}
/* 把空格的宽度设置为 0,再减去 0.4em 的字距,这刚好和未加空格的文本是一样的字距 */
kbd{width:0!important;letter-spacing:-.4em}
/* 这是我正文的 CSS 样式 */
.the_content{
	text-align:justify; /* 文本两端对齐 */
	text-justify:distribute; /* 文本两端对齐 */
	word-break:break-word; /* 只对超长的英文单词断句 */
	}
/* 将段间距设置为空一行 */
.the_content p{margin-bottom:1em}

[/coll]

[coll title="jQuery 代码"]

$('body').html(function()  {
  return $(this).html().replace(/[\u2010-\u203B]|[\u3001-\u301E]|[\uFE10-\uFE44]|[\uFF01-\uFF0F]|[\uFF1A-\uFF20]|[\uFF3B-\uFF40]|[\uFF5B-\uFFE5]/g,'<font>$&</font>').replace(/<\/font><font>/ig,'');
  });
/*
我选择的元素是整个 <body>,这将对网站的所有可见内容进行修改,示例中没包含所有的标点,你可以自己添加,如果要添加半角标签,请加上一个斜杠 \,比如「\,」。
修改内容:
	1. 在全角标点嵌套 <font> 标签
	2. 删除连续标点中间的 <font> 标签,变成只用一套 <font> 标签来嵌套连续的标点
*/

$('.the_content,.preview').html(function() {
  return $(this).html().replace(/<\/font>/ig,'$&<wbr>').replace(/[\u4E00-\u9FFF]|<wbr>/ig,'$&<kbd> </kbd>').replace(/<kbd> <\/kbd> /ig,' ').replace(/<kbd> <\/kbd><font>/ig,'<font>');
  });
/*
这处只选择了文章正文和主页的文章摘要的 div(请根据你的网站修改选择器里的 div 类),因为使用 <wbr> 标签会使禁止换行的 CSS 无效,可能会出现换行错误。
修改内容:
	1. 在标点的 <font> 标签后插入 <wbr> 标签
	2. 在所有宽字节字符(包括汉字、日文)和 <wbr> 标签后插入 <kbd> 空格
	3. 如果 <kbd> 空格后面紧接着一个未嵌套的空格,则删除嵌套了 <kbd> 的空格
	4. 将全角标点之前的 <kbd> 空格删除
2015/09/02 修订:加入浏览器判断,如果是IE就不再插入空格,因为IE本身就能完美排版
*/

$('.prettyprint').html(function(){return $(this).html().replace(/<kbd> <\/kbd>/ig,'');});
/* 如果正文中有不适应插入空格的内容,针对它的 CSS 类进行删除 */

$('a').each(function() {
  var t=$(this).attr('title');
  if(t) {
    t=t.replace(/<font>/ig,'').replace(/<\/font>/ig,'').replace(/<wbr>/ig,'').replace(/<kbd> <\/kbd>/ig,'');
    $(this).attr('data-title',t);
    }
  });
/*
除了直接显示的文字之外,网页里还包含了一些鼠标悬停显示的文字,这些 title 属性的内容不会对 HTML 标签进行转义,因此要特别删除里面的标签。
这行代码的功能是删除 <a> 标签内 title 属性中的 <font> 和 <wbr> 标签。
你的网站也可能在其他地方用了 title 属性,请添加到选择器中。
如果你用了 :after 和 :befot 等 CSS 选择器或第三方库来取代浏览器默认的鼠标悬停样式,你可能需要把代码中的 title 改成 data-title。当然,一切都依你网站的具体设计而定。
比如本站使用了 Layer 弹出层库来取代浏览器默认的鼠标悬停样式,Layer 弹出层支持对 HTML 标签进行转义,因此我可以保留 <font> 标签,只需要删除可能会造成排版混乱的 <wbr>
2015/09/02 修订:加入浏览器判断,如果是IE就不再插入空格,因为IE本身就能完美排版
*/

[/coll]

[coll title="如果你不想替换标点的字体,可以把代码改成这样"]

$('body').html(function() {
  return $(this).html().replace(/[\u2010-\u203B]|[\u3001-\u301E]|[\uFE10-\uFE44]|[\uFF01-\uFF0F]|[\uFF1A-\uFF20]|[\uFF3B-\uFF40]|[\uFF5B-\uFFE5]/g,'$&<wbr>').replace(/<wbr>(.)<wbr>/g,'$1<wbr>').replace(/<wbr>(.)<wbr>/ig,'$1<wbr>');
  });
/*
后两次重复的替换是为了删除连续标点之间的 <wbr>,两次替换可以处理三个连续的标点,因为中文里连续的标点一般不超过三个,一般是够用了,觉得不够可以重复多一两次
*/

$('.the_content,.preview').html(function() {
  return $(this).html().replace(/[\u4E00-\u9FFF]|<wbr>/ig,'$&<kbd> </kbd>').replace(/<kbd> <\/kbd> /ig,' ').replace(/<kbd> <\/kbd><font>/ig,'$&<font>');
  });
$('a').each(function() {
  var t=$(this).attr('title');
  if(t!=''&&t!=null) {
    t=t.replace(/<wbr>/ig,'').replace(/<kbd> <\/kbd>/ig,'');$(this).attr('data-title',t);
    }
  });

[/coll]

该方法的不足之处是,在复制网页上的文字时会把空格一起复制,这必然会造成一定的困惑。我尝试通过 JavaScript 实现复制文字的时候,从剪贴板中移除文字间的空格,但我在网上抄了不少代码,都不起作用,原来是只有 IE 支持使用 clipboardData 访问系统剪贴板。希望谁能给我提供一个可行的跨浏览器代码。总算编写好可以实现的代码……

[coll title="复制文本时去掉多余空格"]

/* 插入到 body 标签中 */
<script type='text/javascript'>
function remove_space() {
  if(navigator.userAgent.indexOf('MSIE')>0||navigator.userAgent.indexOf('Trident/')>0) {
    /* 判断浏览器是否IE,如果是IE,执行以下的代码 */
    window.setTimeout(function() {
    var text=clipboardData.getData('text');
    if(text) {
      text=text.replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([\u2010-\u203B]|[\u3001-\u301E]|[\uFE10-\uFE44]|[\uFF01-\uFF0F]|[\uFF1A-\uFF20]|[\uFF3B-\uFF40]|[\uFF5B-\uFFE5])\s(\w)/g,'$1$2');
      clipboardData.setData('text',text);
      }
    },100);
  } else {
  /* 非 IE 浏览器执行以下代码 */
  var body_element = document.getElementsByTagName('body')[0];
  var selection=window.getSelection();
  var selection_s=selection.toString();
  var copy_text=selection_s.replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([\u2010-\u203B]|[\u3001-\u301E]|[\uFE10-\uFE44]|[\uFF01-\uFF0F]|[\uFF1A-\uFF20]|[\uFF3B-\uFF40]|[\uFF5B-\uFFE5])\s(\w)/g,'$1$2');
  var new_div=document.createElement('div');
  new_div.style.left='-99999px';
  new_div.style.position='absolute';
  body_element.appendChild(new_div);
  new_div.innerText=copy_text;
  selection.selectAllChildren(new_div);
  window.setTimeout(function() {
    body_element.removeChild(new_div);
    },0);
  }
}
document.body.oncopy = remove_space;
</script>

代码解释:

IE 浏览器使用 window.clipboardData 对象访问剪贴板,这个函数相当简单直接,但只支持 IE 浏览器。其他浏览器则使用了 Selection API,这个接口几乎支持所有的浏览器,除了 IE 6/7/8……

Selection API 的用法有些复杂:

  1. 获取剪贴板对象
  2. 把剪贴板对象转换成字符串,注意获取文本不再是网页源代码,没有 HTML 标签了,所以下面移除空格也变得稍为负责
  3. 用正则表达式去除字符串中多余的空格:两个中日韩文字之间的空格、全角标点后面的空格
  4. 因为 Selection 不能直接把字符串获取到剪贴板,所以在 body 里新建一个 div 元素,并把 div 定位在窗口外面,使其不可见
  5. 将字符串写入到新建的 div 中,注意我这里用的是 innerText,我原来参考的代码用的是 innerHtml,区别在于后者会对 HTML 标签进行转义,如果文章里有贴出源代码的话,访客复制的文本可能就被改变了,后者也不支持 \n 换行符,这样复制出来的文本是不换行的。改用 innerText 可解决这些问题
  6. 重新从 div 中获取 Selection 对象到剪贴板
  7. 删除 div 元素
    [/coll]

此外,IE 浏览器应该(注意是“应该”,因为我未测试早期的 IE 版本,不敢打包票)是不需要在汉字中间插入空格也能两端对齐,所以有不少代码可以加入条件判断,如果是 IE 就不执行。所以部分代码可以改成以下的样子。

[coll title="加入 IE 条件判断"]

/* 中日韩文字间插入空格的代码 */
if(navigator.userAgent.indexOf('MSIE')<0||navigator.userAgent.indexOf('Trident/')<0) {
  // 首先判断浏览器是否IE,非 IE 才继续执行代码
  $('.the_content,.preview').html(function() {
    return $(this).html().replace(/<\/font>/ig,'$&<wbr>').replace(/[\u4E00-\u9FFF]|<wbr>/ig,'$&<kbd> </kbd>').replace(/<kbd> <\/kbd> /ig,' ').replace(/<kbd> <\/kbd><font>/ig,'<font>');
    });
}

/* 复制文本时去掉多余空格 */
<script type='text/javascript'>
// 首先判断浏览器是否IE,非 IE 才继续执行代码
if(navigator.userAgent.indexOf('MSIE')<0||navigator.userAgent.indexOf('Trident/')<0) {
  function remove_space() {
    var body_element = document.getElementsByTagName('body')[0];
    var selection=window.getSelection();
    var selection_s=selection.toString();
    var copy_text=selection_s.replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([^\x00-\xff])\s([^\x00-\xff])/g,'$1$2').replace(/([\u2010-\u203B]|[\u3001-\u301E]|[\uFE10-\uFE44]|[\uFF01-\uFF0F]|[\uFF1A-\uFF20]|[\uFF3B-\uFF40]|[\uFF5B-\uFFE5])\s(\w)/g,'$1$2');
    var new_div=document.createElement('div');
    new_div.style.left='-99999px';
    new_div.style.position='absolute';
    body_element.appendChild(new_div);
    new_div.innerText=copy_text;
    selection.selectAllChildren(new_div);
    window.setTimeout(function() {
      body_element.removeChild(new_div);
      },0);
  }
document.oncopy = remove_space;
}
</script>

[/coll]

总结

不直接在 CSS 里设置段首缩进,而是改用手动插入,是为了降低出错的机会。而最稳妥的做法是自己写好所有该出现的 HTML 标签,最大限度地避免由程序去自动生成标签,只是这样的效率太低下,不值得提倡。

替换标点字体的初衷是为了改善显示效果,如果部分标点替换后的表现反而更差,就应该把这些标点从字体中删除。另外,现在大部分 CSS 中都设置了多个字体,其中需要替换标点字体的可能只有微软雅黑,这个修改会将所有字体的标点替换掉,有点一刀切。

如果你的网站有使用图标字体,那还可以把标点字体和图标字体合并,我的方法是先压缩好标点字体,然后把它上传到 IcoMoon,再下载字体,里面会有一个图标一个文件的 SVG 格式,把这些 SVG 文件上传到 Iconfont,合并到原来的图标字体中,再把标点图标的 Unicode 码修改成正确的就可以了(可以用百度字体编辑器打开原来的标点字体来查询 Unicode 码)。

这个两端对齐的排版总算是搞掂了,我曾经考虑在英文字母之间也插入这些看不见的空格,但我最终连测试也没有做,因为现在看起来也足够了。提醒一下,写文章的时候记得在英文和汉字之间手动输入一个空格,这也是中文排版的要求。

这篇文章只是抛砖引玉,期望高手能扔给我更好的解决方案。

PS. 才发现 blink 内核的浏览器已经能很好地实现中文两端对齐和首尾字符控制。

作者:Xelloss
本博客文章采用 知识共享(Creative Commons) 署名-非商业性使用-禁止演绎 4.0 进行许可。