"""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 bs4.element import PreformattedString 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") # JavaScript's `\s`, not Python's (which also matches \x1c-\x1f and \x85 but not ): a # block's text is cut and measured the way the web reader does. _JS_SPACE_RE = re.compile("[ \t\n\v\f\r   - 

   ]+") # 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, PreformattedString): continue # comments, CDATA, declarations: not text (textContent skips them) 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 = _JS_SPACE_RE.sub(" ", child.get_text("")).strip(" ") 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 = _JS_SPACE_RE.sub(" ", "".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