实现
前面各章通过模型与接口描述 EKP:一行值多少、哪些断点合法、方案怎样渲染。本章描述这些决定实际是怎样算出来的——从字符串到成品布局的数据流、让重复排版变便宜的缓存,以及每个边界上成立的不变量。本章不定义自己的接口;它解释实现其它章所定义接口的机器。
一次排版调用经过五个阶段。第一,把段落切分成盒子:拉丁词、CJK 字符、贴身于邻居的标点与空白串;盒子获得自己的类型,每个间隙获得胶类别。第二,拉丁词盒可选地按字典断字点切成音节盒。第三,测量并索引盒子:像素宽带去重地测量,胶的上下限由间距参数派生,并建起前缀和数组,使任何候选行的理想、最小与最大宽都能在常数时间读出。第四,断点搜索在这些数组上运行,返回选定的断点与每行的调整量。第五,渲染把调整量分配到每行的间隙上、剥掉行缘空白、装上可选连字符,再把各行连接起来。
前三个阶段在给定段落与布局上下文下只跑一次,其结果缓存为段落记录。第四个阶段对每个(段落、宽度、参数组)跑一次。第五个阶段在第四阶段结果确定后是确定的。这一分工正是改变窗口大小很便宜的原因:换宽度复用记录,只重跑搜索。
示例。 对含 n 个盒子的段落,记录持有三个 n+1 元素的前缀数组,加上逐盒的胶数组;一条横跨盒子 i 到 k 的候选行读取 ideal[k] − ideal[i]、min[k] − min[i] 与 max[k] − max[i],再减去前导胶与行缘空格串。搜索期间不做任何测量。
段落记录以一切能改变段落形态的东西为键:字符、它们的文本属性区间、检测到的拉丁与 CJK 字体、宽度上下文、断字语言、对齐,以及布局策略与间距签名。键按结构比较,因此哈希冲突不会把两个不同段落到同一个别名上,输入相等的两次调用共享同一份记录。
记录持有:盒子向量与逐盒宽度、类型和胶值;断字位置与连字符宽;三个前缀数组;按类别统计的拉伸/压缩间隙前缀计数;每个位置上的行首与行尾空白串宽度;当前生效的胶参数快照;以及动态规划结果与语义方案的结果缓存。间距参数属于记录而不是搜索键,因为间距一经选定就固定了,无论它是按字体派生还是显式设定。
因为键里包含属性区间,编辑段落文本或改变它的脸会让记录失配并重建;因为键里包含字体与宽度上下文,主题或文字缩放变化同样如此。这就是「布局结果绝不来自过期记录」这条规则背后的机制;影响排版的选项进入键也是同一道理。
在常数时间内度量一行
Section titled “在常数时间内度量一行”前缀让搜索无需触碰盒子就能给候选行定价。一行的原始理想宽是理想前缀之差减去前导间隙自身的胶;渲染器会剥掉每行边缘的空白串,所以搜索减去原始宽与两个边缘串之和中的较小者,与渲染器将要做的完全一致。最小宽与最大宽用同样的计算,只是换用胶的上下限。
连字符断点加上连字符宽,它按词末字符的样式测量;悬挂的末字形在行缘释放自身的一部分宽度。这两项修正都在计算行的调整量之前应用,因此搜索与渲染器对「胶必须吸收多少」得到同一个数。正是这种一致让每条两端对齐的行都精确渲染到目标宽:两侧共用一条行缘剥离规则,渲染器从不重新推导搜索没有定价过的宽度。
搜索从左到右松弛位置。每个到达的断点保存:到达它的最少代价、达到该代价的前一个断点、行调整量、间隙计数、适应度等级以及连续连字符行的数量。被禁则、硬性不可断原子或连接字符禁止的候选断点根本不成为候选,搜索继续延长本行跨过它。候选到达已有记录的断点时,只有代价更低才替换,因此表里始终保存目前已知的最佳路径,最终沿前驱回溯就得到选定布局。
候选的代价来自断行原理的公式:行罚分加劣度的平方、连字符罚分的平方、平坦的相邻适应度收费,以及二次的连续连字符收费。第一遍只接受合法断点。段落终点不可达时,第二遍给欠满候选的弹性宽度加一笔有界应急拉伸并沿用同一计分,另有一条可达性规则在拒绝某个溢出候选会让某断点失去全部存活路径时,按紧等级、不追加代价地装入该候选。只有到达终点的那一遍跑完后才抽取断点。
有两项特性改变搜索的形状而不是它的计分。非零的 ekp-looseness 与非 nil 的 ekp-parshape 让行宽依赖行号,于是状态变成(位置、行数):搜索按行数保存最省路径的表,找出最优行数,再选出与请求偏移最接近的行数。这个二维搜索只存在于 Emacs Lisp 引擎,因此这两个选项会绕过原生模块。原生模块加载后,在编译代码里运行同一个一维松弛,并能在工作线程池上一次处理多个段落,其结果按原生模块的契约校验。
渲染把每行的调整量变成实际的胶宽。必须拉伸的行按拉丁、混合、CJK 的固定顺序分配多余的像素,所以词间空白先张开,CJK 字符后才张开;当所有间隙的标称拉伸用尽时,由 CJK 间隙吸收余量,而不是让行溢出。必须压缩的行按同样顺序分配亏空,并在每个类别的压缩上限处停下,胶宽以零为下限。没有胶可供吸收的单盒行改用行尾填充。
段落的最后一行不做两端对齐:它保留理想胶并得到行尾填充,这正是它在右侧参差而测量宽仍覆盖目标的原因。搜索已经用短行规则而不是普通劣度给最后一行计过分,所以渲染器无需再替它判断。
渲染器消费的是语义方案而不是搜索的内部状态:源偏移、每行的盒子区间、胶目标、缩进与连字符决定,没有显示表示。字符串接口用合成间距和断行字符渲染该方案,这些字符的显示属性记录它们替换了什么,缓冲区接口因此可以反变换;缓冲区接口则把显示属性装到现有字符上,交互使用有述。缓存命中发出归调用方所有的副本,因此渲染器可以保留或改动方案而不扰动缓存中的那一份。
EKP 保留三层缓存状态。段落记录最宽:按上文所述为键,持有不依赖宽度与罚分设置的一切。动态规划结果以宽度与每个改变得分的参数为键——松紧度、各项罚分、应急拉伸——因此参数变化选中新结果而不必让段落记录失效。语义方案以布局上下文为键,在方案延长时复用已完成的前缀行。逐字形宽度缓存支撑测量,一条单条目的快路径避免对同一个字符串对象重复结构比较。
缓存有界。段落缓存达到大小上限时整体清空,因为重建很直接;方案缓存保留最近少量条目;宽度缓存有它自己的上限。这一切在结果中不可见,而 ekp-clear-caches 会为键无法表达的情况——改变字形宽度的字体或主题变化,键里的字体名可能分辨不出——把它们全部丢弃。
活动编辑路径
Section titled “活动编辑路径”自动模式为正在编辑的那一行增加了一条更窄的流水线。已提交状态持有该段落最近投影的方案、它的签名与稳定断点锚。第一次真实编辑打开一个事务,并只从包含该编辑的最小投影区间里移除 EKP 的属性;该岛屿内部的后续编辑使用原生换行,不做整段规划。跨过视觉行、段落完成、下一次真实编辑发生在别处、显式 refill,以及宽度或上下文变化,都是提交点;没有空闲快照,也没有 post-command 钩子。提交时计算或复用一个整段方案,推导出已完成的各行,并静默发布变化的后缀。稳定的行签名把写入降到最少,一个小的缓冲区局部方案缓存服务最近的宽度。IME 预编辑、外来 display 所有者、不支持的压缩与过大的硬行都会失败关闭回原生显示,部分投影在错误浮出之前回滚。
流水线保证什么
Section titled “流水线保证什么”任何一次排版调用结束时,有三条性质成立,包的测试直接检验它们。字符流不被触碰:投影只增删文本属性,保存、搜索与复制看到逻辑文本。只要得分是在合法断点上算出的,两端对齐的行就精确渲染到目标宽,例外只有最后一行、悬挂与应急一遍。两个引擎产出相同布局;Emacs Lisp 引擎是参照,原生模块只是运行同一计算的更快方式。