Bind number-only Part headings to shared navigation translations

This commit is contained in:
Kai Tan
2026-10-08 08:38:23 +08:00
parent 644e93b874
commit 0e4aec82d8
4 changed files with 19 additions and 3 deletions
+1
View File
@@ -4,6 +4,7 @@
- Add `--complete-book` to include notes, bibliography, index, copyright and non-spine text documents.
- Add explicit configuration/credential file options and a Zenmux Sonnet 5.5 configuration without credentials.
- Checkpoint whole-book terminology scans and record conflicting model proposals while retaining established translations.
- Bind number-only Part headings such as House of Huawei's “Part I” and normalize “第一部分” to “第一部”.
## 2026-10-08 — clickable Part navigation
+2
View File
@@ -67,3 +67,5 @@ OCR、扫描页图文提取、版面中间结构与可点击目录的后续大
50 项测试通过,覆盖中文/双语部号、完整模式提取非 spine 尾注、Zenmux 凭据优先级,以及术语冲突记录、扫描中断和续扫。默认模式继续保留原有跳过策略;完整模式将尾注、参考文献、索引和版权页等文字纳入翻译。
真实 Sonnet 5.5 全书运行中发现,单批术语返回同一别名的不同译名会中止全书。现在自动扫描逐条校验建议,保留已确定译名并记录冲突,每批原子保存结果及进度。手工术语表的冲突仍严格报错。两本书的实际运行结果将在完成后单独记录;本节不代表已完成全书译文验收。
追加验证:两本书完整模式共 6,782 个单元,原文格式往返失败 0、新增结构问题 0。《House of Huawei》的 Part I/II/III 只有编号,没有副标题,原绑定算法去掉编号后得到空标题,导致未绑定;现允许明确的 h1–h6 纯编号标题匹配,同文字的普通段落仍不匹配。目录和正文复用同一译文,并将“第一部分”规范为“第一部”。累计 51 项测试通过。
+6 -3
View File
@@ -115,7 +115,10 @@ def _headings(soup, target, label):
group = blocks[start:end]
if any(len(plain(t.get_text())) > 300 for t in group): break
combined = ' '.join(t.get_text(' ', strip=True) for t in group)
if title_identity(combined) == identity and identity:
number_only = (not identity and len(group) == 1 and group[0].name in HEADINGS
and number_info(label)[0] is not None
and number_info(combined) == number_info(label))
if title_identity(combined) == identity and (identity or number_only):
matches.append(group)
# A number leaf and the subtitle alone can both match after stripping a
# numeric prefix. Prefer the complete adjacent title group.
@@ -225,11 +228,11 @@ 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 re.match(r'^第[零一二三四五六七八九十百千万\d]+[章部篇]',compact):
node.replace_with(re.sub(r'^第[零一二三四五六七八九十百千万\d]+[章部篇]',prefix,compact))
node.replace_with(re.sub(r'^第[零一二三四五六七八九十百千万\d]+(?:章|部分?|篇)',prefix,compact))
return root.decode_contents()
+10
View File
@@ -265,3 +265,13 @@ def test_complete_book_includes_backmatter_and_non_spine_documents(tmp_path):
assert any(e.file_path == 'Text/footnotes.xhtml' and 'Note text' in e.original_text for e in complete)
assert FineGrainedExtractor(complete_book=True)._classify_document('index.xhtml') == 'core'
assert FineGrainedExtractor(complete_book=True)._classify_document('nav.xhtml') == 'toc'
def test_number_only_part_heading_is_bound_without_matching_prose():
from src.common.html import parse_html
from src.preprocessing.navigation import _headings, normalize_heading_html
soup = parse_html('<h1 id="part">Part IV</h1><p>Unrelated epigraph.</p>')
headings, reason = _headings(soup, 'part.xhtml', 'Part IV')
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}, '第四部分') == '第四部'