TUI: Blocktext wie der Web-Reader, Tests und Anker-Parität

HTML-Kommentare zählten als Text (1435 Blöcke in einem Testbuch), Leerraum wurde mit
Pythons statt JavaScripts \s zusammengezogen, Fußnotenmarker bekamen Leerzeichen.
Neu: tests/ (Blockliste, Anker, App-Start mit Textual-Pilot) und tools/parity/ – vergleicht
Anzahl und Tags der Blöcke mit parseEpub aus dem Web-Reader in headless Chromium
(6 Bücher, bis 25.798 Blöcke: gleich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-26 12:40:20 +01:00
parent 58f59476d8
commit 7095e176b7
7 changed files with 235 additions and 2 deletions

View file

@ -115,6 +115,27 @@ deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße —
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
auf einmal. auf einmal.
## Tests und Anker-Parität mit dem Web-Reader
```bash
pip install -e . pytest
pytest tests # Blockliste, Anker, App-Start (Textual-Pilot)
```
Die Position ist ein Block-Index, den alle Clients gleich zählen müssen. Ob die TUI das tut,
prüft `tools/parity/` gegen den echten Web-Code (`parseEpub` aus `static/js/app/40-epub.js`
in headless Chromium):
```bash
NODE_PATH=<diora-web>/node_modules PLAYWRIGHT_BROWSERS_PATH=... node tools/parity/web_blocks.cjs <diora-web> web.json buch1.epub buch2.epub
python tools/parity/compare_blocks.py web.json
```
Stand 2026-10-26 (6 Bücher, bis 25.798 Blöcke): Anzahl und Tags aller Blöcke gleich. Bekannte
Textabweichungen ohne Wirkung auf Anker: lxml liest `<h1/>` (selbstschließend) als leer, der
HTML5-Parser des Browsers als offenen Block; vereinzelt ein Leerzeichen mehr zwischen
verschachtelten Blöcken.
## Grenzen der aktuellen Version ## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, - Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,

View file

@ -25,6 +25,7 @@ from pathlib import Path
import ebooklib import ebooklib
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
from bs4.element import PreformattedString
from ebooklib import epub from ebooklib import epub
# EPUB content documents are XHTML; treating them as HTML (matching app.js's # EPUB content documents are XHTML; treating them as HTML (matching app.js's
@ -43,6 +44,9 @@ _BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt"
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"] _STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
# Matches app.js's _looksLikeFootnoteLink's class-name check. # Matches app.js's _looksLikeFootnoteLink's class-name check.
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b") _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 # Anchor format the server accepts (books/views.py:save_progress); anything else
# is silently discarded back to ''. # is silently discarded back to ''.
@ -102,13 +106,15 @@ def _looks_like_footnote_link(el: Tag, href: str) -> bool:
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None: def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
for child in el.children: for child in el.children:
if isinstance(child, PreformattedString):
continue # comments, CDATA, declarations: not text (textContent skips them)
if isinstance(child, NavigableString): if isinstance(child, NavigableString):
out.append(str(child)) out.append(str(child))
elif isinstance(child, Tag): elif isinstance(child, Tag):
if child.name == "a": if child.name == "a":
href = child.get("href") or "" href = child.get("href") or ""
if _looks_like_footnote_link(child, href): if _looks_like_footnote_link(child, href):
marker = child.get_text(" ", strip=True) marker = _JS_SPACE_RE.sub(" ", child.get_text("")).strip(" ")
offset = len("".join(out)) offset = len("".join(out))
target_id = href.split("#", 1)[1] if "#" in href else "" target_id = href.split("#", 1)[1] if "#" in href else ""
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id)) footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
@ -129,7 +135,7 @@ def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
footnotes: list[FootnoteRef] = [] footnotes: list[FootnoteRef] = []
parts: list[str] = [] parts: list[str] = []
_walk_text(el, footnotes, parts) _walk_text(el, footnotes, parts)
text = re.sub(r"\s+", " ", "".join(parts)).strip() 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] 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( blocks.append(
Block( Block(

44
tui/tests/conftest.py Normal file
View file

@ -0,0 +1,44 @@
"""Tests run against a throwaway home: progress, config and caches are module-level
paths from platformdirs, read at import time, so the environment is set before anything
from diora_tui is imported."""
import os
import tempfile
import zipfile
from pathlib import Path
_HOME = tempfile.mkdtemp(prefix="diora-tui-test-")
for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
os.environ[var] = _HOME
os.environ["HOME"] = _HOME
import pytest # noqa: E402
CONTAINER = """<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"""
def make_epub(path: Path, chapters: list[str], title: str = "Testbuch") -> Path:
"""A minimal EPUB whose spine is `chapters` (each one the full XHTML of a document)."""
items = "".join(f'<item id="c{i}" href="c{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(len(chapters)))
spine = "".join(f'<itemref idref="c{i}"/>' for i in range(len(chapters)))
opf = f"""<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>{title}</dc:title><dc:creator>Autor</dc:creator>
<dc:identifier id="id">test-{title}</dc:identifier><dc:language>de</dc:language></metadata>
<manifest>{items}</manifest><spine>{spine}</spine></package>"""
with zipfile.ZipFile(path, "w") as z:
z.writestr("mimetype", "application/epub+zip")
z.writestr("META-INF/container.xml", CONTAINER)
z.writestr("OEBPS/content.opf", opf)
for i, body in enumerate(chapters):
z.writestr(f"OEBPS/c{i}.xhtml", f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>x</title></head><body>{body}</body></html>')
return path
@pytest.fixture
def epub(tmp_path):
def build(chapters, title="Testbuch"):
return make_epub(tmp_path / f"{title}.epub", chapters, title)
return build

View file

@ -0,0 +1,30 @@
"""The Textual app starts, lists a library, opens a book and leaves it again."""
import asyncio
from diora_tui.app import DioraTuiApp
from diora_tui.reader_screen import ReaderScreen
def test_open_read_and_leave(epub, tmp_path):
lib = tmp_path / "lib"
lib.mkdir()
path = epub(["<h1>Anfang</h1>" + "".join(f"<p>Absatz {i} " + "wort " * 40 + "</p>" for i in range(60))])
path.rename(lib / path.name)
async def run():
app = DioraTuiApp(lib)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
await pilot.press("enter")
for _ in range(50):
await pilot.pause(0.1)
if isinstance(app.screen, ReaderScreen):
break
assert isinstance(app.screen, ReaderScreen)
await pilot.pause(0.5)
await pilot.press("j", "j", "j", "n")
await pilot.press("escape")
await pilot.pause()
assert not isinstance(app.screen, ReaderScreen)
asyncio.run(run())

53
tui/tests/test_blocks.py Normal file
View file

@ -0,0 +1,53 @@
"""The block list must count and read exactly like the web reader (static/js/app/44-books.js,
40-epub.js sanitizeEpubHtml): anchors are block indexes shared between all clients. The
real-book comparison against the web reader's own code is tools/parity/."""
from diora_tui.blocks import anchor_is_further, format_anchor, load_flat_book, parse_anchor
def texts(book):
return [(b.tag, b.text) for b in book.blocks]
def test_block_selector_and_nesting(epub):
book = load_flat_book(epub([
"<h1>Titel</h1><p>Eins</p><blockquote><p>Zitat</p></blockquote><ul><li>a</li></ul>"
"<div>Blatt</div><div><span>kein Blatt</span></div><div><p>Behälter</p></div>"
]))
# a div counts only when it has no element children; blockquote and its p both count
assert texts(book) == [
("h1", "Titel"), ("p", "Eins"), ("blockquote", "Zitat"), ("p", "Zitat"),
("li", "a"), ("div", "Blatt"), ("p", "Behälter"),
]
def test_all_spine_documents_count_even_without_text(epub):
book = load_flat_book(epub(["<p>a</p>", "<p>b</p>"]))
assert [b.chapter_index for b in book.blocks] == [0, 1]
def test_comments_are_not_text(epub):
book = load_flat_book(epub(['<h1><!-- d="kap1" -->Titel</h1><p>x<!-- weg -->y</p>']))
assert texts(book) == [("h1", "Titel"), ("p", "xy")]
def test_whitespace_is_javascripts(epub):
# \s in JS: no-break space and BOM collapse like spaces; \x85 does not
book = load_flat_book(epub(["<p>a  bc</p><p>\x85</p>"]))
assert texts(book)[0] == ("p", "a b c")
assert texts(book)[1][1] == "\x85"
def test_footnote_marker_adds_no_space(epub):
book = load_flat_book(epub(['<p>Satz.<sup><a href="#n1">1</a></sup>Weiter</p><p id="n1">Fußnote</p>']))
assert book.blocks[0].text == "Satz.1Weiter"
assert book.blocks[0].footnotes[0].marker == "1"
assert book.footnote_targets["n1"] == 1
def test_anchor_format_and_order():
assert format_anchor(12, 0.5) == "12:0.500000"
assert parse_anchor("12:0.500000") == (12, 0.5)
assert parse_anchor("kaputt") is None
assert anchor_is_further("5:0.1", "4:0.9")
assert anchor_is_further("5:0.5", "5:0.4")
assert not anchor_is_further("4:0.9", "5:0.1")

View file

@ -0,0 +1,35 @@
"""Compare the TUI's block list with the web reader's (web_blocks.cjs output).
python compare_blocks.py web.json [name-part ...]
What must match is the number and tag of the blocks (they make the position anchors
shared by all clients). Text differences are reported too: they are cosmetic, but they
show where the TUI's HTML parser (lxml) reads a book differently from Chrome's.
Exit status 1 when a book's block list differs in length or tags.
"""
import json
import os
import sys
from pathlib import Path
from diora_tui.blocks import load_flat_book
def main(ref_file, only):
ref = json.load(open(ref_file, encoding="utf-8"))
failed = False
for file, web in ref.items():
if only and not any(o in file for o in only):
continue
book = load_flat_book(Path(file))
wb, tb = web["blocks"], book.blocks
structure = len(wb) == len(tb) and all(w["tag"] == t.tag for w, t in zip(wb, tb))
texts = [i for i, (w, t) in enumerate(zip(wb, tb)) if w["text"] != t.text]
failed |= not structure
print(f"{'OK ' if structure else 'FAIL'} {os.path.basename(file)[:50]}: web {len(wb)} / tui {len(tb)} blocks, "
f"{len(texts)} with other text" + (f" (first: {texts[0]})" if texts else ""))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2:]))

View file

@ -0,0 +1,44 @@
// The web reader's own block list for EPUBs, from the real app code (static/js/app/40-epub.js
// parseEpub + the reader's block selector) in headless Chromium. Reference for compare_blocks.py.
//
// node web_blocks.cjs <diora-web checkout> <out.json> book.epub [more.epub ...]
//
// Needs playwright-core (e.g. NODE_PATH=<diora-web>/node_modules) and a Chromium
// (PLAYWRIGHT_BROWSERS_PATH as for the e2e tests).
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright-core');
(async () => {
const [web, out, ...books] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('about:blank');
await page.addScriptTag({ path: path.join(web, 'static/js/jszip.min.js') });
await page.addScriptTag({ path: path.join(web, 'static/js/app/40-epub.js') });
const result = {};
for (const file of books) {
const b64 = fs.readFileSync(file).toString('base64');
result[file] = await page.evaluate(async (b64) => {
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const book = await parseEpub(bin.buffer);
const el = document.createElement('div');
el.innerHTML = book.chapters.join('');
document.body.appendChild(el);
const SEL = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; // EPUB_BLOCK_SELECTOR, 44-books.js
const blocks = Array.from(el.querySelectorAll(SEL));
const chapters = Array.from(el.querySelectorAll('[data-epub-src]'));
const res = blocks.map((b) => ({
tag: b.tagName.toLowerCase(),
text: b.textContent.replace(/\s+/g, ' ').trim(),
len: b.textContent.length,
ch: chapters.findIndex((c) => c.contains(b)),
}));
el.remove();
return { title: book.title, chapters: chapters.length, blocks: res };
}, b64);
console.error(path.basename(file), result[file].blocks.length, 'blocks');
}
fs.writeFileSync(out, JSON.stringify(result));
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });