# 〖共创稿事节〗HarmonyOS 7 新特性实战(62):七万六千字逐字上屏——整部《山海经》的两套底本对账与漂移记账

上一篇写了上架自检的导航栏归零,那是一次被动返修;这一篇回到内容本身。应用阅读器里现在躺着整部《山海经》——《南山经》到《中山经》十九篇,加上《轩辕本纪》与《逍遥游》,篇章层合计 20 篇 / 912 段 / 76,859 字 / 1,762 条行内夹注。系列第 57 篇写过这批内容「怎么切」:译文和原文要一句对一句,切点规则与三道校验。这一篇写另一半,也是更早发生的那一半:这几万字怎么保证逐字上屏,一个字都不错。

结论先说:支撑它的是一台逐字核对器、一套「漂移即记账」的校勘纪律、一条「对齐数据必须生成不许手写」的铁律,和一轮 9 串全命中的真机复验。今天(2026-11-09)门禁复跑依然全绿。

内容管线全景:两套底本进对账器,差异走记账,规范源派生出全部数字

一、两条对账规则:经文逐字,注文按集合

先交代底本。古籍没有「原始文件」可拷贝,只有两种可获取的整理本:标点本(维基文库《山海經》整理稿)(经文连标点齐全,但存在讹字)和四库全书本(字形古老、无标点,但保留郭璞注的原始序列)。规范源以标点本经文为骨架、把四库本的注以 〈…〉 形式挂回句间,于是每一侧都有独立的参照物,对账器就建在这两条参照上:

  • 经文对标点本逐字相同。 每条 entry 去掉注后,必须与标点本对应段落逐字一致——不是「大意一致」,是码点级一致。
  • 注文对四库本做集合比对。 每条注文去掉我们补的句读(,。;:)和引号后,必须能在四库本注文序列里找到同一条。

为什么注文不用逐位置比对、而用集合?因为编者的合法加工恰好有两种:补句读、给别本异文加引号。去掉这两类字符后,剩下的差异就是漏字、改字、错序——集合比对对「编者合并两条注」这类位置移动不敏感,逐位置比对反而会假阴性。对账器的核心比对逻辑不到一百行:

// 1) 经文:每条 entry 去掉 〈…〉 后与标点本对应段落逐字比对
for (let i = 0; i < Math.max(entries.length, biaoParagraphs.length); i += 1) {
  const plain = entries[i].text.replace(/〈[^〉]*〉/gu, '');
  if (plain !== biaoParagraphs[i]) {
    // 报出首异字位置与两侧上下文,人工核对是「标点本讹」还是「我们改错」
    console.log(`[正文漂移] 第 ${i + 1} 条,首异于第 ${k} 字`);
  }
}

// 2) 注文:集合比对——去句读、去引号后,每条注必须在四库本注文序列里找到
const bareNote = (text) => text.replace(PUNCT, '').replace(QUOTES, '');
const paperBare = [...new Set(normNotes.map(bareNote))];
for (const note of mineNotes) {
  const b = bareNote(note);
  if (/^一作/.test(b) || /^一曰/.test(note)) continue;  // 别本异文只在标点本存在,豁免
  if (!paperBare.includes(b)) console.log(`[注文失配] ${note.slice(0, 30)}…`);
}
const missing = paperBare.filter((b) => !mineBare.includes(b));  // 四库本有而规范源没收

核对器的输出固定三分类,这个分类后来成了整条流水线的沟通语言——每批次收工时看一眼这三个数字,就知道这一批有没有引入新问题:

输出分类判定逻辑处置路径
正文漂移去注后与标点本逐字不等,报首异字位置核对后走修复表 + 校勘记账
注文失配去句读引号后在四库本序列里找不到查归一表漏项或修复串字形
漏收注四库本有而规范源没有的注补挂或记账说明不收的原因

二、漂移不是 bug,是记账

第一次跑对账时最意外的发现是:正文漂移不一定是我们的错。B6 批次收工时对账器报:

中山经: 213 条 / 213 段,正文漂移 4 处,注文失配 0 处,漏收注 3 条

4 处漂移逐条核过,全部是「标点本错、别本对」:

位置标点本我们采用依据
段 143 騩山祠礼其祠︰羞酒其祠:羞酒直排专用冒号,统一横排口径
段 208 十二经总记凡十萬山凡十五山此列实十五座山,注本作「十五」,「十萬」为讹
段 208 十二经总记一牝豚刏一牝豚刉注本与已上线各段同作「刉」,异体归一
段 95(B2 已记)白/自等字形从注本前批记账的老账

处理这类分歧有一条纪律:不硬改参照物,也不悄悄改正文,而是走修复表加记账。修复表是数据文件里的定点替换(只作用于经文或只作用于注),每一条修复必须在版本说明(editionNote)里记账,写明位置、原文、采用字形和依据:

// 正文级 fix(只改〈…〉以外的经文部分)
const BODY_TEXT_FIX = [
  ['其祠︰羞酒', '其祠:羞酒'],   // 段 143:直排专用冒号统一作「:」
  ['凡十萬山', '凡十五山'],       // 段 208:标点本讹,注本作「十五」,从注本
  ['刏', '刉']                    // 段 208:标点本异体,注本与已上线各段同作「刉」
];

记账的价值在半年后:任何人看到正文与标点本不一致,都能查到「这是有依据的校勘」还是「这是引入的 bug」。没有账本,两者无法区分,对账报告就会永远红着,最后被人无视。

注文侧还踩过一个隐蔽的坑:修复表的匹配串必须写归一之后的字形。管线里有一层旧写/俗体归一(「隂→陰」「䑕→鼠」等三十余组,只作用于注),修复表在归一之后执行——有一条修复串写成了归一前的「隂諧」,永远匹配不上,工具不报错、注就悄悄错着。教训落成了规矩:修复串一律从归一后的文本里复制,不从底本照片抄。

还有一条注走到了反面:段 125 某注在底本里只剩结构符(⿰/⿱ 形的合体字描述),被释的字和注音两处都缺失,定不出是什么字。决策是整条不收,在记账里写明原因,而不是硬编一个字形上去。古籍应用里,一个编造的字比一条缺失的注严重得多。

三、生成而非手写:对齐数据必须从同一份数据派生

内容侧的第二条铁律:凡是能算出来的数字,不许手写。

写作流水线把人压缩到只做一件事——写译文。工具按段出「工作单」(每段切成几块、每块原文是什么),人按块填译文;填完由生成器核对块数,从译文表取出内容,直接产出规范源要写的 entry 字面量。译文块数与切分不一致时,生成器拒绝输出——不是警告,是不产出。

node gen.mjs ws 193 212    # 出工作单:段 193–212 每段几块、每块原文
node gen.mjs emit 193 212  # 出 entry 字面量:译文块数不符的段直接跳过并报错

生成器产出 entry 后,一个构建器把它编译成阅读器真正消费的篇章对象,逐句对齐(alignments)也在这里算出来。切块器是两套工具共用的同一份口径,核心十几行:

// 走「带注的原文」判句:遇到 〈 整段跳过(注里的句号不算边界),
// 遇到经文自己的 。!? 收一块,并把紧跟标点的注与右引号吸进本块
function splitBlocks(text) {
  const blocks = [];
  let current = '';
  let i = 0;
  while (i < text.length) {
    const ch = text.charAt(i);
    if (ch === '〈') {
      const close = text.indexOf('〉', i + 1);
      current += text.slice(i, close + 1);   // 注整体跟住,不参与判句
      i = close + 1;
      continue;
    }
    current += ch;
    i += 1;
    if ('。!?'.includes(ch)) {
      while (i < text.length && (text.charAt(i) === '」' || text.charAt(i) === '』')) {
        current += text.charAt(i);           // 右引号不留给下一块,否则下块以 」 开头像断错
        i += 1;
      }
      blocks.push(current);
      current = '';
    }
  }
  if (current.length > 0) blocks.push(current);
  return blocks;
}

「先删注再判句」是第一版踩过的坑:注里的句号会被当成句子边界,块就切错了。为什么对齐要算而不是手写?构建器文件头写得很直白:

为什么对齐要算而不是手写:`alignments` 是「怎么切」的声明,
分页器只会照着字数 slice,切错就是丢字或重字,它自己发现不了。
手写数字迟早会与正文漂移;这里从同一份数据派生,天然闭合。

这段注释来自一个真实教训:早期版本的块数字段是手填的,正文改一个字、块数就静默过期,而分页器照着过期数字切,丢字不报错。「同一份数据派生」之后,这类漂移在结构上不可能发生。

门禁侧同理。篇章层门禁把「每篇该有多少节 / 多少段 / 多少注 / 多少块」收进一张期望表,全部断言在表上循环——今天复跑的输出里,20 篇各自的三态页数(对照 / 原文 / 译文)、912 段逐段守恒、912/912 译文铺满、592 段对齐成 2,130 块、两块逐字闭合,全在这一个脚本里断言。加一篇古籍只需往表里加一行,断言不用复制粘贴——这个形状决定了它跟得上内容扩张。

四、上屏口径:三态分页、行内注与扩展 B 区生僻字

数据对了,上屏还有三个口径问题。

分页按三态分别算。 同一节内容,对照模式(一句原文紧跟一句译文)、纯原文、纯译文的版心容量不同,页数自然不同。分页预算是 16.3 行/页,同节短段可合并、超长段按行拆页,门禁断言「每一页都在预算内、912 段全部被承载」。以本篇叙事主体的中山经(12 节 / 213 段 / 322 注)为例,三态页数就是三个不同的数字:

阅读模式排版方式中山经页数
对照一句原文紧跟一句它的译文,按块切页65
原文纯经文连排,行内小字注随文153
译文纯白话连排68

对照模式页数最少——原文译文同页互不单独成页;原文模式最厚,因为 1,762 条小字注全部随文挤占版心。

注文是小字行内注,不是脚注。 1,762 条郭璞注全部以小字号挂在句间。最长的考验是洞庭之山「帝之二女」那条超长注(湘夫人典故段),真机上整条注行内排印不断版;「或作某」类校勘注与「音某」类音注同一形态,4 倍放大逐条核对过。

生僻字必须真机出字形。 《中山经》里「𤝻」(U+2477B)落在 CJK 统一表意文字扩展 B 区——基本多文种平面之外,这类字在老设备字体里最容易变豆腐块。真机实测原文与译文均正常出字形、非豆腐块——这一条直接影响了「是否需要内置子集字体」的决策:现有证据下系统字体覆盖足够,欠账留了「更大范围字表抽样再定论」,不为一个字内置一套字体。

真机复验 9 串全命中:从封面元数据到总说收尾的检索串与页位

五、真机复验:9 串全命中与三个环境坑

门禁绿了只代表数据层闭合,「上屏之后读者看到什么」要靠真机复验。方法很朴素:给每一批内容选几条只可能出现在正确页位的特征串,用 hdc shell 加设备端 grep 在 uitest dumpLayout 的输出里检索——每串记录命中的页码,与期望页位对照。B6 收尾那一轮 9 串全命中:封面「12 节 · 213 段 · 322 条小字注」、B5 四串(含生僻字段)、B6 五串(首段、长注、总记、总说、收尾)全部在期望页位。

真机在屏:中山经封面「12 节 · 213 段 · 322 条小字注」

三个环境坑值得记下,都是只有跑起来才会遇到的:

  • 模拟器点击注入全面失效。 uitest uiInput click 返回 No Error 但界面无响应,同点 swipe、doubleClick、longClick、uinput 鼠标点击均无效,只有 swipe 可用。复验靠「横滑翻页」完成——检索翻页本来就用 swipe,点击失效没有挡路,但同类验证若依赖点击就得切真机。
  • 非 BMP 字符 grep 不可靠。 设备端 grep 对「𤝻」(U+2477B,超出基本多文种平面)匹配失败,主机 grep 同样命中不了。绕行:同段内换一条等效特征串(「見則其邑有火」),检索目标不变。
  • 检索预算小于内容页位会全 MISS。 有一轮 4 串全没找到,排查半天发现是检索脚本的页面扫描预算设小了(78 页),而目标内容在篇末 135–150 页——内容好好的,是探照灯没照到那么远。教训写进了脚本:预算不足要在报告里显式声明,不能和「内容缺失」混在同一句 MISS 里。

另外记一个与内容无关但同场发生的细节:复验过程中另一台应用的广告两次抢占前台,aa force-stop 加深链重启后,阅读器页码状态保留——这个无名的小韧性让检索不用从头翻。

生僻字在屏:𤝻(U+2477B)真机出字形,音注小字行内

收个尾

复盘这条内容管线,最有复用价值的不是任何单个工具,而是一种账本思维:

  • 对账器只认三分类输出,新批次的差异必须是「已记账的老账」或零;
  • 每一条偏离底本的修复,都要在版本说明里留下位置、原文、依据三要素;
  • 定不出结论的地方(段 125 那条注)选择留空加记账,而不是编一个答案;
  • 所有派生数字从同一份数据算出,门禁把「读者看到的排版口径」钉成版本号。

这套纪律成立后,内容扩张变成了流水线动作:从中山经合卷到全书十九篇齐,每批收工只看三个数字——正文漂移 4 · 注文失配 0 · 漏收注 3(全老账)。数字形态对了,内容就是可信的;数字形态不对,红着的就是真问题。HarmonyOS 侧的分页、行内注与字形渲染反而成了末端执行——数据层把「对」保证完了,上屏层只需要把「对」排出来。

附:本篇证据清单

  • 门禁复跑(2026-11-09):app-harmony/scripts/verify-source-chapters.mjs PASS——20 篇 / 912 段 / 76,859 字 byte-for-byte / 1,762 夹注 / 2,130 对齐块 / 912 段译文铺满,版号 2026.10.18-v29。
  • 对账器:.workbuddy-ai/tmp/check-bendi.mjs(经文逐字 + 注文集合比对 + 三分类输出);生成器 .workbuddy-ai/tmp/b5x/gen.mjs(ws/emit 双模式、归一表、修复表);构建器 backend/scripts/build-shanhaijing-chapters.mjs(alignments 派生与切块口径)。
  • 真机复验:acceptance/2026-10-16-shanhaijing-zhongshan-b6/(9 串命中表、13 张截图:封面元数据、B5 补验、生僻字特写、长注排印、总说收尾)。
  • 过程记录:docs/development/TASK_BOARD.md 第十三~十六轮(B3–B6 批次的对账输出、修复记账、真机复验逐轮记录)。
  • 关联稿件:句对切分与三道校验见系列第 57 篇;阅读流进栈与返回矩阵见系列第 56 篇。
  • 已知欠账:生僻字覆盖结论基于中山经抽样,更大范围字表抽样后再定是否内置子集字体;段 125 一条注定不出字,整条未收已记账。
Logo

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

更多推荐