Files
my_bookmate/backend/app/epub/toc.py
T

216 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import zipfile
import re
from lxml import etree
from dataclasses import dataclass, field
@dataclass
class Heading:
title: str
level: int
href: str
cfi: str = ""
children: list["Heading"] = field(default_factory=list)
NS = {
"opf": "http://www.idpf.org/2007/opf",
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
"xhtml": "http://www.w3.org/1999/xhtml",
}
def parse_toc_tree(epub_path: str) -> list[Heading]:
"""从 EPUB 中提取完整目录树,4 级降级:nav.xhtml -> ncx -> spine -> h 标签"""
with zipfile.ZipFile(epub_path) as zf:
opf_path, opf_root = _read_opf(zf)
opf_dir = _dirname(opf_path)
headings = _try_nav(zf, opf_root, opf_dir)
if headings:
return headings
headings = _try_ncx(zf, opf_root, opf_dir)
if headings:
return headings
headings = _try_spine(zf, opf_root, opf_dir)
if headings:
return headings
return _fallback_spine(zf, opf_root, opf_dir)
def _read_opf(zf: zipfile.ZipFile) -> tuple[str, etree._Element]:
container = etree.fromstring(zf.read("META-INF/container.xml"))
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
opf_path = rootfile.attrib["full-path"] if rootfile is not None else "OEBPS/content.opf"
opf_root = etree.fromstring(zf.read(opf_path))
return opf_path, opf_root
def _try_nav(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""EPUB3: nav.xhtml(最优先,有原生层级)"""
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)]
for idref in spine_ids:
href = manifest.get(idref, "")
if "nav" in href.lower() or "toc" in href.lower():
nav_path = _resolve_path(opf_dir, href)
try:
return _parse_nav_xhtml(zf, nav_path, opf_dir)
except Exception:
continue
return None
def _parse_nav_xhtml(zf: zipfile.ZipFile, nav_path: str, opf_dir: str) -> list[Heading]:
html = etree.fromstring(zf.read(nav_path))
ol = html.find(".//{http://www.w3.org/1999/xhtml}nav[@{http://www.w3.org/1999/xhtml}type='toc']//{http://www.w3.org/1999/xhtml}ol")
if ol is None:
ol = html.find(".//{http://www.w3.org/1999/xhtml}nav//{http://www.w3.org/1999/xhtml}ol")
if ol is None:
return []
return _parse_ol(ol, opf_dir, 1)
def _parse_ol(ol, opf_dir: str, base_level: int) -> list[Heading]:
result = []
for li in ol.findall("{http://www.w3.org/1999/xhtml}li"):
a = li.find("{http://www.w3.org/1999/xhtml}a")
if a is None:
continue
title = (a.text or "").strip()
href = a.attrib.get("href", "")
heading = Heading(title=title, level=base_level, href=href)
sub_ol = li.find("{http://www.w3.org/1999/xhtml}ol")
if sub_ol is not None:
heading.children = _parse_ol(sub_ol, opf_dir, base_level + 1)
result.append(heading)
return result
def _try_ncx(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""EPUB2: toc.ncx"""
spine = opf.find("opf:spine", NS)
if spine is None:
return None
ncx_id = spine.attrib.get("toc", "")
if not ncx_id:
return None
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
ncx_href = manifest.get(ncx_id)
if not ncx_href:
return None
ncx_path = _resolve_path(opf_dir, ncx_href)
try:
ncx_root = etree.fromstring(zf.read(ncx_path))
return _parse_ncx_navmap(ncx_root, opf_dir)
except Exception:
return None
def _parse_ncx_navmap(ncx: etree._Element, opf_dir: str) -> list[Heading]:
navmap = ncx.find("ncx:navMap", NS)
if navmap is None:
return []
return _parse_ncx_points(navmap, opf_dir, 1)
def _parse_ncx_points(parent, opf_dir: str, base_level: int) -> list[Heading]:
result = []
for np in parent.findall("ncx:navPoint", NS):
label = np.find("ncx:navLabel/ncx:text", NS)
content = np.find("ncx:content", NS)
title = _text(label) if label is not None else ""
href = content.attrib.get("src", "") if content is not None else ""
heading = Heading(title=title, level=base_level, href=href)
heading.children = _parse_ncx_points(np, opf_dir, base_level + 1)
result.append(heading)
return result
def _try_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None:
"""退化方案:每个 spine 文件当作一章"""
manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)}
spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)]
headings = []
for i, idref in enumerate(spine_ids):
href = manifest.get(idref, "")
if not href:
continue
try:
path = _resolve_path(opf_dir, href)
html = zf.read(path)
soup = etree.fromstring(html)
title_tag = soup.find(".//{http://www.w3.org/1999/xhtml}title")
title = _text(title_tag) if title_tag is not None else f"Section {i + 1}"
headings.append(Heading(title=title, level=1, href=href))
except Exception:
continue
return headings if headings else None
def _fallback_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading]:
"""最后兜底:直接读所有 xhtml 的 h1-h3"""
return _try_spine(zf, opf, opf_dir) or []
def flatten_headings(headings: list[Heading], level_offset: int = 0) -> list[dict]:
"""将树形目录展平为列表,包含 heading_path"""
result = []
for h in headings:
result.append({
"title": h.title,
"level": h.level + level_offset,
"href": h.href,
})
result.extend(flatten_headings(h.children, level_offset))
return result
def build_heading_paths(headings: list[dict], order_idx: int) -> list[dict]:
"""为每个扁平条目构建完整 heading_path(如 '第3章 > 3.2 前向传播')"""
stack = []
result = []
for h in headings:
while stack and stack[-1]["level"] >= h["level"]:
stack.pop()
stack.append(h)
path = " > ".join(s["title"] for s in stack)
result.append({**h, "heading_path": path, "order_idx": order_idx})
order_idx += 1
return result
def _resolve_path(opf_dir: str, href: str) -> str:
if href.startswith("/"):
return href.lstrip("/")
parts = [p for p in opf_dir.split("/") if p] if opf_dir else []
parts.append(href)
while ".." in parts:
idx = parts.index("..")
if idx > 0:
parts.pop(idx)
parts.pop(idx - 1)
else:
parts.pop(idx)
return "/".join(parts)
def _dirname(path: str) -> str:
parts = path.rsplit("/", 1)
return parts[0] if len(parts) > 1 else ""
def _text(el) -> str:
return "".join(el.itertext()).strip() if el is not None else ""
def href_to_fragment(href: str) -> str:
"""从 href 中提取 file 和 fragment 部分"""
if "#" in href:
return href.split("#", 1)[1]
return ""