标题加 balance、正文加 pretty?我拿中英两份语料量了 1470 个档位

标题加 balance、正文加 pretty?我拿中英两份语料量了 1470 个档位

半路手记 · 踩坑实录

问题从哪来

设计圈现在有一行几乎当常识在流传的写法:标题给 text-wrap: balance,正文给 text-wrap: pretty。我手上那份外部设计规则集里也是这么写的,配了一句“正文交给 pretty 就行”。

这句话要真写进我的模板,就得先回答一个很具体的问题:在一个正文全是中文的站上,这两个值到底改动了什么?

balance 干的事是把一个块里的各行长度拉平,pretty 干的事是避免出现很短的末行(孤行)。两个听起来都跟中文排版有关,但中文没有词间空格,每个字都是合法断点——这两条机制搬到中文上还成立吗?

猜没意义,量。

怎么量的

思路很简单:拿同一段文本,按字符数一档一档截短,每档读一次逐行宽度。文本长度连续变化,行数就会从一行爬到十几行,末行宽度也会遍历各种长短——这比自己手挑五个“看起来有代表性”的用例要诚实得多。

步长取 1,不用 2、不用 6。这条是后来补的课,第三节的错就是步长 6 造成的,放在后面说。

关键是每一档都要新建一个元素。这一点我是撞了墙才知道的,后面会说。

夹具页里放两段文本(一段英文、一段中文,长度够爬到十三行)和一个测量函数:

// 按 top 把 Range 的矩形分组:同一行的小矩形合并,一组就是一行
function widthsOf(el) {
  const r = document.createRange();
  r.selectNodeContents(el);
  const rects = [...r.getClientRects()].filter(x => x.width > 0 && x.height > 0);
  const rows = {};
  for (const x of rects) {
    const k = Math.round(x.top);
    if (!rows[k]) rows[k] = { l: x.left, r: x.right };
    else { rows[k].l = Math.min(rows[k].l, x.left); rows[k].r = Math.max(rows[k].r, x.right); }
  }
  return Object.keys(rows).sort((a, b) => a - b).map(k => Math.round(rows[k].r - rows[k].l));
}

// 每档新建块,测完就摘掉 —— 不复用同一个元素
function fresh(text, wrap) {
  const p = document.createElement('p');
  p.textContent = text;
  p.style.textWrap = wrap;
  stage.appendChild(p);
  const w = widthsOf(p);
  stage.removeChild(p);
  return w;
}

Range.getClientRects() 在标题这类元素上会返回每行的小碎片,不按 top 合并就把“末行”读成了最后一个碎片。这个坑我先踩过一次,第一版数据整列偏小。

驱动脚本用无头 Chrome 走 CDP,每档跑三次(normal 当基准,再分别跑 balance 与 pretty),比较逐行宽度数组是否完全相同。node dense.js 40 463 CN pretty 一轮 424 档,几十秒的事。

语料长度要单独交代:网格的上限就是语料的长度,中文那份 463 字、英文那份 1085 字符。第一版我没管这条,代价写在方法坑第四节里。

截断网格有一个天生的限制:末行是什么字符,语料说了算,我指定不了。所以第三节讲 pretty 的触发条件时,我另造了第二个夹具——前 38k 格填满无标点汉字,末行那几格由我写死。两个夹具的结论要能对上,才算数。

环境说清楚:Chromium 156 无头、桌面视口、盒宽 620px、正文 16px/行高 1.7、系统字体栈、样式写在元素上(el.style.textWrap)。中文每行恰好放得下 38 个字(608px),这个数字下面还要用到。方向和上限是可复用的,具体像素数属于这个夹具。

总量:中文 n=40..463 共 424 档,英文 n=40..1085 共 1046 档,合计 1470 档,每档都要与 normal 对齐比一次。

第一件事实:balance 的上限是“整块跳过”

同一段文本逐档截短,末行宽度按行数归组统计“这一档 balance 改没改”:

行数英文:改 / 不改中文:改 / 不改
10 / 50—(本夹具最短也是两行)
275 / 935 / 2
371 / 1236 / 2
471 / 1136 / 2
583 / 736 / 2
683 / 536 / 2
70 / 850 / 38
80 / 850 / 38
90 / 860 / 38
100 / 830 / 38
110 / 810 / 38
120 / 870 / 38
130 / 620 / 7

七行是一道硬坎:中英两份语料在七行及以上共 804 档(英文 569、中文 235),无一例外全不响。改动的 562 档(英文 383、中文 179)全部落在二到六行之间;二到六行共 616 档,剩下 54 档没动的原因是末行本来就快满宽了——中文那 10 档整齐得像刻度表,全是 n ≡ 37 或 0 (mod 38) 的档位(n=75、76、113、114……末行 592px 或 608px),没东西可平衡;英文那 44 档同理。另有一行的小块 50 档也无从平衡。

Chrome 的 balance 文档里给过一条限制:平衡只应用到六行以内的换行内容(原文是英文,这句是我的转述)。但这句话有两种读法:可能它只平衡末尾六行,也可能整块直接放弃。实测是后一种。 一个七行的中文段落,末行只剩 96px(约六个字),加不加 balance,逐行宽度一模一样;十三行、末行只挂一个字的那一档也一样:

n=234  608/608/608/608/608/608/96              → 一模一样
n=457  608×12 / 16(十三行,末行只有 1 个字)  → 一模一样

这一条直接决定了 balance 的适用面:它是给短块用的,不是给正文用的。任何“给正文段落加 balance 治孤行”的写法,七行以上等于没写——浏览器还要付出计算代价。

第二件事实:中文才是 balance 收益最大的地方

六行以内,中文的改动幅度比英文大,而且改的是整块,不只是末行。中文重排的粒度是一个字(16px),所以它能做到逐行严格等宽:

中文档位(620px 盒)normal 逐行宽度balance 逐行宽度
三行,末行 1 个字(n=77)608 / 608 / 16416 / 416 / 400
三行,末行 2 个字(n=78)608 / 608 / 32416 / 416 / 416
四行,末行 6 个字(n=120)608 / 608 / 608 / 96480 / 480 / 480 / 480
六行,末行 2 个字(n=192)608 ×5 / 32512 ×6

normal 下中文正文的默认断行就是“每行填满、末行剩零头”,balance 把整块摊平之后,末行从两个字变成 416px。中文里那种“最后一行挂一个标点”的难看收尾,一条声明就没了。

英文做不到这么整齐——它有词边界约束,重排后各行宽度只能近似相等(例如 n=258:589/597/598/23 → 459/465/451/433)。同样是“末行只剩几个字”,中文能被拉平得更彻底。

代价也要说清:balance 会缩短前面的行(608→416 是每行都让出了近三分之一宽度)。所以它放在标题、副标题、一张卡片的引导语上很好看,放进正文栏会让人读到一列参差不齐的右边缘。短块用它,长栏别用它。

第三件事实:pretty 在中文上只治一种病——末行只剩一个实字

先给总数:中文 424 档,pretty 改动 12 档;英文 1046 档,改动 51 档。

中文这 12 档不是散开的。620px 的盒里中文每行恰好放 38 个字,n mod 38 就决定了末行剩几格。先看其中 11 档:n ≡ 1 (mod 38),末行正好剩一个字,pretty 11 档全救:

截断点行数normal 逐行宽度pretty 逐行宽度
n=773608 / 608 / 16592 / 592 / 48
n=1916608 ×5 / 16608 ×4 / 592 / 32
n=2297608 ×6 / 16608 ×5 / 592 / 32
n=34310608 ×9 / 16608 ×8 / 592 / 32
n=45713608 ×12 / 16608 ×11 / 592 / 32

手法固定:紧挨着末行的那一行让出一个字(608→592),末行从 16px 变 32px;三行的短块它让前两行各出一个字,末行变 48px。

并且它没有六行这道坎。 同一档文本,balance 在七行以上整块跳过,pretty 一路管到十三行:

n=457  中文十三行,末行 16px(只剩一个“流”字)
  balance → 608×12 / 16        一模一样
  pretty  → 608×11 / 592 / 32  动手了

我第一版稿子里写过“一档文本,balance 因为行数上限罢工,pretty 越过了那个上限还在干活”,当时只在英文上找到例证。中文上同样成立,只是第一版那 57 档一个触发点都没采到——这条为什么采不到,方法坑一节专门算。

末行两个格它救不救?中文网格有 12 档末行恰好两格,11 档原样放行,只有一档动了:

动了:  n=382  11 行  608×9 / 608 / 32  →  608×9 / 592 / 48
没动:  n=192   6 行  608×5 / 32        →  原样
没动:  n=458  13 行  608×12 / 32       →  原样

动了那一档的末行两格是“事:”——一个汉字后面挂一个全角冒号。第一版稿子里我把它当异常记着。这次专门造了第二个夹具去问:它是巧合,还是一族?

夹具的做法是把行长钉死:前面 38k 格填满无标点汉字,末行由我指定。先问“末行有几个字”这一维,m 个汉字再加一个收尾符,m = 1..4,收尾符取 。、:、全角空格、以及不挂标点,六行与十行两个数组各跑一遍,32 档:

末行内容normal 末行pretty 末行pretty
一个汉字,不挂标点16px32px动了
一个汉字 + 。 / : / 全角空格32px48px动了
两个汉字,不挂标点32px32px不动
两个汉字 + 。 / : / 全角空格48px48px不动
三个、四个汉字的所有组合48–80px原样不动

32 档里动的正好 8 档,全是 m = 1 那几格。

同样 32px 的末行,“事:”动了,“事事”没动。 所以它数的不是像素,也不是格数,是实字:一个字挂一个收尾标点,在它眼里还是一个字;两个实字摆在那儿,后面挂不挂标点它都不管。

这条规则回过去解释网格也刚好闭合。把 424 档按“末行实字数”重新数一遍,实字数等于 1 的正是 12 档——n = 77、115、153、191、229、267、305、343、381、382、419、457——和 pretty 实际改动的那 12 档一字不差。预测 12、命中 12,没有一档需要额外解释。n=382 不是异常,它是这一族在网格里唯一的“字+标点”样本。

再问第二维:什么样的收尾符算“不算字”。27 个收尾符各造一档(六行版),pretty 动了 20 档。动的是 14 个全角标点(: 。 , 、 ; ? ! 」 』 ) 》 】 〕 …)、4 个半角标点(. ? ! ))和全角空格;还有一档收尾符是半角空格,行尾空格直接被吃掉,末行塌成 16px,那一档就是普通的孤字行,不能当独立证据。没动的是破折号 —、半角数字 5、半角字母 a,另加四个纯汉字对照(直、引、照、水)。

所以数字和字母算实字,中英文标点大多不算——它用的是断行分类(哪些字符不该被单独留在行首),不是我口语里那句“标点”。破折号为什么站在不动那一边,我解释不了:规范里 pretty 的判定式我至今没读到(取证为什么难,本节末尾就说)。这一条也只报观测。

英文给不出这么整齐的规则,因为断点由词宽决定。能确认的是三段:

normal 末行宽度档数pretty 动了
≤20px1111
20–76px9240
>76px9430

两头是干净的:末行短到 20px 以内它必动,宽过 76px 它一次都没动。中间那 92 档才是它“看情况”的地方——有十三个宽度值上同时存在动了的和没动的档位(27、31、38、39、40、44、49、52、61、63、66、68、76px)。同样 66px 的末行,四行时不动(n=264),十行时动了(n=774);同样 76px,两行时动了(n=97),八行时不动(n=609)。所以末行宽度不是英文 pretty 的触发条件,还得连“哪个词能被挪下来”一起算。我第一版想从 141 档里归纳出这条规律,归纳不出;扩到 1046 档仍然归纳不出,但至少说清了它在哪个区间是随机的。

能确认的还有方向:改后末行总是变宽,而且它挪的往往不止末行——n=606 那一档它把第五行往后的整块重排了(607/596/595/59 → 589/606/573/90)。

balance 和 pretty 不等价、不能互相顶替,英文这一档最直白:

十行英文 n=774,normal 逐行宽度 589/597/598/618/607/596/595/615/563/66:
  balance → 一模一样     (超过六行上限,整块跳过)
  pretty  → …595/585/566/93(动了:末行 66→93,第八、九行跟着挪)

“给正文加 pretty 代替 balance”或者反过来,都是拿一个不管事的值去顶另一个的岗位。

至于 pretty 为什么在中文上只认“末行一个实字”这一种孤行,我依然没能拿到一个我信得过的解释。第一版稿子的对象是“为什么完全不动”,现在对象换了,取证的难处没变:text-wrap 的词条在 CSS Text 4 里,但我两次抓取那页都只落到目录,正文没取回来,pretty 的判定式到底写没写、写到什么程度,我没读到,不敢替它编。又去搜“CJK 下 pretty 无效”的原因,回来一批教程站,其中一个把原因说成“依赖空格分词”,听起来正好能解释我的观测——但同一批页面连基本事实都在互相打脸:一处写 Safari 17+ 支持 pretty,另一处写 Safari 完全不支持。这种质量的来源我不能拿来当结论的支撑。

所以这一条我也只报观测,不报原因:在这个引擎、这个版本、这两份夹具上,中文 pretty 的作为范围就是“末行只剩一个实字”。

方法坑:这份数据里我自己错过的五处

上面那句“我撞了墙才知道”的墙是第一处。

我第一版扫描是在同一个 <p> 上反复换文本、换 text-wrap 值。结果 84 档全部报告“balance 与 normal 逐行宽度一致”,连两行的短块都不改。这跟手挑夹具测出来的强效果直接矛盾。

改成每档新建元素、测完摘掉,同一次运行里就拿到了上表那个干净的六行分界。也就是说:在同一块上连续改样式再比对,会读到上一轮的布局缓存,把真实存在的差别量成零。

这个坑的方向很坏。假阴性长得跟“这个属性本来就没用”一模一样,而后者正好是一个很容易让人接受的结论——它甚至会迎合你原本的判断。

第二处方向相反。第一版英文只扫了 141 档,pretty 动了 8 档,我把这 8 档的末行宽度一看——全在 70px 以下——顺手就总结成“末行短于一成半才触发”。这是拿命中样本反推条件,分母一个没看。补量之后,70px 以下那一族里有五档没动,最短的 25px 照样没救,这条规律当场作废。只挑“改了的那几档”去找共性,得到的是幸存者名单,不是触发条件。 现在扩到 1046 档,同一类错误换了个面貌等着我:≤20px 全动、>76px 全不动,两段整齐得诱人把它直接写成判据——中间那 92 档里有 52 档没动,判据就是断在这一段。

第三处是这次最贵的一处,它直接产出了那条错误结论。第一版中文我扫 57 档,步长取 6,从 n=60 起:60、66、72、78……清一色是偶数。而这份语料每行恰好 38 个字,“末行只剩一个字”要求 n = 38k + 1;38k 是偶数,加 1 必是奇数。两边的档位集合完全不相交——我扫五十七档、五百档、五千档都一样,一条都采不到。

算术在这里:采样步长与触发周期一旦有公因子,某些余数类就整族消失。 6 与 38 的最大公约数是 2,这条网格只能落到偶数余数类上,那另一半的档位——包括全部十一个“末行一个字”的档位——不是运气不好没碰上,是结构上不可能碰上。按周期均匀估一下“期望命中 1.5 次、一次没撞上也约有两成概率”这种说法在这里并不适用,命中率是 0,而且是确定的 0。补一句:真正的触发点其实是十二档,十一档之外还有一档 n=382(末行“事:”,n ≡ 2),它虽然落得进偶数那一半,却也不在 60 + 6k 这条线上(382 除以 6 余 4),照样采不到。

这一类漏检最麻烦的地方是它不报错。每一档都测得干干净净,逐行宽度全都有值,比较逻辑也没 bug,只是每一步都正好落在“不发生”的那一侧,最后交出一份自洽的假结论。改成步长 1,或者至少让步长与每行字数互质,这类盲区才会现形。

第四处是同一次返工里撞的:给网格定上限时我按“扫到 400 档够宽了”来定,没管语料有多长。当时中文语料只有 294 字,n 一旦超过 294,截出来的都是同一篇完整文本——那一轮 361 档里有 106 档是重复渲染,行数最多也只到八行,正好把九到十三行那一段(也就是最能区分“pretty 有没有行数上限”的那一段)盖住了。网格的上限由语料长度决定,不由你写的 for 循环决定。 语料加长到 463 字以后,十三行才第一次被量到。同一个坑在第二个夹具上又踩了一次:填充串我只写了 182 字,slice(0, 38*k) 在 k ≥ 5 时静默截到 182,三十档全塌成同一种构造——预期“六行、末行 32px”,跑出来“五行、末行 512px”。是输出里那两列行数和末行宽度自己打架,我才看见构造没成立。构造类测量最好在表里留一列“这一档的构造成立吗”,比事后肉眼校对可靠。

第五处是一段比较写错了类型。新表格第一版跑出来,balance 那一列三十档全报“动了”,连十行的块都说自己改了——跟我自己那份网格数据(七行以上零改动)当场顶牛。查下去是脚本把“逐行宽度数组”和“整个返回对象”放在一起 JSON.stringify 比对,两边永远不等,判定恒真。一个恒真的判据不会报错,只会安静地制造阳性。 这回它撞上的恰好是我另一份实测:网格那边 804 档零改动,这张表这边三十档全动,不一致才逼人先去查脚本,而不是去写一条新结论。两份构造不同的夹具互相咬合,价值就在这儿。

顺带一条:硬换行的引诗不受影响

中文站上有一类块是作者手动断行的:引诗、歌词。它们通常配 white-space: pre-wrap,每行一个硬换行。我担心 balance 会去重排这类块,专门测了一档:

四行引诗(每行硬换行,18px/行高 1.8):
  normal  → 180 / 180 / 162 / 180
  balance → 180 / 180 / 162 / 180
  pretty  → 180 / 180 / 162 / 180

balance 只在一个软换行块内部重新分配,它不动硬换行。所以给标题批量加上 balance,不必为引诗另开例外规则。

落到 CSS

我现在的写法:

h1, h2, h3, .lede, .card > p:first-child { text-wrap: balance; }
/* 正文长栏不加 balance:六行以内它把整块摊平、右边缘参差;六行以上它本来就不干活 */
p { text-wrap: pretty; }   /* 中文上它只接一单活:末行只剩一个实字(挂不挂标点都算,网格 12 档全中,十三行也救)。末行有两个实字,它就不管了。 */

三条判断:

  1. 给短元素加 balance,并逐条读数。用 *{text-wrap:balance} 会把六行以内的每个块都重排,包括你不想动的地方;超过六行的地方它又不干活。两头都不讨好。
  2. pretty 可以留着,但要照它真实的那条边界期待它。它在中文上确实做事,做的事只有一件:把末行那一个实字的孤行挤成两个字,而且不看行数上限。它不做的事同样明确——末行摆着两个实字,后面挂个句号它也不动。“正文交给 pretty 就行”这句话,对一字孤行成立,对其它孤行不成立。
  3. 超过六行的正文段落,除一字孤行之外只能自己治:调栏宽(620px 与 900px 下末行长短完全不同)、调字号,或者接受它。这个位置浏览器不会替你做主。

检查清单

  • 拟加 text-wrap 的元素,实际行数在六行以内吗?超了就等于没写(Chromium 156 实测:改动的 562 档全落在二到六行,七行及以上的 804 档零改动,分界在第六与第七行之间)。
  • 加了 balance 之后,读一遍所有行的宽度,不只读末行——它会缩短前面的行。
  • 你在治哪一种孤行?末行只剩一个实字的,pretty 会接;末行有两个及以上实字的(事事 和 事事。 都算),pretty 不接,只能调栏宽或字号。
  • 对比测量是不是每档新建元素?复用元素会读出布局缓存,把有差别量成零。
  • 那个“动没动”的布尔值,比较的两边类型对不对?拿数组和对象比,判据恒真,阳性会自己长出来。
  • 归纳出的触发条件,分母是全量档位还是只看了命中的那几档?后者会得到一份幸存者名单。
  • 扫描步长与“每行容纳的字符数”互质吗?中文 620px/16px 是每行 38 字,取步长 6 会整族漏掉奇数余数类,命中率是确定的 0。
  • 语料长度够不够爬到你要验证的行数?网格上限等于语料长度,超出的档位是同一篇文本的重复渲染;slice 越界不会报错,只会静默给你同一种构造。
  • 引擎只测了一个版本,别把结论外推到 Firefox / Safari(社区文档给 Firefox 报的上限是十行,Safari 说没设上限,我本机没测)。
  • 引诗、歌词这类硬换行块:确认你的 white-space 设置,别把 pre-line 当 pre-wrap 用。

一句话

balance 是短块的活:六行以内它在中文上把整块摊平得最好看,七行以上整块跳过(中英两份语料 804 档零改动)。pretty 在中文上不是一句“不管用”能打发的:它只接一单活,把末行只剩一个实字的那种孤行挤成两个字,从三行一路管到十三行;末行有两个实字它就站在旁边——那一类孤行还得你自己治。

出处

上面所有像素数是我自己量的,夹具就是文中贴出的那几段代码,任何人换个盒宽都能重跑。只有三条不是我的观测,单独标出来: