硬换行、软折行与渲染:中文段落里为什么多了空格
一篇 Markdown 文档,作者为了在编辑器里好读,每行写到 40 个字就手动换行。发布出来,中文段落里每隔一段就出现一个莫名的空格;换个浏览器看,空格又没了。另一位同事用编辑器的「格式化段落」整理了一遍文档,结果文档里的代码块被并成了一行。
「换行」至少有三种不同的东西:文件里真实存在的换行符(硬换行)、编辑器为了显示把长行折到下一行(软折行,文件不变)、渲染器和浏览器把源文件里的换行显示成什么。三者混在一起,就会出现上面的问题。中文让问题更明显,因为英文单词之间本来就有空格,而中文没有。
本文用 Vim 9.1 验证编辑器一侧的行为,用 markdown-it-py 和 Playwright 驱动的三种浏览器验证渲染一侧。实验见文末。
一、软折行:只改显示,不改文件
set wrap linebreak 后保存:文件内容不变,仍是 1 行wrap 让超过窗口宽度的行折到下一行显示,linebreak 让折行发生在合适的字符处而不是正好在最后一列。这只影响显示:文件里仍然是一行。软折行下用 gj、gk 按屏幕行移动,j、k 按文件行移动。
如果目的只是「在编辑器里好读」,软折行就够了,文件里的段落保持一行,渲染时不会出任何问题。本站的文章就是这样写的:一个段落一行。
二、硬换行:gq 与中文
gq 按 textwidth 把文本重新排版成多行,这会真正改变文件:
textwidth=20,默认 formatoptions,gqq 一行纯中文:1 行,缓存失效之后所有请求都打到数据库上,命中率几乎掉到零,扩容的那一刻最明显
formatoptions+=mM:4 行,缓存失效之后所有请求⏎都打到数据库上,命中⏎率几乎掉到零,扩容的⏎那一刻最明显(⏎ 表示换行符。)默认设置下 Vim 只在空白处断行,一整段没有空格的中文断不开。formatoptions 里加上 m(允许在多字节字符之间断行)和 M(合并行时不在多字节字符旁插入空格),才能处理中文。合并行时也有同样的问题:
默认 J 合并两行中文与两行英文:第一行的结尾 第二行的开头⏎end of line one start of line two
formatoptions+=B:第一行的结尾第二行的开头⏎end of line one start of line twoJ 默认在两行之间插入一个空格,对英文正确,对中文就多了一个空格。B 让两个多字节字符之间不插空格,英文照常处理。中英混排的文档可以设 setlocal formatoptions+=mB。
gq 不理解 Markdown 的结构:
对一段说明加代码块执行 gggqG:一段说明文字, 后面跟着代码:⏎⏎```sh make build make test ```对整个文件执行 gq,代码块的三行被当成一个段落并成了一行,这段代码已经不能运行。列表、表格、硬换行(行尾两个空格)同样会被破坏。排版只应作用于选中的普通段落;需要整篇格式化时,用理解 Markdown 语法的格式化工具(例如 Prettier 的 proseWrap 选项),并在提交前看一遍差异。
三、渲染:段落里的换行显示成什么
CommonMark 规定段落内的单个换行是软换行,渲染器可以输出换行符或空格,由浏览器决定怎么显示:
markdown-it-py 4.2.0(CommonMark)渲染两段软换行的结果:<p>中文段落的第一行⏎接着写第二行</p>⏎<p>first line of text⏎second line</p>⏎HTML 里的换行符属于空白,在 white-space: normal 下要被折叠。英文单词之间的换行变成一个空格,这是对的;两个汉字之间的换行该怎么处理,CSS Text 规范把它交给了浏览器(segment break transformation),实际结果并不一致:
chromium 153.0.8010.12:两个汉字之间的换行显示成空格,两个英文单词之间的换行显示成空格
firefox 155.0:两个汉字之间的换行不显示,两个英文单词之间的换行显示成空格
webkit 26.6:两个汉字之间的换行显示成空格,两个英文单词之间的换行显示成空格这就是开头「中文里多了空格,换个浏览器又没了」的原因:Firefox 在两个汉字之间去掉了换行,Chromium 和 WebKit 把它显示成空格。作者在源文件里每 40 个字换一次行,读者在 Chrome 和 Safari 里就会看到每隔 40 个字一个空格。
几种处理方式:
- 段落写成一行,编辑器用软折行显示。最简单,与任何渲染器、浏览器都兼容。
- 渲染时去掉汉字之间的软换行:一些 Markdown 插件会做这件事,但要确认它只在两个汉字之间生效,不影响英文。
- 需要真正换行时用硬换行(行尾反斜杠或两个空格),或者干脆分段。
四、modeline:单个文件的设置
想让某一个文件使用特殊的 textwidth,可以在文件开头或结尾写一行 modeline:
# vim: set textwidth=33 :以 root 运行、-N:modeline=0,textwidth=0
set modeline 之后打开同一个文件:textwidth=33
modeline 里设置 foldexpr:E992: Not allowed in a modeline when 'modelineexpr' is off: foldexpr=Danger();foldexpr 仍是默认值 0modeline 是写在文件里、由打开文件的人的编辑器执行的设置,所以有几层限制:Vim 默认只对普通用户开启,以 root 运行时关闭;能执行表达式的选项(foldexpr、statusline 等)默认不允许在 modeline 里设置。历史上出现过通过 modeline 执行任意命令的漏洞,打开来路不明的文件时不要放宽这些限制。项目级的排版约定更适合放在 .editorconfig 或 ftplugin 里,而不是每个文件的 modeline。
五、检查清单
- 只为了好读就用软折行(
wrap、linebreak),不改文件。 - 中英混排的文档设
formatoptions+=mB,避免断不开中文、合并时多出空格。 - 不对整篇 Markdown 执行
gq;整篇格式化用理解语法的工具并检查差异。 - 中文段落不在句中手动换行,或在渲染时处理汉字之间的软换行;至少在 Chromium 与 Firefox 各看一次。
- 不放宽 modeline 的限制;项目约定写进
.editorconfig。
配套实验
- codesphere-labs/engineering/line-breaks:Vim 9.1、markdown-it-py 4.2.0、Playwright 1.63.0:gq 与 formatoptions 的 m、M、B,J 合并中文行,wrap 不改文件,gq 破坏代码块,modeline 的限制,段落内换行在 Chromium、Firefox、WebKit 中的显示(验证记录)
参考资料
- Vim 帮助:
:help 'wrap'、:help 'linebreak'、:help gq、:help fo-table、:help modeline、:help 'modelineexpr' - CommonMark Spec:Soft line breaks
- CSS Text Module Level 3:Segment Break Transformation Rules
- Prettier 文档:Prose Wrap