Fix clickable parent Part navigation in Calibre

This commit is contained in:
Kai Tan
2026-10-08 08:09:50 +08:00
parent a158b4e26c
commit 498b5d09ce
5 changed files with 86 additions and 2 deletions
+6
View File
@@ -1,3 +1,9 @@
## 2026-10-08 — clickable Part navigation
- Keep parent Part links directly under NAV list items so Calibre resolves their destinations.
- Repair generated wrappers in older preprocessing caches without retranslating.
- Add Calibre TOC integration checks for Chinese and bilingual output.
## 2026-10-08 — Chinese typography and shared navigation titles ## 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. - Indent Chinese body paragraphs by two em, including chapter openings and converted div prose; preserve heading/caption/note layouts.
+11
View File
@@ -48,3 +48,14 @@ Luna 翻译了 Gambling Man 的 45 个标题单元,45/45 占位符校验通过
渲染期间有一次 Calibre PDF 进程 SIGSEGV,重新生成后的最终两组输出成功。自动报告仍为 visual_review: pending,人工/agent 观察记录单独保存为 visual-review.json;截图生成本身不表示整本视觉验收通过。 渲染期间有一次 Calibre PDF 进程 SIGSEGV,重新生成后的最终两组输出成功。自动报告仍为 visual_review: pending,人工/agent 观察记录单独保存为 visual-review.json;截图生成本身不表示整本视觉验收通过。
OCR、扫描页图文提取、版面中间结构与可点击目录的后续大版本方案见 `doc/OCR_EPUB_ROADMAP.md`。 OCR、扫描页图文提取、版面中间结构与可点击目录的后续大版本方案见 `doc/OCR_EPUB_ROADMAP.md`。
## 第三轮:Part 父级目录跳转修复
用户反馈点击“第 X 部:XX”时报“目的不存在”。原因是预处理给带子目录的 Part 链接添加了生成的 span,形成 `li > span > a`;href 本身存在,但 Calibre 将该父项解析成无目标的目录分组。此前检查了链接地址和目录层级,遗漏了阅读器对父项目标的实际解析。
现在 NAV 保留直接的 `li > a + ol` 结构,旧缓存中的生成包装也会在导航预处理时移除,保留链接内部的原有强调样式。已有译文可直接重新封装,无需重新调用翻译模型;已生成的旧 EPUB 需要重新构建。
- 45 项测试通过,新增旧缓存修复及 Calibre 实际解析的中文/双语回归;未安装 Calibre 时这两项集成测试明确跳过。
- 11 本书再次往返检查:格式还原失败 0,新增结构问题 0。
- Calibre 解析修复后的 Gambling Man 中文和双语样例,五个 Part 的目标均存在;在原生阅读器中实际点击第一部和第五部,均跳转到对应部标题,无错误弹窗。
- 新样例位于 `.work/part-link-fixed/`,沿用已验证译文,仍为 55/1,582 个单元的局部翻译;旧的 `.work/navigation-final-v4/` 仅保留为此前 QA 证据。
+17 -1
View File
@@ -14,14 +14,30 @@ def parse_html(content):
return BeautifulSoup(re.sub(r'^\s*<\?xml[^>]*\?>', '', content), 'html5lib') return BeautifulSoup(re.sub(r'^\s*<\?xml[^>]*\?>', '', content), 'html5lib')
def normalize_navigation_markup(soup):
"""Remove only our generated wrappers around nested NAV labels.
Reader TOC parsers expect li > a + ol. A generated li > span > a
retains a valid href but is interpreted as an unlinked section by Calibre.
This also upgrades source structures cached by earlier versions.
"""
for wrapper in list(soup.find_all('span', attrs={'data-translation-unit': 'true'})):
parent = wrapper.parent
if (parent and parent.name == 'li' and wrapper.find_parent('nav')
and parent.find('ol', recursive=False)
and wrapper.find('a', href=True, recursive=False)):
wrapper.unwrap()
def translation_targets(soup): def translation_targets(soup):
"""Choose disjoint leaf blocks; wrap mixed direct text without renaming containers. """Choose disjoint leaf blocks; wrap mixed direct text without renaming containers.
A li containing p must never be extracted twice. Text before a nested list or A li containing p must never be extracted twice. Text before a nested list or
inside an otherwise bare body still needs its own unit. inside an otherwise bare body still needs its own unit.
""" """
normalize_navigation_markup(soup)
for container in list(soup.find_all(list(TARGET_TAGS | {'body'}))): for container in list(soup.find_all(list(TARGET_TAGS | {'body'}))):
if container.find_parent(list(SKIP_TAGS)): if container.find_parent(list(SKIP_TAGS)) or container.find_parent('nav'):
continue continue
has_blocks = container.find(list(BLOCK_TAGS)) is not None has_blocks = container.find(list(BLOCK_TAGS)) is not None
if container.name != 'body' and not has_blocks: if container.name != 'body' and not has_blocks:
+3 -1
View File
@@ -10,7 +10,7 @@ import zipfile
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
from bs4 import NavigableString from bs4 import NavigableString
from lxml import etree from lxml import etree
from src.common.html import parse_html from src.common.html import parse_html, normalize_navigation_markup
from src.common.data_model import ManifestEntry from src.common.data_model import ManifestEntry
from src.preprocessing.format_extractor import FormatExtractor from src.preprocessing.format_extractor import FormatExtractor
from src.assembly.package_writer import package_path from src.assembly.package_writer import package_path
@@ -134,6 +134,8 @@ def prepare_navigation(original, structure, entries):
"""Enrich the manifest with canonical heading entries, never translate TOC duplicates.""" """Enrich the manifest with canonical heading entries, never translate TOC duplicates."""
soups={r.href:parse_html(r.content) for r in structure.resources.values() soups={r.href:parse_html(r.content) for r in structure.resources.values()
if r.media_type=='application/xhtml+xml' and r.content} if r.media_type=='application/xhtml+xml' and r.content}
for soup in soups.values():
normalize_navigation_markup(soup)
resources={posixpath.normpath(unquote(r.href)):r for r in structure.resources.values()} resources={posixpath.normpath(unquote(r.href)):r for r in structure.resources.values()}
labels={} labels={}
def collect(document, href, label): def collect(document, href, label):
+49
View File
@@ -67,6 +67,10 @@ def test_headings_nav_ncx_contents_share_translation_and_keep_links(tmp_path,mod
assert '第一章:根源' in link.get_text() assert '第一章:根源' in link.get_text()
assert link.get_text().count('根源')==1 assert link.get_text().count('根源')==1
assert link.find_parent('ol').find_parent('li') is not None assert link.find_parent('ol').find_parent('li') is not None
if relative == 'nav.xhtml':
parent_link = b.find('a', href='Text/part.xhtml#part')
assert parent_link.parent.name == 'li'
assert parent_link.parent.find('ol', recursive=False) is not None
a=etree.fromstring(before.read(prefix+'toc.ncx'));b=etree.fromstring(after.read(prefix+'toc.ncx')) 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()="content"]/@src')==b.xpath('//*[local-name()="content"]/@src')
assert a.xpath('//*[local-name()="navPoint"]/@id')==b.xpath('//*[local-name()="navPoint"]/@id') assert a.xpath('//*[local-name()="navPoint"]/@id')==b.xpath('//*[local-name()="navPoint"]/@id')
@@ -179,3 +183,48 @@ def test_ncx_declared_utf16_is_parsed_without_utf8_assumptions(tmp_path):
z.writestr(info,data) z.writestr(info,data)
prepare_navigation(converted,structure,entries) prepare_navigation(converted,structure,entries)
assert any(r['source_label']=='Chapter 1: Roots' for r in structure.navigation_titles) assert any(r['source_label']=='Chapter 1: Roots' for r in structure.navigation_titles)
def test_navigation_generated_wrappers_are_repaired_but_publisher_styles_remain():
from src.common.html import parse_html, translation_targets
soup=parse_html('<nav epub:type="toc"><ol><li><span data-translation-unit="true" id="tr-old"><a href="part.xhtml"><em>Part 1</em></a></span><ol><li><a href="ch.xhtml">Chapter 1</a></li></ol></li></ol></nav>')
translation_targets(soup)
link=soup.find('a',href='part.xhtml')
assert link.parent.name=='li'
assert link.em.get_text()=='Part 1'
assert link.parent.find('ol',recursive=False)
translation_targets(soup)
assert link.parent.name=='li'
def test_prepare_navigation_upgrades_cached_wrapped_part_link(tmp_path):
source,work,structure,entries=fixture(tmp_path)
nav=next(r for r in structure.resources.values() if r.href=='nav.xhtml')
soup=BeautifulSoup(nav.content,'html.parser')
link=soup.find('a',href='Text/part.xhtml#part')
wrapper=soup.new_tag('span',attrs={'data-translation-unit':'true','id':'tr-old'})
link.wrap(wrapper);nav.content=str(soup)
prepare_navigation(source,structure,entries)
assert BeautifulSoup(nav.content,'html.parser').find('a',href='Text/part.xhtml#part').parent.name=='li'
@pytest.mark.parametrize('mode',['target_only','bilingual'])
def test_calibre_resolves_parent_part_destination(tmp_path,mode):
import json
import shutil
import subprocess
calibre=shutil.which('calibre-debug')
if not calibre: pytest.skip('Calibre integration check requires calibre-debug')
source,work,structure,entries=fixture(tmp_path)
asyncio.run(BackfillEngine().backfill(structure,entries,mode))
output=tmp_path/'reader.epub';BilingualBuilder(work,source).build(structure,output)
script=('import json,sys; from calibre.ebooks.oeb.polish.container import get_container; '
'from calibre.ebooks.oeb.polish.toc import get_toc; '
'print(json.dumps(get_toc(get_container(sys.argv[-1])).as_dict))')
result=subprocess.run([calibre,'-c',script,'--',str(output)],capture_output=True,text=True,check=True,timeout=30)
toc=json.loads(result.stdout)
part=next(n for n in toc['children'] if n['children'])
assert part['dest'].endswith('Text/part.xhtml')
assert part['frag']=='part'
assert part['dest_exists'] is True
assert part['children'][0]['dest_exists'] is True