From 498b5d09ce463ef69fe653de634711c529b2f868 Mon Sep 17 00:00:00 2001 From: Kai Tan Date: Thu, 8 Oct 2026 08:09:50 +0800 Subject: [PATCH] Fix clickable parent Part navigation in Calibre --- CHANGELOG.md | 6 ++++ doc/QA_2026-10-08.md | 11 ++++++++ src/common/html.py | 18 +++++++++++- src/preprocessing/navigation.py | 4 ++- tests/test_navigation.py | 49 +++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dff50c..144ad17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - Indent Chinese body paragraphs by two em, including chapter openings and converted div prose; preserve heading/caption/note layouts. diff --git a/doc/QA_2026-10-08.md b/doc/QA_2026-10-08.md index 1a3138f..d7375ff 100644 --- a/doc/QA_2026-10-08.md +++ b/doc/QA_2026-10-08.md @@ -48,3 +48,14 @@ Luna 翻译了 Gambling Man 的 45 个标题单元,45/45 占位符校验通过 渲染期间有一次 Calibre PDF 进程 SIGSEGV,重新生成后的最终两组输出成功。自动报告仍为 visual_review: pending,人工/agent 观察记录单独保存为 visual-review.json;截图生成本身不表示整本视觉验收通过。 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 证据。 diff --git a/src/common/html.py b/src/common/html.py index 0dd7bb7..9cf34c3 100644 --- a/src/common/html.py +++ b/src/common/html.py @@ -14,14 +14,30 @@ def parse_html(content): 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): """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 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'}))): - if container.find_parent(list(SKIP_TAGS)): + if container.find_parent(list(SKIP_TAGS)) or container.find_parent('nav'): continue has_blocks = container.find(list(BLOCK_TAGS)) is not None if container.name != 'body' and not has_blocks: diff --git a/src/preprocessing/navigation.py b/src/preprocessing/navigation.py index 625b6b6..19b1c2a 100644 --- a/src/preprocessing/navigation.py +++ b/src/preprocessing/navigation.py @@ -10,7 +10,7 @@ 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.html import parse_html, normalize_navigation_markup from src.common.data_model import ManifestEntry from src.preprocessing.format_extractor import FormatExtractor 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.""" soups={r.href:parse_html(r.content) for r in structure.resources.values() 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()} labels={} def collect(document, href, label): diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 7a06861..f75af96 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -67,6 +67,10 @@ def test_headings_nav_ncx_contents_share_translation_and_keep_links(tmp_path,mod assert '第一章:根源' in link.get_text() assert link.get_text().count('根源')==1 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')) 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') @@ -179,3 +183,48 @@ def test_ncx_declared_utf16_is_parsed_without_utf8_assumptions(tmp_path): z.writestr(info,data) prepare_navigation(converted,structure,entries) 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('') + 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