ekp/DEVELOPER_ZH.md
2026-08-02 15:30:12 +08:00

458 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Emacs-KP 开发者文档
本文档描述 `emacs-kp` 的实际内部架构、算法与 API,面向贡献者和高级用户。
当前仓库健康度与后续工作的优先级见
[2026-07-28 系统审计](./Docs/REPOSITORY_AUDIT_20260728.md)。
## 1. 处理管线
一次排版调用经过五个阶段:
```
字符串
① 分词 ekp-split-to-boxes (ekp-utils.el)
│ 拉丁词 / CJK 单字 / 空格串 → 盒子(box);
│ CJK 标点按避头尾规则附着
② 断词 ekp--split-with-hyphen (ekp.el + ekp-hyphen.el)
│ 拉丁词盒子 → 音节盒子(Liang 模式)
③ 测量与索引 ekp--make-para (ekp.el)
│ 像素宽度、glue 类型、前缀和数组
│ → 缓存为 `ekp-para` 结构
④ 断行(DP) ekp--dp-run-1d / C 模块 (ekp.el / ekp_c/)
│ Knuth-Plass 动态规划 → 断点序列
⑤ 渲染 ekp-line-glues, ekp--pixel-justify
分配 glue 像素、剥离行首尾空格盒、附加连字符
→ 以 "\n" 连接的行
```
`ekp-pixel-justify``"\n"` 拆分输入,每个非空段独立走这条管线
(C 模块可用时通过 batch API 并行处理)。
## 2. 数据结构
### `ekp-para`(段落缓存条目)
DP 和渲染需要的一切,每段只算一次:
| 字段 | 内容 |
|:-----|:-----|
| `string`, `latin-font`, `cjk-font` | 原文与检测到的字体 |
| `boxes` | 盒子字符串向量 |
| `boxes-widths` | 每个盒子的像素宽(带去重测量) |
| `boxes-types` | 每盒 `(首类型 . 尾类型)`:`latin`/`cjk`/`cjk-punct`/`space` |
| `glues-types` | 每个盒子*之前*的 glue 类别:`lws`/`mws`/`cws`/`nws` |
| `hyphen-pixel`, `hyphen-positions` | 连字符宽度;可断词盒索引的有序向量 |
| `ideal/min/max-prefixs` | 盒+glue 宽度在理想/最收/最伸状态下的前缀和(n+1 个元素) |
| `glue-ideals/shrinks/stretches` | 按盒索引的前导 glue 值(n 个)——原样传给 C |
| `lws/mws/cws-prefixs` | 各可伸缩 glue 类别的前缀**计数** → 每候选行 O(1) 数间隙 |
| `lead-spaces` | `lead-spaces[i]` = 从盒 i 开始的连续空格盒总宽;下标 0 强制为 0(首行缩进保留) |
| `trail-spaces` | `trail-spaces[k]` = 到盒 k1 结束的连续空格盒总宽 |
| `glue-params` | 创建时九个间距值的 plist 快照 |
| `dp-cache` | 以 `equal` 比较的哈希:完整 DP 签名 → dp-result plist |
段落缓存(`ekp--para-cache`)以 `equal` 比较结构化 key——字符串内容、
文本属性区间的打印形式、检测字体、断词语言(`ekp-latin-lang`)、九个
显式间距值或自动 CJK stretch 默认值。其他自动值由字体测量派生,已
由字体与显示上下文字段表达。结构化 key 使哈希碰撞无害(旧的
`sxhash` 整数方案理论上可能串段)。超过 `ekp-para-cache-limit`
整体清空。单条快路径(`ekp--last-para`)只绕过哈希 lookup,复用前仍
比较同一个完整结构 key。因此排版相关 text property 的原地修改会让
两条路径都 miss,并得到与 fresh paragraph 相同的结果。
DP 签名独立于段落 key,包含行宽、looseness 与全部六个运行时代价参数。
参数变化会选择新结果,无需丢弃与宽度无关的段落数据;结构化 `equal`
比较也让非零 looseness 签名能够正常命中缓存。
### dp-result
`(:rests R :gaps G :breaks B :cost C :line-count N)`。`breaks` 为每行
的排他终点索引;`rests[i]` = 行宽 行理想宽(glue 需要吸收的像素);
`gaps[i]` = `(lws数 mws数 cws数)` 用于 glue 分配(单盒行和末行为 nil)。
## 3. 行度量
候选行覆盖盒子 `[i, k)` 时:
```
raw = prefix[k] prefix[i] 前导glue(i)
space-w = min(raw, lead-spaces[i] + trail-spaces[k])
width = raw space-w (若盒 k1 处断词,再加连字符宽)
```
理想/最小/最大三个值均 O(1) 得出。行边缘的空格盒串被排除,因为渲染层
会剥离它们;DP 与渲染层因此严格一致,每一行的渲染宽度精确等于目标宽
(测试 `ekp-test-justify-line-width-invariant`)。
`ekp--line-stripped-space-pixel` 统一拥有 1D/loose DP、C 结果重建与
渲染层使用的这一排除规则。
## 4. Knuth-Plass 动态规划
`ekp--dp-run-1d` 从左到右松弛位置。对每个可达起点 `i` 扫描终点 `k`,
直到行的最小宽度超过目标。断点合法条件:`min ≤ 目标 ≤ max`,或末行
`ideal ≤ 目标`
**Demerits**(每行,与 `ekp_c/ekp_kp.c` 完全一致):
```
demerits = (line-penalty + badness)²
+ penalty² ; 断词处为 hyphen-penalty
+ adjacent-fitness-penalty ; 当 |fitness 前行fitness| > 1
+ consecutive-hyphen-penalty × 连续次数²
badness = min(10000, 100·|adjustment/flexibility|³)
```
松紧等级(tight/decent/loose/very-loose)沿用 TeX 的比例阈值。特殊情
况:严格遍的单盒行 flexibility 固定为 1;最终遍使用与普通欠宽行相同
的有限 emergency stretch。末行代价为
`(line-penalty + 短行badness)²`,填充率低于 `ekp-last-line-min-ratio`
`短行badness = last-line-short-penalty × (1 填充率)`
与 1981 论文的差异(有意为之):penalty 一律以 `+p²` 计入(无负
penalty/flagged 断点),主流程无 `q`/looseness(见 §6),相邻松紧惩
罚为平坦常数。
### 两遍紧急策略
某些输入不存在合法排版:比行宽更宽的不可断盒子,或无法伸展到目标宽
的刚性(全 `nws`)区段。先跑严格遍;若段尾不可达,第二遍额外允许
为普通欠宽候选加入有限 background emergency stretch,并继续走同一套
adjustment ratio、badness、fitness 和 demerits。这样最终遍的欠宽选择
仍由全局 K-P DP 比较,不会被塞进另一条固定代价路径。
另外,最终遍实现 TeX 的 `artificial_demerits` 可达性保护。如果某个
超宽候选将使一个断点的最后活动路径消失,且该断点没有任何未超宽候选
存活,就以 tight fitness 和零增量 demerits 安装最佳暂存路径。这不是
hard atom 专用的评分捷径:只要正常活动路径还在,它就不参与竞争,也不
检查中文、单位或 token 类别。因此任何输入仍会得到覆盖完整源文本的
plan(回归:窄栏 CJK 曾整段返回空结果),而普通欠宽行仍按正常 K-P 代价
竞争。Elisp 1D、looseness/parshape 与 C 引擎实现相同语义。
## 5. 渲染
`ekp-line-glues` 把每行的 `rest` 转成各 glue 的像素值:
- rest > 0 → 拉伸,按 拉丁 → 中西 → CJK 优先级分配;CJK 间隙可吸收超
出名义容量的剩余(紧急摊布)。
- rest < 0 收缩,同样的优先级,不低于各类收缩下限;glue 宽度钳制
0
- 末行右侧不齐(理想 glue + 尾部填充);单盒行的尾部填充钳制 0
`ekp-layout-plan` 是布局决策与渲染方式之间的表示边界它把段落盒子
及其原文 offsetDP 断点逐行 glue 目标缩进边缘剥离和可选断词
决策组合成 `ekp-layout-plan`、`ekp-layout-line` `ekp-layout-gap`
记录plan 不包含 buffer 位置或具体显示机制
`ekp-render-layout-string` 用它服务现有字符串 API;buffer 集成可复用
完全相同的决策,无需重新运行或重新解释 KP 算法
缓存的 semantic plan 对缓存 owner 不可变缓存命中会返回 consumer
自有副本,覆盖所有 plan 自有的可变 payload:源字符串context
向量与盒字符串 offsetline 记录line gluegap 记录和 line
signature。`ekp-layout-plan-para` 是有意例外:段落缓存所有权早于
semantic-plan cache,append planning 依赖稳定的段落身份
layout context snapshot 与返回 plan context 使用同一个递归 copier,
覆盖 consvector string,因此可变 policy 输入不会别名到 cache key
或后续 consumer plan
两个消费方拥有不同且明确的表示权限
#### 字符串渲染器
`ekp-render-layout-string` 保持公开字符串 API 兼容它剥离首尾空格
合成 display 空格与视觉换行,并在选中断词点附加继承样式的连字符
返回字符串通过四类私有标记保持无损:
| 属性 | 位置 | / 含义 |
|-------------------|------------------|---------------------------|
| `ekp-glue` | 合成的 glue 空格 | 它所替换的原文 |
| `ekp-soft-break` | 合成的 `\n` | 断点处吞掉的边界空白 |
| `ekp-soft-hyphen` | 合成的连字符 | 仅作标记 |
| `ekp-hidden` | 段落边缘文本 | 保留源文本显示为空 |
这份物理表示只存在于返回字符串中,用于兼容既有 API,绝不会安装到源
buffer。`ekp--layout-marker-properties` 统一拥有其标记词汇表与不继承
契约
#### Buffer 渲染器
`ekp-buffer.el` 保持 buffer 字符序列不变,只在现有源字符上用文本属性
投影同一份 plan:
- ASCII 空格:
`((space-width FACTOR) (min-width ((TARGET-PIXELS))))`;
- 无源空格的 CJK/混排 glue:把 `min-width` 加在前一个完整字素上,
目标为其自然 advance glue;
- 缩进:`line-prefix`;
- 源空白断点:第一个边界字符显示成换行,其余显示为空;
- CJK 或拉丁断词断点:replacing display string 重现已有完整字素,
接上可选连字符和视觉换行
`ekp-buffer--display` `ekp-buffer--line-prefix` 记录精确 owner
移除时只有公开属性的值仍与 EKP owner 值相同时才清除,因此后来的外部
修改不会被误删带外部 `display`、`line-prefix`、`wrap-prefix`、
`composition` `invisible` owner 的段落保持 verbatim若精确排版
要求缩窄 tab/ ASCII 空白也会拒绝,因为 `space-width` 只影响 ASCII
空格
所有安装/移除都在 `with-silent-modifications` 内完成,自有属性设为
nonsticky因此 buffer 字符point/markmodified 状态undo字符
修改 tick 与外部 change hook 仍完全由源编辑拥有任何 EKP buffer
路径都不会创建 overlay重投影直接恢复 mark marker,不会调用会激活
选区的 `set-mark`,随后独立恢复 `mark-active`;因此布局不能把旧 mark
变成选区
保存普通搜索语法和直接 Elisp 字符 API 不再需要逻辑文本 adapter:
真实 buffer 本来就是逻辑文本复制过滤仍有必要,因为
`buffer-substring` 按设计保留文本属性EKP 先组合原有
`filter-buffer-substring-function`,再只移除自己在复制字符串中的投影
元数据
#### 实时流式排版
实时编辑直接消费正常的整段 Knuth-Plass plan,不会把编辑状态交给
核心规划器:
1. live state 按最窄窗口权威宽度持有最后一次提交的硬行源文本正常
`ekp-layout-plan`语义签名投影 span 与稳定断行锚点
2. 第一次真实变更打开一个编辑事务事务先快照提交态,再只移除包含该
编辑的最小投影 span 范围中的 EKP 属性;span 对象与无关锚点仍保持
注册
3. 脏岛内的后续变更交给原生软折行,不规划整条硬行若逻辑源文本精确
回到快照,EKP 直接恢复保存的自有属性区间与 marker 偏移;stateplan
signatures spans 都保持对象身份
4. 跨过原生视觉行边界才提交EKP 计算或复用一次整条硬行 plan,得到
全部已完成语义行,并在同一个 command-loop 转换中静默发布真正变化的
后缀;新的当前行保持自然
5. 其他提交事件只有硬换行/段落完成下一次真实编辑发生在脏岛之外
显式 refill,以及宽度/字体/face/主题或布局上下文变化point 移动永远
不是提交,即使跨越硬段落也一样;live 路径没有 `post-command-hook`
6. 稳定行签名只负责在提交时减少属性写入,buffer-local 16 LRU 只负责
复用近期文本/上下文 plan何时允许布局变化由事务控制,不是二者控制
7. IME preedit外部显示所有权不支持的收缩超长硬行过期
generation 或发布错误都会 fail closed 到原生显示半成品投影会
回滚,原始错误继续向外报告
原生软折行是状态机的前提,不能只希望用户碰巧开启它mode 启用时先
保存 `truncate-lines` `truncate-partial-width-windows` 的值与
buffer-local 所有权,再把二者设为 buffer-local `nil`;teardown 时恢复
原局部值,或移除临时局部绑定让全局值重新接管这样 Emacs 默认的
50 列分栏阈值就不会悄悄把窄分栏变成横向滚动
这个状态模型不需要 live lookaheadpush/pull 收敛逐键整行规划器或
idle formatterpoint 移动本身严格零副作用;之后若在别处发生真实编辑,
则允许提交先前活动硬行,即使 narrowing 让该行已不在可访问区内
不存在编辑空闲后整段 formatterresize/后台工作都带 generation
buffer 按可见优先的硬段落 chunk 处理;单个硬段落超过
`ekp-auto-justify-paragraph-limit` ,自动路径保持自然显示,只有显式
`ekp-refill-paragraph` 才执行无界完整质量 pass文本属性属于 buffer,
所以以最窄活动窗口作为唯一权威宽度
### 5.1 断行许可、对齐、悬挂、段形
- **断行许可**:每个 CJK 字符(含标点)独立成盒;
`ekp-para-breaks-allowed` 按禁则(全角与半角)、`ekp-no-break`
区间及 NBSP 族连接符禁止相应间隙,被禁间隙不携带 glueDP 跳过
被禁候选但继续延伸行最终遍中的合法欠宽候选获得有限 emergency
stretch 并按正常 badness/demerits 评分;若超宽候选将消灭最后活动
路径,TeX 风格 artificial demerits 以零增量代价保留该路径C 侧接收
稀疏 `forbidden-positions` 向量显式 hard atom 只禁止其区间内部
断行;atom 前后本来合法的边界仍然合法atom 邻接不获得特殊评分,
也不会额外禁止断点
- **可配置 policy 编译**:buffer core policy 变量在 tokenization
之前解析为私有结构区间区域 `ekp-break-policy` 优先,显式
buffer/file/dir local 值优先于 mode profile,profile 优先于全局默认
只有 token policy 是按类别合并的 map;标量与 face 列表都直接替换
低优先级值core 可以在 analysis copy 上使用
`ekp--face-break-policy`、`ekp--no-hyphen`、`ekp--literal-spacing`
等私有属性, cache keyplan stringboxrendered string
`ekp--last-para` 只能保留公开源属性自动 face no-break 按连续的
私有 face-policy 区间整体测宽,超宽时降级为 no-hyphen;显式
`ekp-no-break` 永不降级
- **行内字面空格**:face 派生的 `no-hyphen` 在行内保留源空格为字面
box,禁止会把源空格 box 移到行首的断点,并允许互补的"空格后到内容"
断点当这些空白成为选中的视觉断点时,源归属属于 break gap metadata,
而不是尾随可见行 box该规则来自 policy,不能依赖源上是否另有公开
`face` 属性
- **对齐**(`ekp-alignment`):非两端对齐把 glue 伸缩数组与类参数
置零,DP `max_w` 加每行额外伸展 R(`ekp-c-set-penalties` 7
),badness = 100·(欠宽/R)³;渲染层按模式分派剩余(尾部/对半/
头部)。
- **悬挂**(`ekp-protrusion`):逐间隙 `tail-protrudes[k]`(穿透尾
随空格盒取最后内容盒) `hyphen-protrude` 标量, DP
`ekp-line-glues`C 结果重建三处同步放宽每个候选的有效目标宽
(`lw = width + release`)——三处必须保持一致
- **每行宽度**(`ekp-parshape` / `ekp-first-line-indent`):由
`ekp--line-spec`(行号 缩进 . 宽度)解析纯首行缩进只改第 0 ,
"以盒 0 开头的行"恰好对应 DP 起点 i = 0,故 1D (以及 C 引擎,
`FIRST-LINE-WIDTH` 参数)无需额外状态即可处理;只有完整的
`ekp-parshape` `ekp-looseness` 才需要(位置×行数)DP 并旁路 C
缩进渲染为行首 `ekp-glue` 垫片
C 模块 1.6:`ekp-c-break-with-arrays` 15 (…、forbidden-positions
tail-protrudeshyphen-protrudefirst-line-width);batch 向量 15 ;
`ekp-c-set-penalties` 48 policy 编译只进入现有断词位置与禁断
向量;没有新的架构决策时,不得增加第 16 C 参数或 batch 字段
特性完成后的性能(字节编译 + C,Apple Silicon,batch):justify zh
w=200 54 msrange zh 117 ms——justify 与特性前持平,range 因盒
数增加约 +55%。热路径缓存:`ekp--str-type` 按字符记忆化glue 字符
串驻留、(段落, 宽度) 渲染结果缓存进 dp-cache(上限 64 个宽度)。
续变宽实测(60 2.6 万字文章, region 层全链路):每次变宽约
73 ms,重访宽度更快;编辑后单段增量重排约 17 ms
`ekp-bench-adversarial-builders` 单独测量源码解释模式下的退化
builder输入从 1,000 增到 8,000 字符时,基于片段的 tokenizer 增长
6.3×,密集插入增长 7.7×,接近输入 8× 的线性增长8,000 字符分别从
3.133 s 降到 1.100 s 0.945 s 降到 0.013 s断词位置缓存使用
显式 miss sentinel,因此合法的 nil 结果也能复用
## 6. Looseness
`ekp-looseness` 0 时切换到 `ekp--dp-run-loose`:完整的
(位置 × 行数)DP,为每个行数保留最优路径,最终选取与
(最优行数 + looseness)最接近的行数,平局取 demerits 更小者该路径
1D ,仅有 Elisp 实现;looseness 激活期间 `ekp--c-available-p`
返回 nil,两引擎永不分歧
## 7. C 模块集成
C 模块(`ekp_c/`,版本 1.6)只执行阶段 ④。所有字体相关数据以 Elisp
为唯一事实来源
- `ekp-c-break-with-arrays`(15 参数):para 的前缀数组glue 数组
断词数据行宽两个空格串数组禁则/悬挂数组和首行宽度返回
`(breaks . cost)`
- `ekp-c-break-batch`:15 元素向量的向量, pthread 线程池并行处理
——每段一个任务(这是正确的并行粒度;DP 本身天然串行)。线程池在
首次多段落 batch 时惰性创建,按机器核心数定大小;队列满时提交方
阻塞等待而非丢弃任务
- `ekp-c-set-penalties`(48 参数):`ekp--c-sync-params` **每次**
进入 C 之前调用,保证 `ekp-line-penalty` 等变量始终生效(回归:此
前从未同步)。
- `ekp-c-module-load` 拒绝低于 `ekp-c-module-required-version` 的模块
并回落到 Elisp,避免升级后的参数数量不匹配
模块不可用ABI 版本不兼容整批 C 结果为 nil 或单项/断点结果为
nil 时回落到 Elisp直接 API 的非法输入 signal
`ekp-c-invalid-input`任何非 nil 的畸形后端结果 signal
`ekp-backend-contract-error`:结果 cons 形状错误breaks 不是 list
break 非整数/越界/非递增/未覆盖段尾cost 非数字, batch 结果形状
错误已启用后端发出的任何 signal 都会穿过公共 formatter,dispatcher
不捕获或隐藏模块不会在部分失败时静默产出不同的排版两引擎输出
逐字节一致,
`ekp-test-c-parity-simple` / `ekp-test-c-parity-files` 300 例性质
fuzz 验证
### 未来方向:段落句柄 API
每次 `ekp-c-break-with-arrays` 调用都会重新编组段落的宽度无关数组
( 18·n `env` 提取)。单次 justify 时这无关紧要,
`ekp-pixel-range-justify` 会对每个候选宽度重新编组同一批数组:即便
段落缓存已暖,C 路径每个宽度仍约 12 ms,其中大部分是编组而非 DP
(整篇样本的 DP 2.5 ms)。
解法是 `make_user_ptr` 句柄:`ekp-c-para-upload` 把数组一次性拷进 C
结构体并返回带 GC finalizer 的句柄,`ekp-c-break (handle, width)` 之后
只传两个宽度相关标量**有意**不纳入本次发布——这是引入 C 端对象
生命周期的破坏性(2.0)ABI 变更,而常见交互路径(单次 justify
`ekp-auto-justify-mode`)本就命中 dp-cache避开了重复编组当宽度
搜索或超大批处理成为瓶颈时,这是明确的下一步
## 8. 断词(ekp-hyphen.el)
普通 Liang 模式算法:
- `dictionaries/hyph_*.dic` 首次使用时编译为模式哈希并按路径缓存
文件可为 UTF-8 ISO-8859(Emacs 自动检测;
`ekp-test-hyphen-de-iso8859-dict` 验证)。
- `ekp-hyphen-create LANG` 先精确匹配,再逐级缩短(`"de_CH" "de"`)。
- 断点两侧默认至少保留 2 个字符
- 非注释 pattern 出现斜杠时失败关闭libhyphen 替换规则只在断点
胜出时改变可见文字与宽度,当前固定宽度 box 无法诚实表达;编译器
计数后 signal `ekp-hyphen-unsupported-pattern`,公共排版入口保留该
错误
- `dictionaries/MANIFEST.tsv` 49 个条目固定到 LibreOffice 提交
(另明确标记一个 legacy Basque 字节),记录 SHA-256语法标记和许可
证据。`tests/check-dictionaries.sh` 做离线门禁,
`dictionaries/update.sh check` macOS/Linux 对照固定上游字节
词盒按 `^[左标点]* (拉丁词) [右标点]*$` 匹配,因此被标点包裹的词
(`(word)`、`word!`、`»word«`)仍可断词;标点粘在首/末音节盒上
## 9. 测试与基准
```bash
tests/run-tests.sh [emacs] # batch 可跑的 ERT 测试集
tests/run-tests.sh [emacs] --random-order
tests/run-tests-isolated.sh [emacs] # 每个 ERT 使用全新进程
tests/check-dictionaries.sh # 离线清单/校验值门禁
dictionaries/update.sh check # 核对固定上游字节
make -C ekp_c PROFILE=portable # 默认可移植发布构建
make -C ekp_c PROFILE=native # 仅本机基准
make -C ekp_c PROFILE=debug # 调试符号,不优化
make -C ekp_c PROFILE=sanitize # ASan + UBSan
emacs -Q --batch -L . --eval '(setq ekp-use-c-module nil)' -l tests/ekp-bench.el
emacs -Q --batch -L . --eval '(progn (require (quote ekp)) (ekp-c-module-load))' \
-l tests/ekp-bench.el
```
可用 `EKP_TEST_SEED` 复现或改变乱序测试 fixture 会动态恢复其隔离的
全部 EKP 配置分派类测试必须经过公开排版入口不能只断言内部资格
谓词
GUI 矩阵需显式加载 `tests/ekp-gui-verify.el`任一行失败时它先打印
完整表格再以状态码 1 退出ERT 套件包含该边界的强制失败负控
`M-x ekp-c-module-build` 使用同一组四种 profile并在 `ekp_c/` 中以
argv 直接启动 make不再构造 shell `cd` 命令发布/CI 使用
`portable``native` 仅用于将在同一机器运行的基准
核心被测不变式:渲染行宽 == 目标宽(像素级对齐)、任意宽度下不丢内
O(1) 前缀机制与暴力算法交叉验证内置文本上的 Elisp/C 一致性
参数持久化/同步回归
基准结果(batch Emacs 30.2Apple Silicon、`tests/text-zh.txt`
3.6KB 中文及各示例;3 次冷缓存取最小值)——"改造前"为重写前的实现
(解释执行):
| 场景 | 改造前 (Elisp) | 改造后 (Elisp 解释) | 改造后 (Elisp 编译) | 改造后 (C) |
|:-----------------------|---------------:|--------------------:|--------------------:|-----------:|
| justify 中文 w=200 | 7547 ms | 1780 ms | 96 ms | 57 ms |
| justify 中文 w=400 | 2928 ms | 815 ms | 71 ms | 57 ms |
| justify 混排 w=300 | 5540 ms | 1275 ms | 53 ms | 23 ms |
| range 中文 340380 | 29696 ms | 8937 ms | 294 ms | 75 ms |
| range 混排 280320 | 68534 ms | 14552 ms | 480 ms | 34 ms |
| DP,中文 w=400 | 2382 ms | 591 ms | 15 ms | 1.3 ms |
("改造后 (C)" 列在字节编译的 Elisp 环境下测得作为参照,重写前的
C 模块在 justify-中文-200 / range-中文 / 仅-DP 上分别为 197 ms /
430 ms / 25 ms——重写通过 para 级预建 glue 数组para 缓存的 `eq'
快路径和 O(1) 重建 rest/gap,把 C 路径也提速了 319 倍。)
主要收益来源:前缀数组带来的 O(1) 行度量(旧内层每候选分配 O(n) 子
序列,总计 O(n³))、两遍紧急策略(保持 DP 稀疏)、盒宽测量去重。
## 10. 文件地图
```
ekp.el 核心:para 结构缓存DP(1D + looseness)、
glue 分配渲染公共 API
ekp-utils.el 分词器(盒子避头尾)、 batch/tty 回退的字体
检测C 模块加载
ekp-hyphen.el Liang 断词 + 词典注册
ekp-buffer.el 纯文本属性 buffer/region 投影同步实时流动窗口
lifecycle复制过滤与诊断
ekp_c/ C 动态模块( ekp_c/README.md)
dictionaries/ Hunspell 断词模式(来自 LibreOffice)
tests/ ekp-tests.elekp-buffer-tests.el(ERT)、
ekp-fuzz.el(一致性 fuzz)、ekp-bench.el
ekp-demo.elekp-showcase.el示例文本run-tests.sh
```