流动的文字 · 与它让开的东西
想看文字给移动物件让路?一整屏中文短文绕着自己漂移、碰撞的物件逐字测量断行,物件可用鼠标拖拽扔出;支持两栏三栏切换、暂停复位与滚轮缩放,衬线字体加大留白,单文件静态页。
流动的文字
与它让开的东西
一篇关于排版的短文 · 逐字测量,实时断行
两栏 · 三栏
暂停复位125 行 · 测量 301 ms(一次) · 重排 2.50 ms
物件自己会动 · 拖一把可以扔出去 · 滚轮缩放 · 全文实时重排
01
一、被挡住的那一行一段文字遇到一样东西,只有三种办法:从它上面跳过去,把它挤到边上,或者绕着它走。前两种容易,第三种一直很难 —— 难到在印刷术的大部分历史里,它都被当作一项要额外收钱的手艺。中世纪的手抄本里已经有绕排了。彩绘首字母占掉版心左上角一大块,抄写员就把接下来三四行依次缩短,让文字沿着字母的轮廓退让。那时候没有什么算法,只有一双眼睛和一整天时间:抄错一行,整页重来。值得注意的是,那些首字母并不是「插图」。它们是版面的一部分,文字为它们让路是理所当然的事。绕排最初不是一种效果,而是一种秩序。真正让绕排变成奢侈品的是金属活字。每一行都是一条铅条,要让第三行比第二行短八个字宽,就得往行末塞进恰好八个字宽的空铅 —— 而空铅只有固定的几种厚度。排一段绕着圆形照片走的文字,是拿一堆矩形去逼近一条曲线,一次一行,全靠手。二、断行是一道最短路径题把绕排先放一放,看更基础的那个问题:一段文字,在给定宽度下,该在哪些地方断行?最朴素的做法是贪心:从左往右塞词,塞不下就换行。打字机和早期的文字处理软件都这么干。它的毛病是只看当下 —— 这一行塞得很满,代价可能是下一行只剩三个词,稀稀拉拉像被拉长的橡皮筋。更好的想法是把整段一起看。给每一行的松紧程度打一个「难看分」,再给整段的总分求最小值。这样一来,某一行主动少放一个词,若能让后面三行都舒服,就是划算的。一旦这么定义,它就变成了一道标准的最短路径问题:
02
每个可以断行的位置是一个节点,从一个断点到下一个断点是一条边,边的权重就是那一行的难看分。求整段最优断行,等于在这张图上求一条最短路。这个转化是关键的一步。它意味着最优解可以用动态规划在线性时间附近算出来,而不需要去试所有断法 —— 后者的数量随段落长度指数爆炸。难看分怎么定,是审美问题不是算法问题。通常会惩罚过松和过紧的行、惩罚连续多行都在行末断出连字符、惩罚段落最后一行只剩一个词。换一套惩罚,就换一种排版风格。三、为什么它一直很贵算法这么清楚,为什么浏览器里的文字绕排至今还是个半成品?答案不在断行,在测量。要判断一行放不放得下,得先知道这些字有多宽。而字的宽度不是把每个字符的宽度加起来那么简单:西文有字距调整,某些字母组合会被合并成一个连字;阿拉伯文的字形随上下文变化;同一段文字换一个字号,宽度不是等比例缩放。所以真正的成本是:每问一次「这一串字有多宽」,都要跑一次真正的文字整形。而最短路径要问的次数,是候选断点数量的平方级。解法是把测量和断行拆开。先把整段文字过一遍,把每一个字素的宽度问清楚,只问一次,缓存下来。之后所有的断行计算都只是在一个数字数组上做加法 —— 从「反复整形文字」变成「反复求和」。这一拆,代价的量级就变了。原本每一次重排都要重新整形一整段文字,现在只需要重新求和。前者是毫秒级甚至几十毫秒,后者是微秒级。接下来才轮到绕排。障碍物的作用,是把每一行的可用宽度从一个常数变成一个随行号变化的数组:第一行可以用整幅,第二行左
03
边被占掉六十个像素,第三行两边都被占掉。断行算法本身一个字都不用改,只是每一行去查一次自己那一行能用多宽。把障碍物投影成「每一行的可用区间」,是整件事里最省力的一步抽象。圆、鱼、塔、云,在断行算法眼里全都一样:一串左右边界。形状的复杂度被挡在了排版逻辑之外。当然,投影本身要小心。一个凹形障碍物会把某一行切成两段互不相连的区间,这时候「一行」就不再是一个矩形。多数实现在这里选择保守:只取最宽的那一段,剩下的空着。四、当障碍物自己会动静态版面里,上面这些做一次就够了。真正有意思的是障碍物会动的时候 —— 你把一个球拖过一段文字,文字要在拖动的每一帧里重新断行。这时候「测量一次、求和很多次」的那道分界线就成了成败所在。字素宽度在物件移动时完全没有变,需要重算的只有可用区间和最短路。一秒六十次地做加法是廉价的;一秒六十次地整形文字不是。还有一个容易被忽略的约束:重排必须是稳定的。物件往右挪一个像素,如果整段文字的断法忽然大改,视觉上就是一次闪烁。最短路径这一点上比贪心好得多 —— 它的解随输入连续变化,不会因为某一行差一个像素而级联到段尾。文字本身也不应该被牺牲。绕排常见的失败是把文字变成图片,或者把每个字塞进一个绝对定位的方块 —— 那样它就不再能被选中、被复制、被朗读、被搜索。真正的做法是只决定断行位置,剩下的仍然交给浏览器去画。于是回到最初那三种办法。跳过去最省事,挤到边上最保险,绕着走最麻烦 —— 而它之
04
所以值得,是因为它承认了页面上不只有文字:还有别的东西在那里,占着位置,有它自己的形状。试着抓住一个物件扔出去。它会一路撞开挡在前面的句子,而每一个字都还在原来的地方 —— 只是换了一行。
流动的文字
一、被挡住的那一行
一段文字遇到一样东西,只有三种办法:从它上面跳过去,把它挤到边上,或者绕着它走。前两种容易,第三种一直很难 —— 难到在印刷术的大部分历史里,它都被当作一项要额外收钱的手艺。
中世纪的手抄本里已经有绕排了。彩绘首字母占掉版心左上角一大块,抄写员就把接下来三四行依次缩短,让文字沿着字母的轮廓退让。那时候没有什么算法,只有一双眼睛和一整天时间:抄错一行,整页重来。
值得注意的是,那些首字母并不是「插图」。它们是版面的一部分,文字为它们让路是理所当然的事。绕排最初不是一种效果,而是一种秩序。
真正让绕排变成奢侈品的是金属活字。每一行都是一条铅条,要让第三行比第二行短八个字宽,就得往行末塞进恰好八个字宽的空铅 —— 而空铅只有固定的几种厚度。排一段绕着圆形照片走的文字,是拿一堆矩形去逼近一条曲线,一次一行,全靠手。
二、断行是一道最短路径题
把绕排先放一放,看更基础的那个问题:一段文字,在给定宽度下,该在哪些地方断行?
最朴素的做法是贪心:从左往右塞词,塞不下就换行。打字机和早期的文字处理软件都这么干。它的毛病是只看当下 —— 这一行塞得很满,代价可能是下一行只剩三个词,稀稀拉拉像被拉长的橡皮筋。
更好的想法是把整段一起看。给每一行的松紧程度打一个「难看分」,再给整段的总分求最小值。这样一来,某一行主动少放一个词,若能让后面三行都舒服,就是划算的。
一旦这么定义,它就变成了一道标准的最短路径问题:每个可以断行的位置是一个节点,从一个断点到下一个断点是一条边,边的权重就是那一行的难看分。求整段最优断行,等于在这张图上求一条最短路。
这个转化是关键的一步。它意味着最优解可以用动态规划在线性时间附近算出来,而不需要去试所有断法 —— 后者的数量随段落长度指数爆炸。
难看分怎么定,是审美问题不是算法问题。通常会惩罚过松和过紧的行、惩罚连续多行都在行末断出连字符、惩罚段落最后一行只剩一个词。换一套惩罚,就换一种排版风格。
三、为什么它一直很贵
算法这么清楚,为什么浏览器里的文字绕排至今还是个半成品?答案不在断行,在测量。
要判断一行放不放得下,得先知道这些字有多宽。而字的宽度不是把每个字符的宽度加起来那么简单:西文有字距调整,某些字母组合会被合并成一个连字;阿拉伯文的字形随上下文变化;同一段文字换一个字号,宽度不是等比例缩放。
所以真正的成本是:每问一次「这一串字有多宽」,都要跑一次真正的文字整形。而最短路径要问的次数,是候选断点数量的平方级。
解法是把测量和断行拆开。先把整段文字过一遍,把每一个字素的宽度问清楚,只问一次,缓存下来。之后所有的断行计算都只是在一个数字数组上做加法 —— 从「反复整形文字」变成「反复求和」。
这一拆,代价的量级就变了。原本每一次重排都要重新整形一整段文字,现在只需要重新求和。前者是毫秒级甚至几十毫秒,后者是微秒级。
接下来才轮到绕排。障碍物的作用,是把每一行的可用宽度从一个常数变成一个随行号变化的数组:第一行可以用整幅,第二行左边被占掉六十个像素,第三行两边都被占掉。断行算法本身一个字都不用改,只是每一行去查一次自己那一行能用多宽。
把障碍物投影成「每一行的可用区间」,是整件事里最省力的一步抽象。圆、鱼、塔、云,在断行算法眼里全都一样:一串左右边界。形状的复杂度被挡在了排版逻辑之外。
当然,投影本身要小心。一个凹形障碍物会把某一行切成两段互不相连的区间,这时候「一行」就不再是一个矩形。多数实现在这里选择保守:只取最宽的那一段,剩下的空着。
四、当障碍物自己会动
静态版面里,上面这些做一次就够了。真正有意思的是障碍物会动的时候 —— 你把一个球拖过一段文字,文字要在拖动的每一帧里重新断行。
这时候「测量一次、求和很多次」的那道分界线就成了成败所在。字素宽度在物件移动时完全没有变,需要重算的只有可用区间和最短路。一秒六十次地做加法是廉价的;一秒六十次地整形文字不是。
还有一个容易被忽略的约束:重排必须是稳定的。物件往右挪一个像素,如果整段文字的断法忽然大改,视觉上就是一次闪烁。最短路径这一点上比贪心好得多 —— 它的解随输入连续变化,不会因为某一行差一个像素而级联到段尾。
文字本身也不应该被牺牲。绕排常见的失败是把文字变成图片,或者把每个字塞进一个绝对定位的方块 —— 那样它就不再能被选中、被复制、被朗读、被搜索。真正的做法是只决定断行位置,剩下的仍然交给浏览器去画。
于是回到最初那三种办法。跳过去最省事,挤到边上最保险,绕着走最麻烦 —— 而它之所以值得,是因为它承认了页面上不只有文字:还有别的东西在那里,占着位置,有它自己的形状。
试着抓住一个物件扔出去。它会一路撞开挡在前面的句子,而每一个字都还在原来的地方 —— 只是换了一行。
断行在浏览器里实时算出来,由浏览器绘制,可以选中和复制。
一页会绕排的文字:文字绕着会自己移动的物件实时绕排、逐字测量断行,物件可以拖拽扔出。
做一页文字绕排实验页:一整屏中文短文,文字绕着会自己移动的物件实时绕排、逐字测量断行;物件漂移、碰撞,可被鼠标拖拽扔出;支持两栏/三栏排版切换、暂停/动起来与复位控制、滚轮缩放;衬线字体加大留白,保持阅读流畅。单文件静态页。
做一页文字绕排实验页:一整屏中文短文,文字绕着会自己移动的物件实时绕排、逐字测量断行;物件漂移、碰撞,可被鼠标拖拽扔出;支持两栏/三栏排版切换、暂停/动起来与复位控制、滚轮缩放;衬线字体加大留白,保持阅读流畅。单文件静态页。
做一页文字绕排实验页:一整屏中文短文,文字绕着会自己移动的物件实时绕排、逐字测量断行;物件漂移、碰撞,可被鼠标拖拽扔出;支持两栏/三栏排版切换、暂停/动起来与复位控制、滚轮缩放;衬线字体加大留白,保持阅读流畅。单文件静态页。
操控小野猪移动、二段跳并发射气功波,迎战会发射扇形弹幕与激光的爆炸辣椒,再挑战飞行形态的飞翼辣椒。本案例由 Kimi K3 生成。
用 Space 挥拍、Q 释放月能大招,在月面重力下挑战 AI、无尽月蚀塔、本地双人或在线 1v1。本案例由 Kimi K3 生成。
