Fix nested chapter anchors and Chinese ordinal navigation

This commit is contained in:
Kai Tan
2026-10-08 18:18:49 +08:00
parent 5dbd7bd240
commit 7f1ae23fe2
4 changed files with 105 additions and 4 deletions
+6
View File
@@ -1,3 +1,9 @@
## 2026-10-08 — nested chapter anchors and Highest Exam regression
- Bind TOC anchors nested inside headings to their enclosing title; exclude punctuation-only ornaments from heading groups.
- Normalize visible Chinese ordinals, including split Chinese numbers and suffixes, without duplicating screen-reader prefixes in navigation labels.
- Complete a Luna agent translation of The Highest Exam in both modes and record full-book audits, semantic samples and paired headless layout review.
## v0.14 (2026-10-08) — Chinese covers and library edition metadata
- Add source-bound bibliographic configuration with authoritative/project title status and evidence; include verified Gambling Man title/author and a clearly provisional House of Huawei title.
+37
View File
@@ -0,0 +1,37 @@
# The Highest Exam — 翻译与抽样 QA
日期:2026-10-08。按 epub-translate 默认流程使用 `gpt-6-luna` 子 agent,先固定全书术语,再以 24 个大段批次翻译。构建模型记录为 `agent/luna`,没有调用旧书的 Sonnet API。
输入:`input/The Highest Exam - Ruixue Jia.epub`。SHA256:`164b24a745c71a1ce0d16d024772d5832141b7c41a15b257ff0ad914a243d962`。
项目译名:《至高之考:高考如何塑造中国》,不作为正式出版译名。作者依源书为贾瑞雪、李宏彬,与 Claire Cousineau 合著。
## 完整性与工程检查
- 原有 1016 个有效单元全部翻译;绑定目录与实际章节标题后消除 16 个重复目录翻译单元,最终 1000/1000 完整回填。保留原始清单及迁移记录,未删除可见原文内容。
- 包括正文、版权、目录、致谢、注释、索引及非 spine 文本。两版清单与源书重新提取结果匹配,无缺项、多项、源文不匹配或校验失败。
- 22 个目录条目通过目标、层级和标题一致性审计;179 个书内注释入口保留。首章和末章各抽一个注号,在相应译本及原文中实际点击并验证返回链接。
- 原封面图片字节保留;本书无正文插图或表格。原英文封面图片内文字未翻译。
- 修正 nested anchor 标题识别、隐藏屏幕阅读器前缀重复及“九”与“章”分散排版。保留标签、ID、链接与原样式结构。回归测试:81 passed,2 skipped(可选 Calibre 环境)。
## 语言与视觉抽查
固定 45 条术语。已核对作者中文姓名;meritocracy 采用“优绩主义”,不把普通姓氏硬绑定到人物。Little Li、Nan Nan 保留原化名拼写,避免猜测汉字。作者正文姓名统一;纯书目作者、作品题名、期刊名和 DOI 可保留原文,解释性注释译为中文。
独立 Luna 复核引言至第七章的 15 个长单元,并补查开篇、引语和数字;根 agent 另对第八、九章和注释的 6 个长单元及标题、致谢、索引做抽查。修正世界排名最高被弱化成“前列”、索引“参见 also”、末章作者英文名,以及八宝山公墓索引译名。仅为语义抽查,不声称逐句审校全部正文。
原文/译文成对渲染并查看题名页、第一部、第三部、第一章、第九章、注释、致谢、索引和封面;检查正文续屏、两格缩进、标题与部号、注号、长引用、链接换行。最终自动审计无新增问题或新增 layout findings,人工查看的采样区域未发现尚未修正的遮挡或标题问题。
第一章原书同时列有英文译句和中文原句,中文模式会显示两份相同中文引语,保留其原有并列结构。索引保留原书排列顺序和原页码,不重新按拼音排序。Chromium 证据是连续 XHTML 排版,不代表所有阅读器的分页表现。
## 本地证据与交付
两版完整 EPUB 位于 `.work/luna-highest-exam/output/`。原书、译本、术语、模型与来源记录、清单和截图均留在本地,不提交书籍内容。
- `translation-provenance.json`:模型、源文件、完整度。
- `terminology-evidence.json`、`semantic-review-luna.json`:术语依据、语义抽查范围。
- `navigation-migration.json`、`editorial-proofreading.json`、`editorial-final.json`:修订记录。
- `qa-chinese/report.json`、`qa-bilingual/report.json`:最终输出摘要绑定的自动审计与渲染证据;自动 visual/semantic 字段维持 pending。
- `review-record.json`:实际人工采样复核、已审截图和输出 SHA,与自动报告分开记录。
作者依据:[贾瑞雪访谈(复旦)](https://econ.fudan.edu.cn/info/1759/41557.htm)、[李宏彬(清华)](https://www.tsinghua.edu.cn/info/1907/75444.htm)。优绩主义术语依据:[清华大学教育研究](https://tje.ioe.tsinghua.edu.cn/oa/DArticle.aspx?id=202603005&type=view)。
+26 -4
View File
@@ -98,6 +98,12 @@ def _headings(soup, target, label):
anchor = soup.find(id=fragment) if fragment else None
if fragment and anchor is None:
return [], 'missing anchor'
# Publisher TOCs often target an <a>/<span> inside a heading. The
# enclosing heading owns the text; subsequent prose is not the target.
if anchor and anchor.name not in HEADINGS:
enclosing = anchor.find_parent(HEADINGS)
if enclosing is not None:
anchor = enclosing
identity = title_identity(label)
# Only inspect the opening leaf blocks or blocks at the explicit anchor.
# An exact match across adjacent blocks also recognizes Calibre-converted
@@ -108,7 +114,7 @@ def _headings(soup, target, label):
nearby = [anchor] + anchor.find_all_next(HEADINGS + ['p','div'], limit=16)
block_ids = {id(t) for t in blocks}
blocks = [t for t in nearby if id(t) in block_ids]
blocks = [t for t in blocks if plain(t.get_text())][:12]
blocks = [t for t in blocks if any(c.isalnum() for c in plain(t.get_text()))][:12]
matches = []
for start in range(min(len(blocks), 6)):
for end in range(start+1, min(start+4, len(blocks))+1):
@@ -220,7 +226,10 @@ def normalize_heading_html(record, html):
prefix=ordinal(record)
if not prefix: return html
soup=parse_html('<div>'+html+'</div>');root=soup.body.div
nodes=[n for n in root.find_all(string=True) if plain(str(n))]
hidden = [n for n in root.find_all(string=True) if plain(str(n)) and
n.find_parent(class_=re.compile(r'^(?:sr-only|visually-hidden|screen-reader-only)$'))]
hidden_ids = {id(n) for n in hidden}
nodes=[n for n in root.find_all(string=True) if plain(str(n)) and id(n) not in hidden_ids]
if not nodes: return html
node=nodes[0];text=plain(str(node));compact=re.sub(r'\s+', '', text).strip('::')
# Some publishers put the digit in its own styled span: 第<span>1</span>部.
@@ -228,11 +237,18 @@ def normalize_heading_html(record, html):
and re.fullmatch(r'[章部篇][::]?',plain(str(nodes[2])))):
nodes[1].replace_with(chinese_number(record['number']))
nodes[2].replace_with('章' if record['role']=='chapter' else '部')
elif re.fullmatch(r'(?:\d+|[IVXLCDM]+|第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇))',compact,re.I):
elif re.fullmatch(r'(?:\d+|[IVXLCDM]+|[零一二三四五六七八九十百千万]+(?:章|部分?|篇)?|第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇))',compact,re.I):
node.replace_with(prefix)
if len(nodes)>1 and plain(str(nodes[1])) in {':',':'}: nodes[1].replace_with('')
elif len(nodes)>1 and plain(str(nodes[1])) in {'章','部','部分','篇'}: nodes[1].replace_with('')
elif re.match(r'^第[零一二三四五六七八九十百千万\d]+[章部篇]',compact):
node.replace_with(re.sub(r'^第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇)',prefix,compact))
visible_now = [n for n in root.find_all(string=True) if plain(str(n)) and id(n) not in hidden_ids]
if visible_now and plain(str(visible_now[0])).startswith(prefix):
# The visible ordinal now includes "chapter/part"; the hidden prefix
# would otherwise announce it a second time to assistive readers.
for text_node in hidden:
text_node.parent['aria-hidden'] = 'true'
return root.decode_contents()
@@ -242,7 +258,13 @@ def navigation_labels(structure, entries):
units=[by_id.get(key) for key in record.get('entry_ids',[record['entry_id']])]
if any(not e or not e.translated_html or e.validation_errors for e in units): continue
units[0].translated_html=normalize_heading_html(record,units[0].translated_html)
label=plain(' '.join(parse_html(e.translated_html).get_text(' ',strip=True) for e in units))
visible = []
for entry in units:
soup = parse_html(entry.translated_html)
for hidden in soup.select('[aria-hidden="true"], .sr-only, .visually-hidden, .screen-reader-only'):
hidden.decompose()
visible.append(soup.get_text(' ', strip=True))
label=plain(' '.join(visible))
prefix=ordinal(record)
# Standardize punctuation only for the navigation label; the rich
# heading can keep its number and subtitle on separate lines.
+36
View File
@@ -279,3 +279,39 @@ def test_number_only_part_heading_is_bound_without_matching_prose():
assert reason is None and [h['id'] for h in headings] == ['part']
assert _headings(parse_html('<p>Part IV</p>'), 'part.xhtml', 'Part IV')[0] == []
assert normalize_heading_html({'role': 'part', 'number': 4}, '第四部分') == '第四部'
def test_nested_toc_anchor_binds_heading_and_normalizes_visible_ordinal():
from src.common.html import parse_html
from src.preprocessing.navigation import _headings, normalize_heading_html
soup = parse_html('<h2 id="ch1"><a id="sec3" href="contents.xhtml#rsec3">'
'<span class="sr-only">Chapter</span><span class="chno">1</span> '
'<span class="chaptit">Obsession</span></a></h2>'
'<p class="stars">° ° °</p><p>Unrelated prose.</p>')
headings, reason = _headings(soup, 'chapter.xhtml#sec3', 'Chapter 1. Obsession')
assert reason is None and [h['id'] for h in headings] == ['ch1']
translated = str(soup.h2.a).replace('Chapter', '第一章').replace('Obsession', '痴迷')
record = {'role': 'chapter', 'number': 1}
normalized = normalize_heading_html(record, translated)
result = parse_html(normalized)
assert result.find(class_='chno').get_text() == '第一章'
assert result.find(class_='sr-only')['aria-hidden'] == 'true'
assert result.find('a')['id'] == 'sec3'
assert result.find('a')['href'] == 'contents.xhtml#rsec3'
assert normalize_heading_html(record, normalized) == normalized
from src.common.data_model import BookMetaData, ManifestEntry
from src.preprocessing.navigation import navigation_labels
entry = ManifestEntry(entry_id='chapter.xhtml#ch1', file_path='chapter.xhtml', element_id='ch1',
original_text='Chapter 1 Obsession', translated_text='第一章 痴迷',
translated_html=normalized)
structure = BookStructure(metadata=BookMetaData(), navigation_titles=[{
**record, 'entry_id': entry.entry_id, 'target': 'chapter.xhtml#sec3'}])
assert navigation_labels(structure, [entry])['chapter.xhtml#sec3'] == '第一章:痴迷'
split = '<span class="sr-only">第</span><span class="chno">九</span>章 <span>考试帝国扩张</span>'
ninth = parse_html(normalize_heading_html({'role':'chapter','number':9}, split))
assert ninth.find(class_='chno').get_text() == '第九章'
assert '章章' not in ninth.get_text()
assert not any(str(n).strip() == '章' for n in ninth.find_all(string=True))
# A nested anchor in ordinary prose must still not become a title.
assert not _headings(parse_html('<p><a id="prose">A sentence.</a></p>'),
'chapter.xhtml#prose', 'Chapter 1. Obsession')[0]