diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab45ba2..5dff50c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+## 2026-10-08 — Chinese typography and shared navigation titles
+
+- Indent Chinese body paragraphs by two em, including chapter openings and converted div prose; preserve heading/caption/note layouts.
+- Bind NAV, NCX and visible contents links to shared chapter/part translations while preserving anchors, hierarchy and resource paths.
+- Recognize adjacent number/title/subtitle blocks and normalize Chinese chapter/part numbers without merging their markup.
+- Include heading context in translation cache fingerprints and update old manifest contexts during reconciliation.
+- Add navigation regression tests and record the scanned PDF/OCR to EPUB roadmap.
+
## 2026-10-08 — translation and QA improvements
- Replace regex tag extraction with balanced DOM placeholders and protect literal φ.
diff --git a/README.md b/README.md
index 6f9a34d..82a976a 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,9 @@ cp .env.example .env
- DOM 遍历保留行内标签,图片、数学公式和脚注作为完整对象保护;占位符须次数正确、嵌套合法,还原后须是合法 XHTML。
- 大段初译后,只重试未通过校验的条目。网络错误、重复输出 ID、缺失条目、截断和占位符错误保持待处理状态,不写入“错误译文”。正常运行有失败时终止,不静默生成不完整中文版。
-- 在原 EPUB 容器上修改正文;保留 OPF、目录、原 CSS 引用、图片、字体和其他资源。双语段落避免复制 ID,中文版保留原锚点;列表和表格不会插入无效兄弟节点。
+- 在原 EPUB 容器上修改正文;保留 OPF、目录链接及层级、原 CSS 引用、图片、字体和其他资源。双语段落避免复制 ID,中文版保留原锚点;列表和表格不会插入无效兄弟节点。
- HTML5 修复处理不闭合标签和重复 ID,抽取不会重复处理嵌套列表/段落;支持裸 div、表格单元、图注和混合容器中的直接文本。
-- 已翻译条目按原文、占位符、术语表、模型及风格指南指纹复用;每个 chunk 完成就保存。源文件变更会重新预处理。旧版本缓存会升级,无法安全复用的译文需要重新翻译。
+- 已翻译条目按原文、占位符、标题上下文、术语表、模型及风格指南指纹复用;每个 chunk 完成就保存。源文件变更会重新预处理。旧版本缓存会升级,无法安全复用的译文需要重新翻译。
`--skip-translation` 用于离线调试/部分样例,不代表全书已翻译。`--force-clean` 重新预处理;`--force-restore` 兼容旧用法,但现在每次回填前都会重新校验,旧的缓存 HTML 不再直接被信任。
@@ -64,3 +64,13 @@ Mac 沙箱中 Calibre 的 Qt 渲染可能需要允许进程运行。QA 使用项
```
`build_sample.py` 只接受与当前原文和占位符完全对应的条目,输出明确标为部分样例。完整架构见 `doc/architecture_flow.md`;本次测试记录见 `doc/QA_2026-10-08.md`。
+
+### 中文正文与标题目录
+
+中文正文(包括章首段)使用 `2em` 首行缩进;标题、图注、引用和注释保持各自布局。原文段落保留出版社样式。中文段落清除原内联 `text-indent`,以免 `!important` 覆盖中文规则。
+
+预处理以目录的目标文件/锚点建立标题映射。每个实际章标题只翻译一次,NAV、NCX、书内目录及阅读器文档标题使用同一结果;目录 href、章节 ID、层级和顺序保留。章号、主标题、副标题分成多个块时,精确匹配相邻标题块并分别回填,不合并 DOM。中文模式把独立章号统一成“第一章”,Part 对应“第一部”;双语模式保留英文标题和目录标签。
+
+无法可靠绑定的目录条目仅翻译标签,不改写猜测的章节正文;具体条目记录在 `book_structure.json` 的 `navigation_titles[].binding_note`。这类条目需要检查章节标题是否一致,不能视为全书标题同步已经通过。索引、版权、注释全文仍遵守原有跳过策略;导航中这些页面的名称可以翻译。
+
+后续扫描 PDF/OCR 大版本的图文、结构与目录方案见 [路线图](doc/OCR_EPUB_ROADMAP.md)。
diff --git a/doc/OCR_EPUB_ROADMAP.md b/doc/OCR_EPUB_ROADMAP.md
new file mode 100644
index 0000000..568ef39
--- /dev/null
+++ b/doc/OCR_EPUB_ROADMAP.md
@@ -0,0 +1,23 @@
+# 扫描 PDF → EPUB:后续大版本
+
+这是独立的大版本方向,本轮不实现。目标是把旧中文书的扫描 PDF 转成可重排、可搜索、可点击目录的 EPUB,同时保留原书中有阅读意义的结构与图文关系。
+
+## 输入与评测样本
+
+先收集少量代表样本:纯文字、竖排/繁体、双栏、跨页脚注、目录、表格、示意图、照片,以及歪斜和低清扫描。再盘点用户本地已部署的 OCR,用相同页面比较文字准确度、阅读顺序、版面坐标、图表识别、速度和资源占用。届时根据实际部署与测试结果选择模型,保留可替换的 OCR 接口。
+
+## 中间结构
+
+OCR 输出不能直接拼成 HTML。建立带页面来源、坐标、阅读顺序与置信度的版面中间表示,区分正文段落、章号、主标题、副标题、页眉页脚、脚注/尾注、表格、图片、图注。识别跨页段落合并与断词,保留人工修正记录,以便重复 OCR 时复用。
+
+## 图文与样式
+
+从原 PDF 页面或扫描图按区域提取图片、示意图,保留分辨率、裁切来源和对应图注,作为 EPUB 独立资源嵌入。表格优先转为语义表格;复杂或低置信度图表保留原图。可重排 EPUB 保留标题层级、强调、段落、引用和图文关系;精确页面位置有意义的材料另外评估固定版式 EPUB,不能要求同一个可重排版本在每种屏幕上保持 PDF 的坐标。
+
+## 目录和注释
+
+把目录条目、章标题、页码映射到中间结构的稳定 ID,采用本项目的“目标锚点 → 标题”映射生成 NAV、NCX 和书内目录。原 PDF 页码用作来源追踪,不直接充当 EPUB 页码。脚注建立正文引用、注释内容及返回链接;低置信度匹配进入待审核队列。
+
+## QA 闭环与验收
+
+对同一段原扫描、OCR 中间结果和 EPUB 阅读器截图进行对照。验证阅读顺序、图片/图注归属、标题层级、目录点击、注释双向跳转、字体与中文缩进。每次修复保留样例和回归用例;把文字识别质量、结构正确性与视觉版式分别报告。先完成一小本代表书的端到端试验,再扩展到批量转换。
diff --git a/doc/QA_2026-10-08.md b/doc/QA_2026-10-08.md
index 384a1b1..1a3138f 100644
--- a/doc/QA_2026-10-08.md
+++ b/doc/QA_2026-10-08.md
@@ -26,3 +26,25 @@ CLI 主流程使用自制小 EPUB 完成无 API 冒烟验证;新增的输出
自动术语扫描依赖模型判断。完整名字和明确别名可约束;同姓人物、间接指称、未列入表的其他译名仍须语言 QA。`exclude_contexts` 与较长术语优先处理能减少误判,不能代替全书语义消歧。
HTML5 解析可以修复结构,不能自动判断出版者本意;特别的固定版式、复杂 SVG/脚本内容和阅读器差异仍需具体样本。正常主流程遇到新增结构错误会停止并留下报告。原书问题单独留存,不用“没有新增问题”宣称原书符合所有 EPUB 标准。
+
+## 第二轮:中文缩进与标题目录同步
+
+- 新增标题目标映射:NAV、NCX 和明确的书内目录链接解析到原文件/锚点;章号、标题、副标题的相邻块精确绑定,各块仍各自翻译和回填。目录复用实际标题译文,不另行生成章标题。
+- 中文章号统一为“第一章”,Part 对应“第一部”。兼容数字、罗马数字和常见英文数字词,保留数字所在 span 的样式;双语模式保留原英文标题。
+- 中文正文首行缩进 2em,包括原书使用 noindent 的章首段。清除中文段落的内联 text-indent,英文段落不修改;图注、引文、注释、居中段落和标题排除。Calibre 转换产生的正文 div 也适用。
+- 41 项离线测试通过,覆盖双语/中文模式、拆分标题、NAV/NCX/书内目录的一致性、链接及层级、反复回填、内联 CSS、标题缓存上下文更新,以及标题首词 Two/I 的误判回归。
+- 第二轮 11 本书共 27,219 单元、467 个目录目标:原文格式还原失败 0,新增结构问题 0。其中 403 个目标绑定实际标题,64 个不确定目标保留独立标签翻译并记录 binding_note。多数是封面、扉页或装饰标题,仍有复杂章节结构;不声称所有书的标题已经自动同步。Empire of AI 的原有 1 项源文件问题仍记录在报告中。
+
+### Luna 样书与视觉范围
+
+Luna 翻译了 Gambling Man 的 45 个标题单元,45/45 占位符校验通过;沿用第一轮 10 段正文,合计 55/1,582 单元。本轮没有做全书正文翻译,也没有验证实际外部 API 的整本吞吐。封面、扉页和版权这三个导航标签采用独立译文,其余目录目标均可绑定章节/部标题。
+
+最终文件位于 `.work/navigation-final-v4/`:`bilingual-sample.epub`、`target_only-sample.epub`、完整 manifest,以及 `qa-chapter/` 和 `qa-bilingual/` 对照证据。这些局部 QA 样例不提交到 Git。
+
+实际检查了中文版第 1–3 页、双语版第 1–4 页与相应原文页面:部标题、章号、主标题、中文首段/后续段缩进、英文 drop cap、上下标、强调与正文排列没有重叠或截断。PDF 页面是辅助视图;中文 EPUB 另外在 Calibre 9.15 阅读器中打开,确认中文目录“第一章:根源”跳转到对应章节、第一部到第五部层级保留、第一条注释弹窗内容正确。继续进入尾注页,点击第一章第 1 条尾注的返回链接,阅读器显示对应中文正文弹窗,再返回正文主视图;原锚点及返回链接正常。
+
+脚注/尾注全文仍按原有提取策略跳过;本轮翻译的是其目录名称和目录所指的页面标题。尾注内部按章分组的标题仍可能是英文。这一范围不能误报为注释全文已译。
+
+渲染期间有一次 Calibre PDF 进程 SIGSEGV,重新生成后的最终两组输出成功。自动报告仍为 visual_review: pending,人工/agent 观察记录单独保存为 visual-review.json;截图生成本身不表示整本视觉验收通过。
+
+OCR、扫描页图文提取、版面中间结构与可点击目录的后续大版本方案见 `doc/OCR_EPUB_ROADMAP.md`。
diff --git a/main.py b/main.py
index cbc1451..65ebce8 100644
--- a/main.py
+++ b/main.py
@@ -12,6 +12,7 @@ from src.common.config import load_global_config
from src.preprocessing.epub_cleaner import EpubCleaner
from src.preprocessing.profiler import BookProfiler
from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.preprocessing.navigation import prepare_navigation
from src.translation.manifest_manager import ManifestManager
from src.translation.translator_engine import Translator
from src.translation.glossary import prepare_glossary
@@ -65,7 +66,8 @@ async def run_pipeline(args):
if work["structure"].exists() and not args.force_clean:
logger.info(f"Reusing existing book_structure: {work['structure']}")
structure = BookStructure.load(work["structure"])
- if structure.source_sha256 != hashlib.sha256(input_path.read_bytes()).hexdigest():
+ if (structure.preprocessing_version < 3 or
+ structure.source_sha256 != hashlib.sha256(input_path.read_bytes()).hexdigest()):
logger.info("Source changed or old preprocessing cache; re-cleaning")
structure = BookStructure.load(EpubCleaner(input_path, work["root"]).clean())
else:
@@ -77,6 +79,8 @@ async def run_pipeline(args):
# 2. Extraction
extractor = FineGrainedExtractor()
manifest_entries = extractor.extract(structure)
+ prepare_navigation(input_path,structure,manifest_entries)
+ structure.save(work["structure"])
# 3. Manifest Management
manifest_manager = ManifestManager(work["manifest"])
diff --git a/pipeline/01_preprocess.py b/pipeline/01_preprocess.py
index 19f2d53..ddc2e80 100644
--- a/pipeline/01_preprocess.py
+++ b/pipeline/01_preprocess.py
@@ -9,6 +9,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from src.preprocessing.epub_cleaner import EpubCleaner
from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.preprocessing.navigation import prepare_navigation
from src.translation.manifest_manager import ManifestManager
from src.common.data_model import BookStructure
from src.common.utils import setup_logger, ensure_directory
@@ -48,6 +49,8 @@ def run_preprocess(args):
logger.info("Extracting text segments...")
extractor = FineGrainedExtractor()
entries = extractor.extract(structure)
+ prepare_navigation(input_path,structure,entries)
+ structure.save(structure_path)
# 3. Update Manifest
logger.info(f"Updating manifest: {manifest_path}")
diff --git a/scripts/build_sample.py b/scripts/build_sample.py
index b836559..01e9a93 100644
--- a/scripts/build_sample.py
+++ b/scripts/build_sample.py
@@ -8,6 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.common.data_model import BookStructure, ManifestEntry
from src.preprocessing.epub_cleaner import EpubCleaner
from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.preprocessing.navigation import prepare_navigation
from src.assembly.restoration_engine import RestorationEngine
from src.assembly.backfiller import BackfillEngine
from src.assembly.builder import BilingualBuilder
@@ -17,6 +18,7 @@ async def run(args):
args.out.mkdir(parents=True,exist_ok=True)
structure=BookStructure.load(EpubCleaner(args.source,args.out/'source').clean())
entries=FineGrainedExtractor().extract(structure)
+ prepare_navigation(args.source,structure,entries)
by_id={e.entry_id:e for e in entries}
provided=[ManifestEntry.model_validate(e) for e in json.loads(args.manifest.read_text())]
for sample in provided:
diff --git a/scripts/corpus_regression.py b/scripts/corpus_regression.py
index 3eb49b5..4be4a59 100644
--- a/scripts/corpus_regression.py
+++ b/scripts/corpus_regression.py
@@ -6,6 +6,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.preprocessing.epub_cleaner import EpubCleaner
from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.preprocessing.navigation import prepare_navigation
from src.common.data_model import BookStructure
from src.assembly.format_restorer import FormatRestorer
from src.assembly.builder import BilingualBuilder
@@ -23,10 +24,12 @@ def main():
work=args.out/source.stem
structure=BookStructure.load(EpubCleaner(source,work).clean())
entries=FineGrainedExtractor().extract(structure)
+ prepare_navigation(source,structure,entries)
bad=[e.entry_id for e in entries if not FormatRestorer().restore(e.original_text,e.placeholders,original_text=e.original_text)[1]]
output=BilingualBuilder(work,source).build(structure,work/'roundtrip.epub')
before,after=audit_epub(source),audit_epub(output)
- row.update(entries=len(entries),format_failures=bad,source_issues=len(before['issues']),
+ row.update(entries=len(entries),navigation_titles=len(structure.navigation_titles),
+ unbound_titles=[r["source_label"] for r in structure.navigation_titles if r.get("binding_note")],format_failures=bad,source_issues=len(before['issues']),
new_issues=compare_audits(before,after))
except Exception as exc:
row['error']=str(exc)
diff --git a/scripts/visual_qa.py b/scripts/visual_qa.py
index 7e14e55..2f26120 100644
--- a/scripts/visual_qa.py
+++ b/scripts/visual_qa.py
@@ -96,7 +96,7 @@ def main():
'scope': 'provided manifest only'}
if errors and not args.allow_partial:
report['new_issues'].append({'kind': 'incomplete_translation', 'detail': str(len(errors))})
- chapter_hrefs = args.chapter or list(dict.fromkeys(e.file_path for e in entries if e.translated_text))
+ chapter_hrefs = args.chapter or list(dict.fromkeys(e.file_path for e in entries if e.translated_text and e.file_path != "__navigation__"))
if not args.chapter and args.max_chapters:
chapter_hrefs = chapter_hrefs[:args.max_chapters]
report['sampled_chapters'] = chapter_hrefs
diff --git a/src/assembly/backfiller.py b/src/assembly/backfiller.py
index b049262..922556d 100644
--- a/src/assembly/backfiller.py
+++ b/src/assembly/backfiller.py
@@ -1,5 +1,7 @@
import copy
+import re
from src.common.html import parse_html
+from src.preprocessing.navigation import normalize_heading_html, sync_navigation
from src.assembly.format_restorer import FormatRestorer
from src.common.utils import setup_logger, add_spacing
@@ -10,6 +12,7 @@ class BackfillEngine:
async def backfill(self, structure, manifest_entries, mode='bilingual', allow_partial=False):
if mode not in {'bilingual', 'target_only'}:
raise ValueError(f'Unknown output mode: {mode}')
+ titles = {r["entry_id"]: r for r in structure.navigation_titles}
by_file = {}
failures = []
applied = 0
@@ -21,9 +24,16 @@ class BackfillEngine:
restored, ok = FormatRestorer().restore(add_spacing(entry.translated_text), entry.placeholders,
original_text=entry.original_text)
if not ok:
+ entry.translated_html = None
+ entry.validation_errors = ["invalid restored XHTML"]
failures.append(entry.entry_id)
continue
entry.translated_html = restored
+ record = titles.get(entry.entry_id)
+ if record:
+ entry.translated_html=normalize_heading_html(record,restored)
+ if entry.file_path=='__navigation__':
+ continue
by_file.setdefault(entry.file_path, []).append(entry)
if failures and not allow_partial:
raise ValueError(f"Backfill rejected {len(failures)} unvalidated entries: {failures[:3]}")
@@ -49,6 +59,21 @@ class BackfillEngine:
new_tag['xml:lang'] = 'zh-CN'
classes = list(new_tag.get('class', []))
new_tag['class'] = classes + ['translation']
+ heading = entry.context and (entry.context.startswith('navigation-heading:') or entry.context in {'chapter','section','subsection'})
+ special = re.search(r'caption|footnote|endnote|epigraph|verse|poem|center|attribution|dedication', ' '.join(element.get('class', [])), re.I)
+ prose = ((tag_name in {'p','div'} or (tag_name=='span' and element.get('data-translation-unit')))
+ and not heading and entry.context != 'epigraph'
+ and not special and not re.search(r'text-align\s*:\s*(?:center|right)', element.get('style',''),re.I)
+ and not element.find_parent(['blockquote', 'figure', 'aside'])
+ and not element.find_parent(attrs={'epub:type': re.compile(r'footnote|endnote')}))
+ # Chinese body paragraphs use two em even when the publisher's
+ # opening-paragraph class sets text-indent:0.
+ new_tag['data-translation-role'] = 'body' if prose else 'heading' if heading else 'other'
+ if prose:
+ # Publisher inline !important rules otherwise outrank CSS.
+ style = re.sub(r'(?:^|;)\s*text-indent\s*:[^;]*', '', new_tag.get('style',''),flags=re.I).strip('; ')
+ if style: new_tag['style'] = style
+ else: new_tag.attrs.pop('style', None)
applied += 1
fragment = parse_html(f'
{entry.translated_html}
').body.div
for node in list(fragment.contents):
@@ -63,6 +88,8 @@ class BackfillEngine:
else:
element.replace_with(new_tag)
resource.content = str(soup)
+ resource.translation_modified = True
+ sync_navigation(structure, manifest_entries, mode)
if mode == 'target_only' and applied and not allow_partial:
structure.metadata.language = 'zh-CN'
return structure
diff --git a/src/assembly/bilingual_style.css b/src/assembly/bilingual_style.css
index 34a523f..4a9fd6c 100644
--- a/src/assembly/bilingual_style.css
+++ b/src/assembly/bilingual_style.css
@@ -1,4 +1,4 @@
-/* Preserve the book's alignment, indent and inline font choices. */
+/* Preserve publisher alignment and inline fonts; apply Chinese prose indentation. */
.translation {
line-height: 1.6;
clear: both;
@@ -17,3 +17,8 @@ p.translation::first-letter {
color: inherit !important;
float: none !important;
}
+/* Prose gets two Chinese character widths, including chapter-opening prose.
+ Headings, captions, quotations and note markers keep their own layout. */
+.translation[data-translation-role="body"] {
+ text-indent: 2em !important;
+}
diff --git a/src/assembly/package_writer.py b/src/assembly/package_writer.py
index fdc5ea3..54a1f04 100644
--- a/src/assembly/package_writer.py
+++ b/src/assembly/package_writer.py
@@ -37,15 +37,20 @@ def write_preserving_package(original, structure, output):
etree.SubElement(manifest, f'{{{OPF}}}item', id=css_id, href=css_href, **{'media-type': 'text/css'})
replacements[css_archive_path] = (Path(__file__).parent/'bilingual_style.css').read_bytes()
for item_id, resource in structure.resources.items():
- if resource.media_type != 'application/xhtml+xml' or not resource.content:
+ if resource.media_type not in {'application/xhtml+xml','application/x-dtbncx+xml'} or not resource.content:
continue
- if 'nav' in (items.get(item_id).get('properties', '') if item_id in items else '').split() and 'translation-for' not in resource.content and 'class="translation"' not in resource.content:
+ if 'nav' in (items.get(item_id).get('properties', '') if item_id in items else '').split() and not resource.translation_modified and 'translation-for' not in resource.content and 'class="translation"' not in resource.content:
continue
if item_id not in items:
raise ValueError(f'Unknown package item: {item_id}')
archive_path = posixpath.normpath(posixpath.join(base, unquote(items[item_id].get('href'))))
if archive_path not in source.namelist():
raise ValueError(f'Missing source resource: {archive_path}')
+ if resource.media_type=='application/x-dtbncx+xml':
+ if resource.translation_modified:
+ etree.fromstring(resource.content.encode(),etree.XMLParser(resolve_entities=False,no_network=True))
+ replacements[archive_path]=resource.content.encode()
+ continue
soup = parse_html(resource.content)
soup.html['xmlns'] = 'http://www.w3.org/1999/xhtml'
soup.html['xmlns:epub'] = 'http://www.idpf.org/2007/ops'
diff --git a/src/common/data_model.py b/src/common/data_model.py
index 89fe975..6b04901 100644
--- a/src/common/data_model.py
+++ b/src/common/data_model.py
@@ -28,11 +28,13 @@ class ResourceItem(BaseModel):
content: Optional[str] = None # For text/html
file_path: Optional[str] = None # For binary/assets (relative to assets dir)
properties: Optional[str] = None
+ translation_modified: bool = False
class BookStructure(BaseModel):
source_sha256: Optional[str] = None
- preprocessing_version: int = 2
+ preprocessing_version: int = 3
metadata: BookMetaData
+ navigation_titles: List[Dict[str, Any]] = Field(default_factory=list)
spine: List[str] = Field(default_factory=list, description="Ordered list of item IDs in spine")
resources: Dict[str, ResourceItem] = Field(default_factory=dict, description="Map of item_id to ResourceItem")
diff --git a/src/preprocessing/navigation.py b/src/preprocessing/navigation.py
new file mode 100644
index 0000000..625b6b6
--- /dev/null
+++ b/src/preprocessing/navigation.py
@@ -0,0 +1,296 @@
+"""One title translation per destination, shared by headings, NAV, NCX and TOC pages.
+
+Original href/src, anchors and hierarchy are never rebuilt from translated text.
+Uncertain heading matches are recorded rather than replacing arbitrary prose.
+"""
+import hashlib
+import posixpath
+import re
+import zipfile
+from urllib.parse import unquote, urlsplit
+from bs4 import NavigableString
+from lxml import etree
+from src.common.html import parse_html
+from src.common.data_model import ManifestEntry
+from src.preprocessing.format_extractor import FormatExtractor
+from src.assembly.package_writer import package_path
+
+HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
+WORDS = {word: i for i, word in enumerate('zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty'.split())}
+NUMBER = r'(?:\d+|[ivxlcdm]+|' + '|'.join(WORDS) + ')'
+
+
+def destination(document, href):
+ parts = urlsplit(href)
+ if parts.scheme or parts.netloc:
+ return None
+ path = (posixpath.normpath(posixpath.join(posixpath.dirname(document), unquote(parts.path)))
+ if parts.path else document)
+ return path + ('#' + unquote(parts.fragment) if parts.fragment else '')
+
+
+def plain(text):
+ return re.sub(r'\s+', ' ', text).strip()
+
+
+def is_toc(soup, href, properties=''):
+ return ('nav' in properties.split() or soup.find(attrs={'role': 'doc-toc'}) is not None
+ or any('toc' in str(t.get('epub:type', '')).split() for t in soup.find_all('nav'))
+ or re.search(r'(?:^|[/_\-\d])(toc|contents|nav)(?:[._\-]|$)', href, re.I) is not None
+ or any(plain(h.get_text()).casefold() in {'contents', 'table of contents', '目录'}
+ for h in soup.find_all(HEADINGS)))
+
+
+def title_identity(label):
+ label = plain(label)
+ label, count = re.subn(r'^(?:chapter|chap\.?|part|book|section)\s+' + NUMBER + r'\b\s*[:.|\-–—]?\s*', '', label, count=1, flags=re.I)
+ if not count:
+ label = re.sub(r'^' + NUMBER + r'\b\s*[.:|\-–—]?\s*', '', label, count=1, flags=re.I)
+ return re.sub(r'[^\w]', '', label.casefold())
+
+
+def number_info(label):
+ match = re.match(r'^(chapter|chap\.?|part|book)\s+(' + NUMBER + r')\b', plain(label), re.I)
+ if match:
+ role = 'chapter' if match[1].lower().startswith('chap') else 'part'
+ number = match[2]
+ else:
+ match = re.match(r'^(\d+)\b', plain(label))
+ if not match: return None, None
+ role, number = 'chapter', match[1]
+ if number.isdigit():
+ value = int(number)
+ elif number.casefold() in WORDS:
+ value = WORDS[number.casefold()]
+ else:
+ values = {'I':1,'V':5,'X':10,'L':50,'C':100,'D':500,'M':1000}
+ value, previous = 0, 0
+ for char in reversed(number.upper()):
+ current=values[char]; value += -current if current < previous else current; previous=max(previous,current)
+ return role, value
+
+
+def chinese_number(number):
+ if number < 0 or number >= 10000:
+ return str(number)
+ if number == 0:
+ return '零'
+ digits='零一二三四五六七八九'; result=''; zero=False
+ for divisor,unit in [(1000,'千'),(100,'百'),(10,'十'),(1,'')]:
+ digit,number=divmod(number,divisor)
+ if digit:
+ if zero: result+='零'
+ if not (digit==1 and divisor==10 and not result): result+=digits[digit]
+ result+=unit;zero=False
+ elif result and number:
+ zero=True
+ return result
+
+
+def ordinal(record):
+ if record.get('number') is None:
+ return None
+ return '第'+chinese_number(record['number'])+('章' if record['role']=='chapter' else '部')
+
+
+def _headings(soup, target, label):
+ fragment = target.partition('#')[2]
+ anchor = soup.find(id=fragment) if fragment else None
+ if fragment and anchor is None:
+ return [], 'missing anchor'
+ 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
+ # number/title/subtitle paragraphs without guessing from font sizes.
+ blocks = soup.find_all(HEADINGS + ['p', 'div'])
+ blocks = [t for t in blocks if not t.find(HEADINGS + ['p','div','section','blockquote','li'])]
+ if anchor:
+ 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]
+ matches = []
+ for start in range(min(len(blocks), 6)):
+ for end in range(start+1, min(start+4, len(blocks))+1):
+ 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:
+ matches.append(group)
+ # A number leaf and the subtitle alone can both match after stripping a
+ # numeric prefix. Prefer the complete adjacent title group.
+ if matches:
+ longest = max(len(g) for g in matches)
+ matches = [g for g in matches if len(g) == longest]
+ if len(matches) == 1: return matches[0], None
+ return [], 'ambiguous heading'
+ # An explicit heading anchor is authoritative even if the publisher uses
+ # an abbreviated TOC label; a prose anchor never receives this fallback.
+ if anchor and anchor.name in HEADINGS: return [anchor], None
+ return [], 'no matching heading'
+
+
+def prepare_navigation(original, structure, entries):
+ """Enrich the manifest with canonical heading entries, never translate TOC duplicates."""
+ soups={r.href:parse_html(r.content) for r in structure.resources.values()
+ if r.media_type=='application/xhtml+xml' and r.content}
+ resources={posixpath.normpath(unquote(r.href)):r for r in structure.resources.values()}
+ labels={}
+ def collect(document, href, label):
+ target=destination(document,href)
+ if target and plain(label):
+ record=labels.setdefault(target,{'target':target,'source_label':plain(label),'source_variants':[]})
+ if plain(label) not in record['source_variants']: record['source_variants'].append(plain(label))
+ # EPUB3 NAV and explicit contents pages take priority over legacy NCX names.
+ toc_files=set()
+ for href,soup in soups.items():
+ if not is_toc(soup,href,resources[posixpath.normpath(unquote(href))].properties or ''):
+ continue
+ toc_files.add(href)
+ toc_containers=[n for n in soup.find_all('nav') if 'toc' in str(n.get('epub:type','')).split() or n.get('role')=='doc-toc']
+ containers=toc_containers or [soup.body]
+ for container in containers:
+ for a in container.find_all('a',href=True): collect(href,a['href'],a.get_text(' ',strip=True))
+ with zipfile.ZipFile(original) as source:
+ base=posixpath.dirname(package_path(source))
+ for resource in structure.resources.values():
+ if resource.media_type!='application/x-dtbncx+xml': continue
+ raw=source.read(posixpath.normpath(posixpath.join(base,unquote(resource.href))))
+ doc=etree.fromstring(raw,etree.XMLParser(resolve_entities=False,no_network=True))
+ for point in doc.xpath('//*[local-name()="navMap"]//*[local-name()="navPoint"]'):
+ content=point.xpath('./*[local-name()="content"]/@src')
+ text=point.xpath('./*[local-name()="navLabel"]/*[local-name()="text"]')
+ if content and text: collect(resource.href,content[0],''.join(text[0].itertext()))
+ # NCX stays XML; it is not passed through an HTML parser.
+ resource.content=etree.tostring(doc,encoding='unicode')
+ # Ordinary manifest extraction must not independently translate visible
+ # TOC text. Those links are synchronized by target after title translation.
+ entries[:]=[e for e in entries if e.file_path not in toc_files]
+ by_id={e.entry_id:e for e in entries}; records=[]
+ for record in labels.values():
+ path=record['target'].partition('#')[0]; resource=resources.get(path)
+ role,number=number_info(record['source_label']);record.update(role=role,number=number)
+ headings=[];reason='missing document'
+ if resource and resource.href in soups:
+ headings,reason=_headings(soups[resource.href],record['target'],record['source_label'])
+ if headings:
+ ids=[]
+ for index,heading in enumerate(headings):
+ if not heading.get('id'):
+ heading['id']='title-'+hashlib.sha256((record['target']+str(index)).encode()).hexdigest()[:16]
+ entry_id=f'{resource.href}#{heading["id"]}'
+ ids.append(entry_id)
+ if entry_id not in by_id:
+ _,text,mapping,_,_=FormatExtractor().extract(str(heading))
+ by_id[entry_id]=ManifestEntry(entry_id=entry_id,file_path=resource.href,element_id=heading['id'],
+ original_text=text,placeholders=mapping)
+ entries.append(by_id[entry_id])
+ by_id[entry_id].context='navigation-heading: '+record['source_label']
+ record.update(entry_id=ids[0],entry_ids=ids,heading_id=headings[0]['id'],file_path=resource.href)
+ else:
+ entry_id='navigation:'+record['target']
+ record.update(entry_id=entry_id,heading_id=None,file_path=path,binding_note=reason)
+ by_id[entry_id]=ManifestEntry(entry_id=entry_id,file_path='__navigation__',element_id=entry_id,
+ original_text=record['source_label'],context='navigation-label: '+record['source_label'])
+ entries.append(by_id[entry_id])
+ records.append(record)
+ # Contents headings are not link labels but still need the same heading policy.
+ for href in toc_files:
+ for h in soups[href].find_all(HEADINGS):
+ if plain(h.get_text()).casefold() not in {'contents','table of contents','目录'}: continue
+ if not h.get('id'): h['id']='translation-contents-heading'
+ entry_id=f'{href}#{h["id"]}'
+ if entry_id in by_id: continue
+ _,text,mapping,_,_=FormatExtractor().extract(str(h))
+ entry=ManifestEntry(entry_id=entry_id,file_path=href,element_id=h['id'],original_text=text,
+ placeholders=mapping,context='navigation-heading: Contents')
+ entries.append(entry);by_id[entry_id]=entry
+ for href,soup in soups.items(): resources[posixpath.normpath(unquote(href))].content=str(soup)
+ structure.navigation_titles=records
+ return entries
+
+
+def normalize_heading_html(record, html):
+ """Convert separate Arabic/Roman chapter-number leaves without changing tags."""
+ prefix=ordinal(record)
+ if not prefix: return html
+ soup=parse_html(''+html+'
');root=soup.body.div
+ nodes=[n for n in root.find_all(string=True) if plain(str(n))]
+ 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: 第1部.
+ if (compact=='第' and len(nodes)>=3 and re.fullmatch(r'\d+|[IVXLCDM]+',plain(str(nodes[1])),re.I)
+ 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):
+ 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))
+ return root.decode_contents()
+
+
+def navigation_labels(structure, entries):
+ by_id={e.entry_id:e for e in entries}; translated={}
+ for record in structure.navigation_titles:
+ 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))
+ prefix=ordinal(record)
+ # Standardize punctuation only for the navigation label; the rich
+ # heading can keep its number and subtitle on separate lines.
+ if prefix:
+ label=re.sub(r'^第\s*[零一二三四五六七八九十百千万\d]+\s*[章部篇]',prefix,label)
+ if prefix and label.startswith(prefix):
+ tail=label[len(prefix):].strip(' ::.-–—')
+ label=prefix+(':'+tail if tail else '')
+ translated[record['target']]=label
+ return translated
+
+
+def sync_navigation(structure, entries, mode):
+ translated=navigation_labels(structure,entries)
+ if not translated: return
+ records={r['target']:r for r in structure.navigation_titles}
+ def display(target):
+ target_text=translated.get(target)
+ if not target_text: return None
+ source=records[target]['source_label']
+ return source+' / '+target_text if mode=='bilingual' else target_text
+ for resource in structure.resources.values():
+ if not resource.content: continue
+ modified=False
+ if resource.media_type=='application/x-dtbncx+xml':
+ doc=etree.fromstring(resource.content.encode(),etree.XMLParser(resolve_entities=False,no_network=True))
+ for point in doc.xpath('//*[local-name()="navMap"]//*[local-name()="navPoint"]'):
+ refs=point.xpath('./*[local-name()="content"]/@src')
+ labels=point.xpath('./*[local-name()="navLabel"]/*[local-name()="text"]')
+ text=display(destination(resource.href,refs[0])) if refs else None
+ if labels and text:
+ labels[0].text=text;modified=True
+ if modified: resource.content=etree.tostring(doc,encoding='unicode')
+ elif resource.media_type=='application/xhtml+xml':
+ soup=parse_html(resource.content)
+ own = next((display(r['target']) for r in structure.navigation_titles
+ if r['file_path'] == resource.href and r.get('heading_id') and display(r['target'])), None)
+ if own and soup.head:
+ if not soup.head.title: soup.head.append(soup.new_tag('title'))
+ soup.head.title.string=own; modified=True
+ if not is_toc(soup,resource.href,resource.properties or ''):
+ if modified:
+ resource.content=str(soup); resource.translation_modified=True
+ continue
+ for link in soup.find_all('a',href=True):
+ text=display(destination(resource.href,link['href']))
+ if not text: continue
+ # Preserve the link node, its attributes and styled descendants.
+ nodes=[n for n in link.find_all(string=True) if plain(str(n))]
+ if nodes:
+ nodes[0].replace_with(text)
+ for n in nodes[1:]: n.replace_with('')
+ else: link.append(NavigableString(text))
+ link['data-navigation-translated']='true';modified=True
+ if modified: resource.content=str(soup)
+ if modified: resource.translation_modified=True
diff --git a/src/translation/llm_client.py b/src/translation/llm_client.py
index bcec779..96b8a88 100644
--- a/src/translation/llm_client.py
+++ b/src/translation/llm_client.py
@@ -144,6 +144,7 @@ Output Format:
"""
id_map = {} # short_id -> entry_id
lines = []
+ title_notes = []
for i, item in enumerate(items, 1):
short_id = f"#{i}"
@@ -151,9 +152,18 @@ Output Format:
# Clean text (remove extra whitespace)
text = re.sub(r'\s+', ' ', item.original_text).strip()
+ if item.context and item.context.startswith('navigation-'):
+ title_notes.append(f"{short_id}: {item.context}")
lines.append(f"{short_id}: {text}")
- return id_map, "\n".join(lines)
+ if title_notes:
+ instructions = ("Title context (instructions only; do not copy into translations):\n"
+ "Translate Chapter N as 第N章, Part N as 第N部, and translate the title itself. "
+ "Keep number/subtitle styling tokens in their original regions.\n"
+ + "\n".join(title_notes) + "\n\nSource units:\n")
+ else:
+ instructions = ""
+ return id_map, instructions + "\n".join(lines)
def _parse_short_id_response(self, response: str, id_map: Dict[str, str]) -> Dict[str, str]:
"""
diff --git a/src/translation/manifest_manager.py b/src/translation/manifest_manager.py
index 6fa9d04..d586a89 100644
--- a/src/translation/manifest_manager.py
+++ b/src/translation/manifest_manager.py
@@ -59,6 +59,7 @@ class ManifestManager:
for entry in new_entries:
previous = self._entries_map.get(entry.entry_id)
if previous and previous.original_text == entry.original_text and previous.placeholders == entry.placeholders:
+ previous.context = entry.context
reconciled.append(previous)
else:
reconciled.append(entry)
diff --git a/src/translation/translator_engine.py b/src/translation/translator_engine.py
index fe5842f..0e0c361 100644
--- a/src/translation/translator_engine.py
+++ b/src/translation/translator_engine.py
@@ -26,7 +26,7 @@ class Translator:
self.restorer = FormatRestorer()
def _fingerprint(self, entry, profile):
- data = [entry.original_text, entry.placeholders, self.glossary.fingerprint(),
+ data = [entry.original_text, entry.placeholders, entry.context, self.glossary.fingerprint(),
getattr(self.llm_client, 'model', ''), profile.style_guide if profile else '']
return hashlib.sha256(json.dumps(data, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
diff --git a/tests/test_navigation.py b/tests/test_navigation.py
new file mode 100644
index 0000000..7a06861
--- /dev/null
+++ b/tests/test_navigation.py
@@ -0,0 +1,181 @@
+import asyncio
+import zipfile
+from bs4 import BeautifulSoup
+from ebooklib import epub
+from lxml import etree
+import pytest
+from src.common.data_model import BookStructure
+from src.preprocessing.epub_cleaner import EpubCleaner
+from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.preprocessing.navigation import prepare_navigation, destination, chinese_number
+from src.assembly.restoration_engine import RestorationEngine
+from src.assembly.backfiller import BackfillEngine
+from src.assembly.builder import BilingualBuilder
+from src.qa.epub_audit import audit_epub, compare_audits
+
+
+def fixture(tmp_path):
+ book = epub.EpubBook(); book.set_identifier('titles'); book.set_title('Title QA'); book.set_language('en')
+ chapter = epub.EpubHtml(uid='ch', file_name='Text/ch.xhtml', title='Roots')
+ chapter.content = '''1
+ ROOTS
Opening prose.
+ A caption.
Quoted text.
+ Second paragraph.1
'''
+ part = epub.EpubHtml(uid='part', file_name='Text/part.xhtml', title='Part 1')
+ part.content = 'PART 1
BOY GENIUS
'
+ contents = epub.EpubHtml(uid='contents', file_name='Text/Contents.xhtml', title='Contents')
+ contents.content = '''Contents
- Part 1: Boy Genius
+
- Chapter 1: Roots
'''
+ notes = epub.EpubHtml(uid='notes', file_name='Text/notes.xhtml', title='Notes')
+ notes.content = 'Notes
'
+ for item in [chapter,part,contents,notes,epub.EpubNav(),epub.EpubNcx()]: book.add_item(item)
+ book.toc = [(epub.Section('Part 1: Boy Genius', 'Text/part.xhtml#part'),
+ [epub.Link('Text/ch.xhtml#ch1','Chapter 1: Roots','ch1')]),
+ epub.Link('Text/notes.xhtml#notes','Notes','notes')]
+ book.spine = ['nav',contents,part,chapter,notes]
+ source=tmp_path/'source.epub';epub.write_epub(str(source),book)
+ work=tmp_path/'source'; structure=BookStructure.load(EpubCleaner(source,work).clean())
+ entries=FineGrainedExtractor().extract(structure)
+ prepare_navigation(source,structure,entries)
+ for e in entries:
+ e.translated_text=(e.original_text.replace('ROOTS','根源').replace('PART 1','第一部')
+ .replace('BOY GENIUS','天才少年').replace('Contents','目录').replace('Notes','注释')
+ .replace('Opening prose.','开篇正文。').replace('Second paragraph.','第二段正文。')
+ .replace('A caption.','图注。').replace('Quoted text.','引文。'))
+ assert asyncio.run(RestorationEngine().restore_entries(entries))==len(entries)
+ return source,work,structure,entries
+
+
+@pytest.mark.parametrize('mode',['bilingual','target_only'])
+def test_headings_nav_ncx_contents_share_translation_and_keep_links(tmp_path,mode):
+ source,work,structure,entries=fixture(tmp_path)
+ chapter_titles=[e for e in entries if e.element_id=='ch1']
+ assert len(chapter_titles)==1
+ assert not any('Chapter 1: Roots' in e.original_text for e in entries)
+ asyncio.run(BackfillEngine().backfill(structure,entries,mode))
+ # Repeat safely: no duplicate bilingual paragraph or translated TOC label.
+ asyncio.run(BackfillEngine().backfill(structure,entries,mode))
+ output=tmp_path/(mode+'.epub');BilingualBuilder(work,source).build(structure,output)
+ assert not compare_audits(audit_epub(source),audit_epub(output))
+ with zipfile.ZipFile(source) as before,zipfile.ZipFile(output) as after:
+ prefix=next(n for n in before.namelist() if n.endswith('/nav.xhtml')).removesuffix('nav.xhtml')
+ for relative in ['nav.xhtml','Text/Contents.xhtml']:
+ name=prefix+relative
+ a=BeautifulSoup(before.read(name),'html.parser');b=BeautifulSoup(after.read(name),'html.parser')
+ assert [x.get('href') for x in a.find_all('a')]==[x.get('href') for x in b.find_all('a')]
+ link=next(x for x in b.find_all('a') if 'ch.xhtml' in x.get('href',''))
+ assert '第一章:根源' in link.get_text()
+ assert link.get_text().count('根源')==1
+ assert link.find_parent('ol').find_parent('li') is not None
+ a=etree.fromstring(before.read(prefix+'toc.ncx'));b=etree.fromstring(after.read(prefix+'toc.ncx'))
+ assert a.xpath('//*[local-name()="content"]/@src')==b.xpath('//*[local-name()="content"]/@src')
+ assert a.xpath('//*[local-name()="navPoint"]/@id')==b.xpath('//*[local-name()="navPoint"]/@id')
+ assert '第一章:根源' in ''.join(b.itertext())
+ ch=BeautifulSoup(after.read(prefix+'Text/ch.xhtml'),'html.parser')
+ title=ch.find('h2',class_='translation')
+ assert title.find('span',class_='line').get_text()=='第一章'
+ assert title.find('span',class_='subtitle').get_text()=='根源'
+ assert title['data-translation-role']=='heading'
+ for key in ['opening','second']:
+ p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key)
+ assert p['data-translation-role']=='body'
+ assert 'text-indent' not in p.get('style','')
+ for key in ['caption','quote']:
+ p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key)
+ assert p['data-translation-role']=='other'
+ notes=BeautifulSoup(after.read(prefix+'Text/notes.xhtml'),'html.parser')
+ assert notes.find(id='note') and notes.find('a',href='ch.xhtml#second')
+
+
+def test_uncertain_destination_translates_label_without_overwriting_prose(tmp_path):
+ source,work,structure,entries=fixture(tmp_path)
+ # A link to prose must not be treated as the chapter heading.
+ nav=next(r for r in structure.resources.values() if r.href=='nav.xhtml')
+ nav.content=nav.content.replace('Text/ch.xhtml#ch1','Text/ch.xhtml#opening')
+ entries=FineGrainedExtractor().extract(structure)
+ prepare_navigation(source,structure,entries)
+ record=next(r for r in structure.navigation_titles if r['target'].endswith('#opening'))
+ assert record['heading_id'] is None
+ assert record['binding_note']=='no matching heading'
+ entry=next(e for e in entries if e.entry_id==record['entry_id'])
+ assert entry.file_path=='__navigation__'
+ assert 'Opening prose.' in next(r for r in structure.resources.values() if r.href=='Text/ch.xhtml').content
+
+
+def test_destination_preserves_canonical_fragment_and_ignores_external():
+ assert destination('Nav/toc.xhtml','../Text/ch%20one.xhtml#part%202')=='Text/ch one.xhtml#part 2'
+ assert destination('Text/ch.xhtml','#x')=='Text/ch.xhtml#x'
+ assert destination('toc.xhtml','https://example.com/') is None
+ assert [chinese_number(x) for x in [1,10,11,20,101,1001]]==['一','十','十一','二十','一百零一','一千零一']
+
+
+@pytest.mark.parametrize('markup',[
+ '1
The Bookseller
The Ren Family: 1937–1968
',
+ '1
The Bookseller
The Ren Family: 1937–1968
',
+ 'CHAPTER 1
The Bookseller
The Ren Family: 1937–1968
',
+])
+def test_separate_number_title_subtitle_are_shared_without_merging_dom(tmp_path,markup):
+ from src.preprocessing.navigation import navigation_labels
+ source,work,structure,entries=fixture(tmp_path)
+ chapter=next(r for r in structure.resources.values() if r.href=='Text/ch.xhtml')
+ chapter.content=''+markup+'A long unrelated opening paragraph.
'
+ nav=next(r for r in structure.resources.values() if r.href=='nav.xhtml')
+ nav.content=nav.content.replace('Text/ch.xhtml#ch1','Text/ch.xhtml').replace('Chapter 1: Roots','Chapter 1: The Bookseller | The Ren Family: 1937–1968')
+ entries=FineGrainedExtractor().extract(structure);prepare_navigation(source,structure,entries)
+ record=next(r for r in structure.navigation_titles if r['target']=='Text/ch.xhtml')
+ assert len(record['entry_ids'])==3
+ for e in entries:
+ if e.element_id in {'number','title','subtitle'}:
+ e.translated_text=e.original_text.replace('CHAPTER 1','第1章:').replace('The Bookseller','书商').replace('The Ren Family: 1937–1968','任家:1937—1968')
+ asyncio.run(RestorationEngine().restore_entries(entries))
+ labels=navigation_labels(structure,entries)
+ assert labels['Text/ch.xhtml']=='第一章:书商 任家:1937—1968'
+ asyncio.run(BackfillEngine().backfill(structure,entries,'target_only',allow_partial=True))
+ ch=BeautifulSoup(chapter.content,'html.parser')
+ assert all(ch.find(id=id) for id in ['number','title','subtitle','prose'])
+ assert ch.find(id='prose').get_text()=='A long unrelated opening paragraph.'
+
+
+def test_heading_number_with_spaces_and_colon_normalizes():
+ from src.preprocessing.navigation import normalize_heading_html,number_info
+ assert normalize_heading_html({'role':'chapter','number':1},'第 1 章:根源')=='第一章根源'
+ assert number_info('Chapter Eleven: Rebels')==('chapter',11)
+ assert number_info('Part Two: Coffee')==('part',2)
+
+
+def test_split_styled_digit_and_separate_separator():
+ from src.preprocessing.navigation import normalize_heading_html
+ assert normalize_heading_html({'role':'part','number':1},'第1部:天才少年')=='第一部天才少年'
+ assert normalize_heading_html({'role':'chapter','number':1},'第1章:根源')=='第一章根源'
+
+
+def test_numbered_titles_starting_with_two_or_i_do_not_lose_title_words():
+ from src.preprocessing.navigation import title_identity
+ assert title_identity('Chapter 22: Two Indians Are Better than One')==title_identity('22 TWO INDIANS ARE BETTER THAN ONE')
+ assert title_identity('Chapter 26: I Hope You Go Bust')==title_identity('26 I HOPE YOU GO BUST')
+
+
+def test_manifest_reconciliation_updates_title_context(tmp_path):
+ from src.common.data_model import ManifestEntry
+ from src.translation.manifest_manager import ManifestManager
+ manager=ManifestManager(tmp_path/'manifest.json')
+ old=ManifestEntry(entry_id='x#h',file_path='x',element_id='h',original_text='Roots',context='section',translated_text='根源')
+ manager.add_entries([old])
+ new=old.model_copy(update={'context':'navigation-heading: Chapter 1: Roots','translated_text':None})
+ manager.add_entries([new])
+ assert manager.entries[0].context==new.context
+ assert manager.entries[0].translated_text=='根源'
+
+
+def test_ncx_declared_utf16_is_parsed_without_utf8_assumptions(tmp_path):
+ source,work,structure,entries=fixture(tmp_path)
+ with zipfile.ZipFile(source) as z:
+ items=[(i,z.read(i.filename)) for i in z.infolist()]
+ converted=tmp_path/'utf16.epub'
+ with zipfile.ZipFile(converted,'w') as z:
+ for info,data in items:
+ if info.filename.endswith('.ncx'):
+ data=etree.tostring(etree.fromstring(data),encoding='utf-16',xml_declaration=True)
+ z.writestr(info,data)
+ prepare_navigation(converted,structure,entries)
+ assert any(r['source_label']=='Chapter 1: Roots' for r in structure.navigation_titles)