# 〖共创稿事节〗HarmonyOS 7 新特性实战(57):对照阅读逐句对齐——413 个句对的译文锚点切点法与三道门禁

「山海万灵」的篇章阅读里,《轩辕本纪》的对照模式原先是这样工作的:segments[i](一整段原文,含行内夹注)配 translations[i](一整段白话),一屏里上面一整段繁体、下面一整段白话。试用之后的反馈只有一句话:「就是感觉译文和原文无法一句一句的对应上,读起来很费劲」。

这不是渲染 bug,是数据里根本没有「句」这一层——粒度是「段」,读者要在一段 459 字的繁体里自己找「这句对应译文哪一句」。更糟的是超长段拆页的时候:分页器 planSlice 的对照分支里,原文切点与译文切点是两条独立的链,只共享一个行数比例。两条链一错就错开一整句。真机取证过一次:第 4/108 页,原文还停在「得祝融,辨乎南方」,译文已经跑到「得到火封,让他辨察西方——酉时是一半,鸡在黎明报晓时…」——译文超前一整块,而且没有任何报错。

改造分两期做完:先做一节样板验版式,确认可读后铺开其余 37 段。最终全篇 15 节 38 段、413 个句对,对照模式里一句原文下面紧接它自己的译文。

改前改后:整段对照会漂移,逐块对照一句原文紧接它的译文

一、铺开前后的盘子

项样板期(v4)铺开后(v5)
带对齐的段1 / 3838 / 38
对齐块总数13413
带对齐的小节1 / 1515 / 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 块');

门禁链条:生成器断言一遍,门禁再断言一遍,块数钉成 413

门禁断言钉死的值为什么
alignedSections15不许回退成只剩样板一节
alignedParagraphs38对齐数据必须与段落一一对应
alignedBlocks413「一句对一句」的总量硬指标,少一块都是丢对齐
两块各自逐字闭合原文 12,124 字对参照文本逐字分页器只照数字 slice,切错就是丢字/重字
边界不落在句中块首不以「,、;:」开头否则读者会看到一行以逗号开头的原文
对照模式排出的块数≥ 413超长块拆到续页会重复记 ordinal,故取下界

「逐字闭合」值得多说一句:对齐数据里每一个数字加起来必须正好等于整段字数,多一个字、少一个字都会红。这条断言是整套方案的承重墙——人工选的锚点可能错,但只要闭合对不上,错误就一定会在门禁现形,而不是变成读者屏幕上的丢字。

七、代价:只有对照模式变长

逐块交替是有版面代价的:每个块末行半空,加上块间距,一段大约多占 0.7 页。门禁口径(440×744,一页 16.3 行)下的总页数:

模式样板期铺开后差
只看原文56560
对照103129+26
只看译文55550

只有对照模式变长。样板期的预估是「铺满 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 这个数字会一直被盯住,直到有人有意识地改它。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐