"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is purely DOM order, no rendering needed) so position anchors are comparable across the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture. innerFraction on the web is defined via getBoundingClientRect() pixel geometry, which has no TUI equivalent — a terminal has no font metrics/reflow the way a browser does. We approximate it with row-based geometry from Rich's own text wrapping (see reader.py), which is close enough because the server's "furthest wins" comparison (_progress_is_further) only falls back to comparing fractions when two anchors share the exact same block index — block index is the primary, exactly-reproducible signal. Footnote references are detected with the same heuristic as app.js's _looksLikeFootnoteLink: wrapped in/wrapping a , a class name containing note/footnote/fn, or an epub:type="noteref" attribute. """ from __future__ import annotations import re import warnings from dataclasses import dataclass, field from pathlib import Path import ebooklib from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning from ebooklib import epub # EPUB content documents are XHTML; treating them as HTML (matching app.js's # `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638) # is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it. # lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which # matters here: some real-world EPUBs run to tens of thousands of blocks. warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) _PARSER = "lxml" # Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, # blockquote, dt, dd, figcaption, div:not(:has(*))' _BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"} # Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped # via regex before DOMParser even runs there; decomposing here is equivalent). _STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"] # Matches app.js's _looksLikeFootnoteLink's class-name check. _FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b") # Anchor format the server accepts (books/views.py:save_progress); anything else # is silently discarded back to ''. _ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?") @dataclass class FootnoteRef: marker: str # visible text of the reference link, e.g. "1" offset: int # character offset into the block's text where the marker sits target_id: str # fragment id to resolve against block ids @dataclass class Block: text: str tag: str chapter_index: int id: str | None = None footnotes: list[FootnoteRef] = field(default_factory=list) # Every id found anywhere in this block's subtree, not just on the block # element itself — footnote *targets* are frequently an or # similar nested a level or two inside the actual containing paragraph # (see showFootnotePopover's `.closest('.footnote') || .parentElement` # walk-up in app.js), so a target id resolves to "the block containing # it" rather than requiring the id to sit on the block tag itself. ids: list[str] = field(default_factory=list) @dataclass class FlatBook: id: str title: str author: str path: Path blocks: list[Block] chapter_titles: list[str] chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block footnote_targets: dict[str, int] # fragment id -> block index of its content def _is_leaf_div(tag: Tag) -> bool: return tag.name == "div" and tag.find(True) is None def _looks_like_footnote_link(el: Tag, href: str) -> bool: if "#" not in href: return False cls = " ".join(el.get("class") or []).lower() if _FOOTNOTE_CLASS_RE.search(cls): return True epub_type = (el.get("epub:type") or "").lower() if "noteref" in epub_type: return True return el.find_parent("sup") is not None or el.find("sup") is not None def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None: for child in el.children: if isinstance(child, NavigableString): out.append(str(child)) elif isinstance(child, Tag): if child.name == "a": href = child.get("href") or "" if _looks_like_footnote_link(child, href): marker = child.get_text(" ", strip=True) offset = len("".join(out)) target_id = href.split("#", 1)[1] if "#" in href else "" footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id)) out.append(marker) continue _walk_text(child, footnotes, out) def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]: soup = BeautifulSoup(html, _PARSER) for name in _STRIP_TAGS: for el in soup.find_all(name): el.decompose() blocks: list[Block] = [] for el in soup.find_all(True): if el.name in _BLOCK_TAGS or _is_leaf_div(el): footnotes: list[FootnoteRef] = [] parts: list[str] = [] _walk_text(el, footnotes, parts) text = re.sub(r"\s+", " ", "".join(parts)).strip() ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id] blocks.append( Block( text=text, tag=el.name, chapter_index=chapter_index, id=el.get("id"), footnotes=footnotes, ids=ids, ) ) return blocks def load_flat_book(path: Path) -> FlatBook: from .epub import _book_id # reuse the same path-hash id scheme raw = epub.read_epub(str(path), options={"ignore_ncx": True}) title_meta = raw.get_metadata("DC", "title") title = title_meta[0][0] if title_meta else path.stem author_meta = raw.get_metadata("DC", "creator") author = author_meta[0][0] if author_meta else "Unbekannt" blocks: list[Block] = [] chapter_titles: list[str] = [] chapter_start_block: list[int] = [] # app.js's parseEpub() includes every spine itemref unconditionally — no # `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote # targets are commonly parked in a linear="no" document. Skipping it here # would both break footnote-target resolution and shift blockIndex # numbering out of sync with the web reader for every block after it. for idref, _linear in raw.spine: item = raw.get_item_with_id(idref) if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT: continue chapter_index = len(chapter_titles) chapter_blocks = _extract_blocks(item.get_content(), chapter_index) chapter_start_block.append(len(blocks)) first_text = next((b.text for b in chapter_blocks if b.text), None) chapter_titles.append((first_text or item.get_name())[:60]) blocks.extend(chapter_blocks) footnote_targets: dict[str, int] = {} for idx, b in enumerate(blocks): for tag_id in b.ids: footnote_targets.setdefault(tag_id, idx) return FlatBook( id=_book_id(path), title=title, author=author, path=path, blocks=blocks, chapter_titles=chapter_titles, chapter_start_block=chapter_start_block, footnote_targets=footnote_targets, ) def format_anchor(block_index: int, inner_fraction: float) -> str: inner_fraction = max(0.0, min(1.0, inner_fraction)) return f"{block_index}:{inner_fraction:.6f}" def parse_anchor(anchor: str) -> tuple[int, float] | None: if not anchor or not _ANCHOR_RE.fullmatch(anchor): return None block_str, _, frac_str = anchor.partition(":") return int(block_str), float(frac_str) def anchor_is_further(new_anchor: str, old_anchor: str) -> bool: """Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js).""" new_parts = parse_anchor(new_anchor) old_parts = parse_anchor(old_anchor) if new_parts is None or old_parts is None: return bool(new_anchor) and not old_anchor nb, ni = new_parts ob, oi = old_parts return ni >= oi if nb == ob else nb >= ob