Fix clickable parent Part navigation in Calibre
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 证据。
|
||||
|
||||
+17
-1
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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('<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
|
||||
|
||||
Reference in New Issue
Block a user