#!/usr/bin/env python3 """Clean up an EPUB table of contents: strip section numbers from labels and keep pandoc's nested chapter/section hierarchy in nav.xhtml and toc.ncx.""" import os import re import sys import tempfile import zipfile from xml.etree import ElementTree as ET XHTML = "http://www.w3.org/1999/xhtml" EPUB = "http://www.idpf.org/2007/ops" NCX = "http://www.daisy.org/z3986/2005/ncx/" ET.register_namespace("", XHTML) ET.register_namespace("epub", EPUB) def direct_children(element, tag): return [child for child in element if child.tag == tag] def toc_item(identifier, href, label): item = ET.Element(f"{{{XHTML}}}li", {"id": identifier}) link = ET.SubElement(item, f"{{{XHTML}}}a", {"href": href}) link.text = label return item def flatten_nav(data, title_label, toc_label, rtl=False, language="ar"): root = ET.fromstring(data) if rtl: root.set("dir", "rtl") root.set("{http://www.w3.org/XML/1998/namespace}lang", language) nav = next( element for element in root.iter(f"{{{XHTML}}}nav") if element.get(f"{{{EPUB}}}type") == "toc" ) top_list = next(child for child in nav if child.tag == f"{{{XHTML}}}ol") # 先删掉所有 section-header-number(让目录只显示标题文字,不带 1.1 / 1.1.1 编号) # 关键:pandoc 把编号放在 1.1 里, # 标题文字在 span.tail(span 之后的文本节点)。直接 remove(span) 会把 tail 也丢掉, # 导致 a 标签变空。所以要先保留 tail 文本。 for a in root.iter(f"{{{XHTML}}}a"): for span in list(a): classes = (span.get("class") or "").split() if "section-header-number" in classes: # 保留 span 后的文本(标题正文)到 a 的 text tail_text = (span.tail or "").lstrip() if tail_text: if a.text: a.text = a.text + tail_text else: a.text = tail_text a.remove(span) for group in direct_children(top_list, f"{{{XHTML}}}li"): classes = group.get("class", "").split() if "chapter-group" not in classes: classes.append("chapter-group") group.set("class", " ".join(classes)) # 保留 pandoc 原始的 H2→H3→H4 嵌套结构。 # 侧边栏靠缩进显示层级。 for nested_list in direct_children(group, f"{{{XHTML}}}ol"): nested_list.set("class", "toc subsections") for sub_li in direct_children(nested_list, f"{{{XHTML}}}li"): sub_classes = sub_li.get("class", "").split() if "subsection" not in sub_classes: sub_classes.append("subsection") sub_li.set("class", " ".join(sub_classes)) top_list.insert(0, toc_item("toc-li-contents", "nav.xhtml#toc", toc_label)) top_list.insert(0, toc_item("toc-li-title-page", "text/title_page.xhtml", title_label)) ET.register_namespace("", XHTML) ET.register_namespace("epub", EPUB) return ET.tostring(root, encoding="utf-8", xml_declaration=True) def set_page_progression(data): root = ET.fromstring(data) namespace = root.tag.partition("}")[0].lstrip("{") spine_tag = f"{{{namespace}}}spine" if namespace else "spine" spine = root.find(spine_tag) if spine is None: raise RuntimeError("EPUB package spine not found") spine.set("page-progression-direction", "rtl") return ET.tostring(root, encoding="utf-8", xml_declaration=True) def set_xhtml_direction(data, language="ar"): root = ET.fromstring(data) root.set("dir", "rtl") root.set("lang", language) root.set("{http://www.w3.org/XML/1998/namespace}lang", language) for name in ("pre", "code", "kbd", "samp"): for element in root.iter(f"{{{XHTML}}}{name}"): element.set("dir", "ltr") ET.register_namespace("", XHTML) return ET.tostring(root, encoding="utf-8", xml_declaration=True) def flatten_ncx(data, title_label, toc_label): ET.register_namespace("", NCX) root = ET.fromstring(data) nav_map = root.find(f"{{{NCX}}}navMap") # Apple Books 侧边栏读 toc.ncx(即使 EPUB3 也会回退到 ncx)。 # 保留 pandoc 完整嵌套层级 + 删掉编号前缀(让侧边栏只显示标题文字)。 # 1. 找到/补上「扉页」入口 title_point = None for point in direct_children(nav_map, f"{{{NCX}}}navPoint"): content = point.find(f"{{{NCX}}}content") if content is not None and content.get("src") == "text/title_page.xhtml": title_point = point label = point.find(f"{{{NCX}}}navLabel/{{{NCX}}}text") if label is not None: label.text = title_label break # 2. 删掉每个 navLabel 里的编号前缀(如 "1.1 现代 Agent" → "现代 Agent") for label_text in root.iter(f"{{{NCX}}}text"): if label_text.text: # 去掉开头的 "1.1.1 " 或 "1.1 " 或 "1 " 这种编号 label_text.text = re.sub(r'^\d+(\.\d+)*\s+', '', label_text.text) # 3. 保留 pandoc 完整嵌套(不删任何 navPoint) # 4. 统一按深度优先顺序重排 playOrder。 # NCX 规范要求 playOrder;缺失或乱序时部分阅读器(如 Apple Books) # 会把嵌套层级拍平显示。 counter = [0] def assign_play_order(point): counter[0] += 1 point.set("playOrder", str(counter[0])) for child in direct_children(point, f"{{{NCX}}}navPoint"): assign_play_order(child) for point in direct_children(nav_map, f"{{{NCX}}}navPoint"): assign_play_order(point) depth = root.find(f".//{{{NCX}}}meta[@name='dtb:depth']") if depth is not None: depth.set("content", "3") return ET.tostring(root, encoding="utf-8", xml_declaration=True) def rewrite_epub(path, title_label, toc_label, rtl=False, language="ar"): replacements = {} with zipfile.ZipFile(path) as source: names = set(source.namelist()) if "EPUB/nav.xhtml" not in names or "EPUB/toc.ncx" not in names: raise RuntimeError("EPUB navigation files not found") replacements["EPUB/nav.xhtml"] = flatten_nav( source.read("EPUB/nav.xhtml"), title_label, toc_label, rtl=rtl, language=language ) replacements["EPUB/toc.ncx"] = flatten_ncx( source.read("EPUB/toc.ncx"), title_label, toc_label ) if rtl: package_names = [ name for name in names if name.endswith(".opf") ] if len(package_names) != 1: raise RuntimeError("Expected exactly one EPUB package document") package_name = package_names[0] replacements[package_name] = set_page_progression(source.read(package_name)) for name in names: if name.endswith(".xhtml"): replacements[name] = set_xhtml_direction( replacements.get(name, source.read(name)), language=language ) directory = os.path.dirname(os.path.abspath(path)) descriptor, temporary_path = tempfile.mkstemp(suffix=".epub", dir=directory) os.close(descriptor) try: with zipfile.ZipFile(temporary_path, "w") as target: for info in source.infolist(): target.writestr(info, replacements.get(info.filename, source.read(info.filename))) os.replace(temporary_path, path) except BaseException: os.unlink(temporary_path) raise if __name__ == "__main__": if len(sys.argv) not in (4, 5, 6): raise SystemExit( f"Usage: {sys.argv[0]} BOOK.epub TITLE_LABEL TOC_LABEL [rtl [LANGUAGE]]" ) rtl = len(sys.argv) >= 5 and sys.argv[4].lower() == "rtl" if len(sys.argv) == 6 and not rtl: raise SystemExit( f"Usage: {sys.argv[0]} BOOK.epub TITLE_LABEL TOC_LABEL [rtl [LANGUAGE]]" ) language = sys.argv[5] if len(sys.argv) == 6 else "ar" rewrite_epub( sys.argv[1], sys.argv[2], sys.argv[3], rtl=rtl, language=language )