〖共创稿事节〗HarmonyOS 7 新特性实战(57):对照阅读逐句对齐——413 个句对的译文锚点切点法与三道门禁
# 〖共创稿事节〗HarmonyOS 7 新特性实战(57):对照阅读逐句对齐——413 个句对的译文锚点切点法与三道门禁
「山海万灵」的篇章阅读里,《轩辕本纪》的对照模式原先是这样工作的:segments[i](一整段原文,含行内夹注)配 translations[i](一整段白话),一屏里上面一整段繁体、下面一整段白话。试用之后的反馈只有一句话:「就是感觉译文和原文无法一句一句的对应上,读起来很费劲」。
这不是渲染 bug,是数据里根本没有「句」这一层——粒度是「段」,读者要在一段 459 字的繁体里自己找「这句对应译文哪一句」。更糟的是超长段拆页的时候:分页器 planSlice 的对照分支里,原文切点与译文切点是两条独立的链,只共享一个行数比例。两条链一错就错开一整句。真机取证过一次:第 4/108 页,原文还停在「得祝融,辨乎南方」,译文已经跑到「得到火封,让他辨察西方——酉时是一半,鸡在黎明报晓时…」——译文超前一整块,而且没有任何报错。
改造分两期做完:先做一节样板验版式,确认可读后铺开其余 37 段。最终全篇 15 节 38 段、413 个句对,对照模式里一句原文下面紧接它自己的译文。

一、铺开前后的盘子
| 项 | 样板期(v4) | 铺开后(v5) |
|---|---|---|
| 带对齐的段 | 1 / 38 | 38 / 38 |
| 对齐块总数 | 13 | 413 |
| 带对齐的小节 | 1 / 15 | 15 / 15 |
样板段选的是「黄帝之生」:1 段,原文 459 字、译文 594 字,切成 13 块。这一段同时压上了后面会讲到的所有难点——长夹注、引号、译文并句,拿它验版式等于把最坏情况先过了一遍。版式确认后,剩下 37 段走同一套规则,版号从 2026.10.12-v4-xuanyuan-benji-sentence-align 升到 2026.10.13-v5-xuanyuan-benji-sentence-align-all。
二、为什么不能自动切
动手之前先排除了「让程序自己对齐」这条路,两个实测数字:
- 原文的「句」含行内夹注(
⟦…⟧),而译文把夹注并进了正文句流。两边句数根本不同:全篇 38 段里只有 7 段(18.4%)原文句数与译文句数相等;只数正文句(不算夹注)更差,13.2%。 - 按累计字数比例切译文,第 2 句就断在词中间(切出「公侯伯子男五等诸」),第 9 句切出空串。
句级对齐只能在内容层人工做。工具能帮上的不是「替人切」,而是把「人工切」变成「人工选切点 + 机器验算」:人只负责在译文里挑锚点,剩下的闭合检查、边界检查全部交给脚本,错了当场红。
三、切法:原文自动切,译文用锚点切
两条链的分工从一开始就不对称:
- 原文的语义单元可以自动切,只有两条切点规则:正文句末标点(。!?)之后;一条夹注刚结束处。
- 译文的切点由锚点驱动:锚点是译文里的一段文字,切在它结尾。锚点数必须等于原文单元数 − 1。
工具是 .workbuddy-ai/tmp/align-work.mjs,两个子命令:dump <起> [止] 把指定段的原文单元与译文句都打出来,人对照着选锚点;apply <锚点文件> 逐段验算并打印规范源要写的字面量。原文自动切的核心实现:
// 原文语义单元:两条切点 —— 正文句末标点(。!?)之后、夹注刚结束处。
const CLOSERS = '”』」)】》';
function noteMask(raw) {
const marks = [];
let cursor = 0;
while (cursor < raw.length) {
const open = raw.indexOf('⟦', cursor);
if (open < 0) {
for (let i = cursor; i < raw.length; i++) marks.push(false);
break;
}
const close = raw.indexOf('⟧', open + 1);
for (let i = cursor; i < open; i++) marks.push(false);
for (let i = open + 1; i < close; i++) marks.push(true);
cursor = close + 1;
}
return marks;
}
function sourceUnits(raw) {
const marks = noteMask(raw);
const flat = raw.replace(/[⟦⟧]/g, '');
const cuts = [0];
const push = (value) => {
if (value > 0 && value < flat.length && cuts[cuts.length - 1] !== value) cuts.push(value);
};
// 句末标点之后若紧跟闭引号(…丹穴之山。」有臣沮頌…),闭引号并进本单元,
// 否则下一个单元会以「”」开头。
const skipClosers = (from) => {
let cut = from;
while (cut < flat.length && '。!?'.includes(flat[cut])) cut++;
while (cut < flat.length && CLOSERS.includes(flat[cut])) cut++;
return cut;
};
for (let i = 0; i < flat.length; i++) {
if (marks[i]) {
const next = i + 1;
const noteEnds = next >= flat.length || !marks[next];
// 夹注若紧跟在句末标点之前(…始養蠶為絲⟦…⟧。乃有天老…),夹注结束后
// 那个句号必须并进本单元 —— 否则会切出一个只有一个「。」的单元。
if (noteEnds && next < flat.length) push(skipClosers(next));
} else if ('。!?'.includes(flat[i])) {
push(skipClosers(i + 1));
}
}
const units = [];
for (let k = 0; k < cuts.length; k++) {
const from = cuts[k];
const to = k + 1 < cuts.length ? cuts[k + 1] : flat.length;
if (to > from) units.push({ from, to, text: flat.slice(from, to) });
}
return { flat, units };
}
apply 那一侧,锚点按顺序在译文里查找,落成切点序列,再做合并与三重验算:
const CONTINUE = ',、;:';
// 合并:下一单元以句读续接开头 ⇒ 并入前一块(原文与译文同步合并);
// 某单元分不到译文(译文把两句并译了)⇒ 也并入前一块,否则切出 [n, 0] 的空块。
function mergeUnits(units, tCuts, trans) {
const pairs = units.map((u, i) => ({
from: u.from, to: u.to,
tFrom: i === 0 ? 0 : tCuts[i - 1], tTo: tCuts[i],
tText: trans.slice(i === 0 ? 0 : tCuts[i - 1], tCuts[i])
}));
const merged = [];
for (const u of pairs) {
const prev = merged[merged.length - 1];
const shouldMerge = prev !== undefined &&
(CONTINUE.includes(u.text.charAt(0)) || u.tText.length === 0);
if (shouldMerge) {
prev.to = u.to; prev.tTo = u.tTo;
prev.text += u.text; prev.tText += u.tText;
continue;
}
merged.push({ ...u });
}
return merged;
}
// apply:锚点数必须 = 原文单元数 − 1;验「原文 N/N、译文 M/M、块首非续接」。
const { flat, units } = sourceUnits(seg.raw);
if (list.length !== units.length - 1) {
console.error(`段 ${key}:锚点 ${list.length} 个,但需要 ${units.length - 1} 个`);
process.exit(1);
}
const tCuts = [];
let searchFrom = 0;
list.forEach((anchor) => {
const at = trans.indexOf(anchor, searchFrom);
if (at < 0) { console.error(`段 ${key} 锚点找不到: ${anchor}`); process.exit(1); }
searchFrom = at + anchor.length;
tCuts.push(searchFrom);
});
tCuts.push(trans.length);
const merged = mergeUnits(units, tCuts, trans);
const srcSum = merged.reduce((a, m) => a + m.to - m.from, 0);
const tSum = merged.reduce((a, m) => a + m.tTo - m.tFrom, 0);
const clean = merged.slice(1).every((m) => !CONTINUE.includes(m.text.charAt(0)));
锚点数 = 单元数 − 1 是整套流程的骨架:它把「这一段切成几块」从人的自由裁量变成了硬约束——切法一旦变了,锚点数立刻对不上,apply 直接拒绝执行。37 段的锚点全部三验通过。合并则很罕见:全篇只有 3 段发生(#3 从 33 块并成 32、#12 从 17 并成 15、#18 从 8 并成 7)——电子底本在句末标点上基本干净,人工判断主要花在译文怎么切上。

四、工具里修掉的三个切点缺陷
样板段的 13 块一次通过,铺开时才暴露出三类边界情况。每一个都真实发生过:
| 缺陷 | 实例 | 不修的后果 | 修法 |
|---|---|---|---|
| 夹注紧贴句末标点之前 | …始養蠶為絲⟦今《禮記》…⟧。乃有天老… | 夹注结束后单独切出一个只有一个「。」的单元 | 夹注结束后把后面的 。!? 并进本单元 |
| 句末标点后紧跟闭引号 | …又出丹穴之山。」有臣沮頌… | 下一单元以「”」开头,原文与译文都可能出现 | 闭引号并进本单元(CLOSERS 表),两条切分链都要改 |
| 译文把两句并译 | 段 21 …與華戎之異。文出《封禪記》。 | 切出 [8, 0] 这种「有原文没译文」的块 | 并入前一块,段 21 从 5 块并成 4 块 |
第三类最隐蔽:原文「與華戎之異」和「文出《封禪記》」是两个语义单元,译文却合成了一句。切点若照旧打在两句之间,第二块就分不到译文——生成器与门禁都会判不合法(translationChars > 0),但更早一步,mergeUnits 里的 u.tText.length === 0 已经把它并进了前一块。三个缺陷修完后,剩余段落再没有出过新的边界情况。

五、落进 App:数据是「字数对」,不是切片文本
规范源 content/catalog/source-chapters.mjs 里,对齐数据只存字数,不存切片文本:
// 逐句对齐:13 个块,每块 [原文块字数, 该块译文字数]。
// 两块各自逐字闭合(459 / 594)由生成器与门禁断言。
alignments: Object.freeze([
Object.freeze([[6, 8], [80, 102], [46, 64], [23, 32], [30, 43], [5, 14],
[21, 40], [31, 41], [20, 28], [13, 13], [66, 44], [44, 59], [74, 106]])
])
存字数而不是文本,是因为底本只有一份:原文与译文的全文已经在 segments / translations 里,切片文本再抄一份就是两处要同步的地方,必然漂移。字数对只是「怎么切」的声明,分页器拿到后照数字切片——原文从扁平字符序列切(保住每个字的夹注标记),译文按字切:
// 对照模式且该段带对齐数据时才拆块;原文 / 译文模式不拆,仍按整段排。
function sectionBlocks(section: ShanhaiChapterSection, mode: string,
sectionStart: number): ShanhaiBlock[] {
const blocks: ShanhaiBlock[] = [];
section.segments.forEach((runs: ShanhaiChapterRun[], index: number) => {
const translation: string =
index < section.translations.length ? section.translations[index] : '';
const flat: ShanhaiFlatChar[] = flattenRuns(runs);
let units: ShanhaiChapterAlignment[] | undefined = undefined;
if (mode === SHANHAI_CHAPTER_MODE_BOTH && section.alignments !== undefined &&
index < section.alignments.length && section.alignments[index].length > 0) {
units = section.alignments[index];
}
if (units === undefined) {
blocks.push({ segmentIndex: sectionStart + index, firstOfSegment: true,
flat: flat, translation: translation });
return;
}
let sourceAt: number = 0;
let translationAt: number = 0;
units.forEach((unit: ShanhaiChapterAlignment, unitIndex: number) => {
blocks.push({
segmentIndex: sectionStart + index,
firstOfSegment: unitIndex === 0,
flat: flat.slice(sourceAt, sourceAt + unit.sourceChars),
translation: translation.slice(translationAt, translationAt + unit.translationChars)
});
sourceAt += unit.sourceChars;
translationAt += unit.translationChars;
});
});
return blocks;
}
阅读页一行没改。对照模式的渲染单位 Paragraph 本来就是「原文块 + 紧随译文块」,分页器以前喂给它的是整段,现在喂的是句对,视图层无感知。原文 / 译文模式不拆块——那两种模式要的是通读,拆块会把一段割成一地碎块。
字数的可信度由电子底本保证:篇章底本逐字对参照文本(如中国哲学书电子化计划的《山海经》),门禁单独断言 sha256 未变。对齐数据动的是「怎么切」,不是「切什么」,两件事分开校验,谁也不掩护谁。
六、门禁:把「一句对一句」钉成 413
对齐数据是纯手工劳动,最怕的就是「改一次底本、对齐全松」。所以生成器断一遍,门禁再断一遍,两边口径一致:
// verify-source-chapters.mjs 的对齐断言核心。
// 「按句序号自动配对」已实测证伪(原文的句含夹注、译文把夹注并进正文句流),
// 这条不测、也不许回退。
for (const section of chapter.sections) {
const alignments = section.alignments ?? [];
assert.equal(alignments.length, section.segments.length,
`${section.sectionId} 的对齐数据必须与段落一一对应`);
alignments.forEach((units, index) => {
if (units.length === 0) return;
const flat = stripNotes(section.segments[index]);
assert.ok(units.length >= 2, `${section.sectionId} 第 ${index} 段只切了一块,等于没对齐`);
let sourceAt = 0;
let translationAt = 0;
units.forEach((unit, block) => {
assert.ok(Number.isInteger(unit[0]) && unit[0] > 0, '原文块字数不合法');
assert.ok(Number.isInteger(unit[1]) && unit[1] > 0, '译文字数不合法');
if (block > 0) {
const previous = flat.charAt(sourceAt - 1);
const first = flat.charAt(sourceAt);
assert.ok('。!?'.includes(previous) || !',、;:'.includes(first),
`第 ${block} 块切在句中(读者会看到一行以「${first}」开头)`);
}
sourceAt += unit[0];
translationAt += unit[1];
});
// 两块各自逐字闭合 —— 分页器只会照数字 slice,切错就是丢字/重字,它自己发现不了。
assert.equal(sourceAt, flat.length, '对齐没有覆盖全部原文字');
assert.equal(translationAt, translation.length, '对齐没有覆盖全部译文字');
});
}
assert.equal(alignedParagraphs, 38, '应有 38 段逐句对齐');
assert.equal(alignedBlocks, 413, '逐句对齐共 413 块');

| 门禁断言 | 钉死的值 | 为什么 |
|---|---|---|
alignedSections | 15 | 不许回退成只剩样板一节 |
alignedParagraphs | 38 | 对齐数据必须与段落一一对应 |
alignedBlocks | 413 | 「一句对一句」的总量硬指标,少一块都是丢对齐 |
| 两块各自逐字闭合 | 原文 12,124 字对参照文本逐字 | 分页器只照数字 slice,切错就是丢字/重字 |
| 边界不落在句中 | 块首不以「,、;:」开头 | 否则读者会看到一行以逗号开头的原文 |
| 对照模式排出的块数 | ≥ 413 | 超长块拆到续页会重复记 ordinal,故取下界 |
「逐字闭合」值得多说一句:对齐数据里每一个数字加起来必须正好等于整段字数,多一个字、少一个字都会红。这条断言是整套方案的承重墙——人工选的锚点可能错,但只要闭合对不上,错误就一定会在门禁现形,而不是变成读者屏幕上的丢字。
七、代价:只有对照模式变长
逐块交替是有版面代价的:每个块末行半空,加上块间距,一段大约多占 0.7 页。门禁口径(440×744,一页 16.3 行)下的总页数:
| 模式 | 样板期 | 铺开后 | 差 |
|---|---|---|---|
| 只看原文 | 56 | 56 | 0 |
| 对照 | 103 | 129 | +26 |
| 只看译文 | 55 | 55 | 0 |
只有对照模式变长。样板期的预估是「铺满 38 段约再增 35 页」,实际 +26——因为短段整段本来就放得下,拆块带来的增量比线性估算小。模拟器(1320×2232)上对照模式显示 146 页。原文与译文两种模式页数纹丝不动,因为压根没动它们的装页单位。这个代价在样板期就摆给用户拍过板:「一句一句对应」的价钱是对照模式多翻 26 页,换来的是不再需要在两段文字之间来回找对应。
八、同一套规则的第二处落地:异兽详情页 78 块
篇章侧铺开的同期,异兽详情页把原先的「古籍原文」「白话解读」两张卡合成了一张逐块对照卡,同一套闭合断言、同一套门禁风格:36 只异兽全部登记,共 78 块(第一批 42 + 第二批 36)。
但锚点法在这里不成立。异兽的原文句数与白话句数差得太多——8 对 4、6 对 4、3 对 7 都有,白话常把两句并成一句、或给一句拆成两句。于是换成「块边界对」:锚点是 [下一块的原文句序号, 下一块的白话句序号],块跟着白话走,一个块允许含多个原文句。36 只的边界表全部 apply 通过。
还有 6 只只能整段读:它们的白话只解释末句里的几个字词(如「陵是山陵」「胠是身体两侧肋部一带」),与原文没有可切的一一对应关系。这类记为单块,UI 退化成「整段原文 + 整段白话」——仍是同一张卡,不是退回旧的两张卡。
过程中踩到一个数据来路的坑:模拟器上白泽(第一批)三块正常,陆吾(第二批)整段不切。原因不在对齐数据,而在投影——第一批异兽运行时水合,走 shanhaiReadingForSource(),天然带 alignments;第二批的生成物里内嵌了一整份 source,这份投影当初只写了 originalText / readingNote / isLaterMaterial,漏了 alignments,装配层透传时对齐数据就丢了。修法是生成器抽出统一的内嵌投影函数,两处都带上字段;门禁加一条逐只比对内嵌投影与规范源——以后再漏就红。这套异兽侧的门禁口径:对齐版本钉 2026.10.13-v1-beast-reading-align、36 只 × 78 块、逐块闭合、边界非句中,全部门禁 39/39 PASS。
九、验收:三个不同小节的对照页
模拟器(127.0.0.1:5555,1320×2232)主验收,路径:图鉴 → 白泽 → 阅读《轩辕本纪》原文 → 开卷阅读(默认进对照模式)。
| 截图 | 页码 | 看什么 |
|---|---|---|
emu-both-p2-huangdi-zhisheng | 第 2/146 页(样板段) | 「軒轅,黃帝。」下紧接「轩辕,就是黄帝,」;下一块同理 |
emu-both-p9-chuli-tianxia | 第 9/146 页(新铺开) | 「時元妃西陵氏始養蠶為絲⟦…⟧。」下紧接它自己的译文 |
emu-both-p29-hetu-yu-wenzi | 第 29/146 页(17→15 合并段) | 长句与短句各自配对,无漂移 |
emu-source-p11-whole-paragraph | 第 11/58 页(原文模式) | 整段连排,未拆块,与改前一致 |
三个不同小节的对照页都做到「一句原文 + 它自己的译文」;原文模式仍是整段通读。样板期还做过一次同屏 A/B:把生成物里的 alignments 临时清空重建一版(只改生成物、不改规范源),取完图立即重新生成还原——同一台设备、同一分辨率、只有对齐数据不同,漂移与对齐的差异一目了然。
回头看,这次改造真正解决的问题不是「排版」,而是把一句含糊的阅读体验翻译成可以机器验算的数据契约:人负责判断「这句译文对应哪句原文」,机器负责验证切点合法、两块闭合、总量钉死。后续篇章沿用同一套规则接入对照模式,每章的段数与块数各自钉在同一个门禁里——413 这个数字会一直被盯住,直到有人有意识地改它。
更多推荐
所有评论(0)