"""EPUB 3 Media Overlay export: package structure, timing tiers, escaping, and the timing record.""" import json import os import re import tempfile import unittest import zipfile from pathlib import Path from xml.etree import ElementTree as ET os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-")) from core import epub_export as ee from core import magazine as mag NS = {"o": "http://www.idpf.org/2007/opf", "s": "http://www.w3.org/ns/SMIL", "x": "http://www.w3.org/1999/xhtml"} def blocks(): return [ {"type": "title", "text": "Die Zukunft der Batterien"}, {"type": "byline", "text": "Von Jane Smith"}, {"type": "body", "text": "Erster Satz. Zweiter Satz! Dritter Satz?"}, {"type": "body", "text": "Ein einzelner Absatz."}, ] def exact_timings(bl=None): bl = bl or blocks() times, t = [], 0 segs = [] for b in bl: n = len(ee.split_sentences(b["text"])) seg = [] for _ in range(n): seg.append([t, t + 1000]); t += 1000 segs.append({"chars": len(b["text"]), "times": seg}) return {"version": 1, "interview": False, "duration_ms": t, "segments": segs} class PlanTests(unittest.TestCase): def test_exact_uses_the_recorded_times(self): units, q = ee.plan_sentences(blocks(), 99999, exact_timings()) self.assertEqual(q, "exact") self.assertEqual(len(units), 1 + 1 + 3 + 1) self.assertEqual((units[0]["start"], units[0]["end"]), (0, 1000)) self.assertEqual(units[-1]["end"], 6000) def test_a_sentence_count_mismatch_keeps_the_paragraph_anchored(self): t = exact_timings() t["segments"][2]["times"] = [[2000, 5000]] # recorded as one utterance, text splits into three units, q = ee.plan_sentences(blocks(), 99999, t) self.assertEqual(q, "anchored") para = [u for u in units if u["block"] == 2] self.assertAlmostEqual(para[0]["start"], 2000) self.assertAlmostEqual(para[-1]["end"], 5000) self.assertTrue(all(a["end"] <= b["start"] + 1e-6 for a, b in zip(para, para[1:]))) def test_changed_text_falls_back_to_an_estimate_over_the_real_duration(self): t = exact_timings() t["segments"][3]["chars"] += 1 # text was edited after the audio was rendered units, q = ee.plan_sentences(blocks(), 10000, t) self.assertEqual(q, "estimated") self.assertAlmostEqual(units[-1]["end"], 10000, places=3) self.assertTrue(all(a["end"] <= b["start"] + 1e-6 for a, b in zip(units, units[1:]))) def test_no_record_and_interview_audio_are_estimated(self): self.assertEqual(ee.plan_sentences(blocks(), 5000, None)[1], "estimated") t = exact_timings(); t["interview"] = True self.assertEqual(ee.plan_sentences(blocks(), 5000, t)[1], "estimated") def test_empty_blocks_yield_nothing(self): self.assertEqual(ee.plan_sentences([{"type": "body", "text": " "}], 1000, None), ([], "estimated")) def test_clock_format(self): self.assertEqual(ee.clock(0), "0:00:00.000") self.assertEqual(ee.clock(3_723_456), "1:02:03.456") self.assertEqual(ee.clock(-5), "0:00:00.000") class BuildTests(unittest.TestCase): def setUp(self): self.dir = Path(tempfile.mkdtemp(prefix="ttvc-epub-")) self.mp3 = self.dir / "a.mp3" self.mp3.write_bytes(b"ID3fakeaudio" * 50) self.out = self.dir / "book.epub" def article(self, **kw): base = {"title": "Batterien & ", "author": "J", "blocks": blocks(), "mp3": self.mp3, "duration_ms": 6000, "timings": exact_timings()} base.update(kw) return base def build(self, arts=None, **kw): return ee.build_epub(self.out, title=kw.pop("title", "c't"), issue=kw.pop("issue", "9/2026"), language="de", articles=arts or [self.article()], identifier="urn:uuid:test", **kw) def test_mimetype_is_first_and_stored_and_every_xml_part_is_well_formed(self): info = self.build() with zipfile.ZipFile(self.out) as z: first = z.infolist()[0] self.assertEqual((first.filename, first.compress_type), ("mimetype", zipfile.ZIP_STORED)) self.assertEqual(z.read("mimetype"), b"application/epub+zip") for name in z.namelist(): if name.endswith((".xhtml", ".smil", ".opf", ".xml")): ET.fromstring(z.read(name)) # raises on malformed XML self.assertEqual(info["quality"], {"Batterien & ": "exact"}) def test_manifest_references_exist_ids_are_unique_and_overlay_is_linked(self): self.build() with zipfile.ZipFile(self.out) as z: opf = ET.fromstring(z.read("OEBPS/content.opf")) items = opf.findall(".//o:manifest/o:item", NS) ids = [i.get("id") for i in items] self.assertEqual(len(ids), len(set(ids))) for i in items: self.assertIn("OEBPS/" + i.get("href"), z.namelist()) xhtml = next(i for i in items if i.get("media-type") == "application/xhtml+xml" and i.get("id") == "a001") self.assertEqual(xhtml.get("media-overlay"), "a001smil") spine = [r.get("idref") for r in opf.findall(".//o:spine/o:itemref", NS)] self.assertEqual(spine, ["a001"]) props = {m.get("property") for m in opf.findall(".//o:metadata/o:meta", NS)} self.assertTrue({"media:duration", "media:active-class", "dcterms:modified"} <= props) def test_every_smil_text_target_exists_and_clips_are_ordered(self): self.build() with zipfile.ZipFile(self.out) as z: page = ET.fromstring(z.read("OEBPS/a001.xhtml")) span_ids = {e.get("id") for e in page.iter("{%s}span" % NS["x"])} smil = ET.fromstring(z.read("OEBPS/a001.smil")) pars = smil.findall(".//s:par", NS) self.assertEqual(len(pars), 6) last_end = -1.0 for p in pars: self.assertIn(p.find("s:text", NS).get("src").split("#")[1], span_ids) a = p.find("s:audio", NS) def secs(v): h, m, s = v.split(":"); return int(h) * 3600 + int(m) * 60 + float(s) b, e = secs(a.get("clipBegin")), secs(a.get("clipEnd")) self.assertLess(b, e) self.assertGreaterEqual(b, last_end - 1e-9) last_end = e self.assertEqual(a.get("src"), "audio/a001.mp3") def test_hostile_text_is_escaped_not_interpreted(self): bl = [{"type": "title", "text": "T"}, {"type": "body", "text": "Ein & \"Test\"."}] self.build([self.article(blocks=bl, timings=None, title="x")]) with zipfile.ZipFile(self.out) as z: raw = z.read("OEBPS/a001.xhtml").decode() self.assertNotIn("