AetheriaLore/scripts/fiction/build_epub.py
2026-07-15 05:54:55 +01:00

251 lines
13 KiB
Python

#!/usr/bin/env python3
"""Build and validate an Aetheria fiction manuscript as a deterministic EPUB 3 archive."""
from __future__ import annotations
import argparse
import html
import os
import re
import uuid
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from xml.etree import ElementTree
@dataclass
class Chapter:
number: int
title: str
movement: str
blocks: list[str]
def inline_markup(text: str) -> str:
placeholders: dict[str, str] = {}
def hold(value: str) -> str:
token = f"\x00{len(placeholders)}\x00"
placeholders[token] = value
return token
text = re.sub(r"`([^`]+)`", lambda m: hold(f"<code>{html.escape(m.group(1))}</code>"), text)
escaped = html.escape(text, quote=False)
escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", escaped)
escaped = re.sub(r"(?<!_)_([^_]+)_(?!_)", r"<em>\1</em>", escaped)
for token, value in placeholders.items():
escaped = escaped.replace(html.escape(token), value)
return escaped
def parse_manuscript(source: Path) -> tuple[str, list[Chapter]]:
lines = source.read_text(encoding="utf-8").splitlines()
if not lines or not lines[0].startswith("# "):
raise ValueError("Manuscript must begin with an H1 title")
title = lines[0][2:].strip()
movement = ""
movement_level: int | None = None
heading_schema: tuple[int, int] | None = None
chapters: list[Chapter] = []
current: Chapter | None = None
for line in lines[1:]:
heading = re.fullmatch(r"(#{1,3})\s+(.+?)\s*", line)
if heading and re.match(r"Movement\s+", heading.group(2), re.IGNORECASE):
level = len(heading.group(1))
if heading_schema is not None and level != heading_schema[0]:
raise ValueError("Movement heading level changes within the manuscript")
movement = heading.group(2)
movement_level = level
current = None
elif heading and (chapter_match := re.match(r"Chapter\s+(\d+)\b", heading.group(2), re.IGNORECASE)):
number = int(chapter_match.group(1))
expected = len(chapters) + 1
if number != expected:
raise ValueError(f"Expected Chapter {expected}, found Chapter {number}")
if not movement:
raise ValueError(f"Chapter {number} has no movement")
schema = (movement_level, len(heading.group(1)))
if schema not in {(1, 2), (2, 3)}:
raise ValueError("Use H1 movements with H2 chapters, or H2 movements with H3 chapters")
if heading_schema is None:
heading_schema = schema
elif schema != heading_schema:
raise ValueError("Movement and chapter heading levels must remain consistent")
current = Chapter(number, heading.group(2), movement, [])
chapters.append(current)
elif heading:
raise ValueError(f"Unsupported heading after title: {line}")
elif current is not None and line.strip():
current.blocks.append(line.strip())
elif line.strip():
raise ValueError(f"Prose appears before the first chapter: {line}")
if not chapters or any(not chapter.blocks for chapter in chapters):
raise ValueError("Every chapter must contain prose")
return title, chapters
def xhtml_page(title: str, body: str, body_class: str = "") -> str:
return f'''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-GB">
<head><meta charset="utf-8"/><title>{html.escape(title)}</title><link rel="stylesheet" type="text/css" href="../styles/book.css"/></head>
<body class="{body_class}">{body}</body>
</html>'''
def chapter_xhtml(chapter: Chapter) -> str:
paragraphs = []
for block in chapter.blocks:
if block in {"***", "* * *", "---", "_ _ _"}:
paragraphs.append('<hr class="scene-break"/>')
continue
rendered = inline_markup(block)
css_class = " class=\"pov\"" if re.fullmatch(r"<strong>[^<]+</strong>", rendered) else ""
paragraphs.append(f"<p{css_class}>{rendered}</p>")
body = (
f'<header><p class="movement">{html.escape(chapter.movement)}</p>'
f'<h1>{html.escape(chapter.title)}</h1></header>' + "\n".join(paragraphs)
)
return xhtml_page(chapter.title, body, "chapter")
def modified_timestamp(source: Path) -> str:
epoch = int(os.environ.get("SOURCE_DATE_EPOCH", source.stat().st_mtime))
return datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def archive_timestamp(source: Path) -> tuple[int, int, int, int, int, int]:
epoch = int(os.environ.get("SOURCE_DATE_EPOCH", source.stat().st_mtime))
value = datetime.fromtimestamp(epoch, timezone.utc)
if value.year < 1980:
value = datetime(1980, 1, 1, tzinfo=timezone.utc)
return value.year, value.month, value.day, value.hour, value.minute, value.second
def write_entry(
archive: zipfile.ZipFile,
name: str,
data: str,
timestamp: tuple[int, int, int, int, int, int],
compress_type: int = zipfile.ZIP_DEFLATED,
) -> None:
info = zipfile.ZipInfo(name, date_time=timestamp)
info.compress_type = compress_type
info.create_system = 3
info.external_attr = 0o644 << 16
archive.writestr(info, data)
def build(
source: Path,
output: Path,
identifier_url: str,
expected_chapters: int | None = None,
expected_title: str | None = None,
) -> None:
title, chapters = parse_manuscript(source)
if expected_title is not None and title != expected_title:
raise ValueError(f"Expected title {expected_title!r}, found {title!r}")
if expected_chapters is not None and len(chapters) != expected_chapters:
raise ValueError(f"Expected {expected_chapters} chapters, found {len(chapters)}")
book_id = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, identifier_url)}"
output.parent.mkdir(parents=True, exist_ok=True)
chapter_items = "\n".join(
f'<item id="chapter-{i}" href="text/chapter-{i:02}.xhtml" media-type="application/xhtml+xml"/>'
for i in range(1, len(chapters) + 1)
)
spine = "\n".join(f'<itemref idref="chapter-{i}"/>' for i in range(1, len(chapters) + 1))
nav_points = "\n".join(
f'<li><a href="text/chapter-{i:02}.xhtml">{html.escape(chapter.title)}</a></li>'
for i, chapter in enumerate(chapters, 1)
)
ncx_points = "\n".join(
f'<navPoint id="nav-{i}" playOrder="{i}"><navLabel><text>{html.escape(chapter.title)}</text></navLabel><content src="text/chapter-{i:02}.xhtml"/></navPoint>'
for i, chapter in enumerate(chapters, 1)
)
package = f'''<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="en-GB">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">{book_id}</dc:identifier><dc:title>{html.escape(title)}</dc:title><dc:creator>GameCult</dc:creator><dc:language>en-GB</dc:language><meta property="dcterms:modified">{modified_timestamp(source)}</meta></metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/><item id="css" href="styles/book.css" media-type="text/css"/><item id="title" href="text/title.xhtml" media-type="application/xhtml+xml"/>{chapter_items}</manifest>
<spine toc="ncx"><itemref idref="title"/>{spine}</spine>
</package>'''
nav = f'''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en-GB"><head><meta charset="utf-8"/><title>Contents</title></head><body><nav epub:type="toc"><h1>Contents</h1><ol>{nav_points}</ol></nav></body></html>'''
ncx = f'''<?xml version="1.0" encoding="utf-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><head><meta name="dtb:uid" content="{book_id}"/></head><docTitle><text>{html.escape(title)}</text></docTitle><navMap>{ncx_points}</navMap></ncx>'''
title_page = xhtml_page(title, f'<main class="title-page"><h1>{html.escape(title)}</h1><p>A story set in Aetheria</p><p class="author">GameCult</p></main>', "title")
css = """body{font-family:Georgia,serif;line-height:1.62;margin:5%;color:#18191b;background:#faf8f3}h1{line-height:1.15}p{margin:0 0 .85em}.movement{text-transform:uppercase;letter-spacing:.08em;font:700 .72em system-ui,sans-serif;color:#666}.pov{margin:1.25em 0 1.5em;font-family:system-ui,sans-serif}.scene-break{border:0;text-align:center;margin:1.75em 0}.scene-break::after{content:"* * *";letter-spacing:.6em}.title-page{text-align:center;margin-top:30vh}.title-page h1{font-size:2.4em}.author{margin-top:3em}code{font-family:monospace;font-size:.9em}@media(prefers-color-scheme:dark){body{color:#e9e7e1;background:#18191b}.movement{color:#aaa}}"""
container = '''<?xml version="1.0" encoding="utf-8"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'''
timestamp = archive_timestamp(source)
with zipfile.ZipFile(output, "w") as archive:
write_entry(archive, "mimetype", "application/epub+zip", timestamp, zipfile.ZIP_STORED)
write_entry(archive, "META-INF/container.xml", container, timestamp)
write_entry(archive, "EPUB/package.opf", package, timestamp)
write_entry(archive, "EPUB/nav.xhtml", nav, timestamp)
write_entry(archive, "EPUB/toc.ncx", ncx, timestamp)
write_entry(archive, "EPUB/styles/book.css", css, timestamp)
write_entry(archive, "EPUB/text/title.xhtml", title_page, timestamp)
for i, chapter in enumerate(chapters, 1):
write_entry(archive, f"EPUB/text/chapter-{i:02}.xhtml", chapter_xhtml(chapter), timestamp)
validate(output, len(chapters), title)
def validate(epub: Path, expected_chapters: int | None = None, expected_title: str | None = None) -> None:
with zipfile.ZipFile(epub) as archive:
entries = archive.infolist()
if not entries or entries[0].filename != "mimetype" or entries[0].compress_type != zipfile.ZIP_STORED:
raise ValueError("EPUB mimetype must be the first, uncompressed entry")
if archive.read("mimetype") != b"application/epub+zip":
raise ValueError("Invalid EPUB mimetype")
xml_entries = [name for name in archive.namelist() if name.endswith((".xml", ".opf", ".ncx", ".xhtml"))]
for name in xml_entries:
ElementTree.fromstring(archive.read(name))
chapters = [name for name in archive.namelist() if re.fullmatch(r"EPUB/text/chapter-\d+\.xhtml", name)]
expected_names = [f"EPUB/text/chapter-{i:02}.xhtml" for i in range(1, len(chapters) + 1)]
if chapters != expected_names:
raise ValueError("Chapter files must be sequential and ordered")
if expected_chapters is not None and len(chapters) != expected_chapters:
raise ValueError(f"Expected {expected_chapters} chapters, found {len(chapters)}")
package = ElementTree.fromstring(archive.read("EPUB/package.opf"))
title = package.find(".//{http://purl.org/dc/elements/1.1/}title")
actual_title = None if title is None else title.text
if expected_title is not None and actual_title != expected_title:
raise ValueError(f"Expected title {expected_title!r}, found {actual_title!r}")
spine_items = package.findall(".//{http://www.idpf.org/2007/opf}spine/{http://www.idpf.org/2007/opf}itemref")
if len(spine_items) != len(chapters) + 1:
raise ValueError("Spine must contain the title page and every chapter")
nav = ElementTree.fromstring(archive.read("EPUB/nav.xhtml"))
nav_items = nav.findall(".//{http://www.w3.org/1999/xhtml}li")
if len(nav_items) != len(chapters):
raise ValueError("Navigation must contain every chapter")
ncx = ElementTree.fromstring(archive.read("EPUB/toc.ncx"))
ncx_items = ncx.findall(".//{http://www.daisy.org/z3986/2005/ncx/}navPoint")
if len(ncx_items) != len(chapters):
raise ValueError("NCX must contain every chapter")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--identifier-url", required=True)
parser.add_argument("--expected-chapters", type=int)
parser.add_argument("--expected-title")
parser.add_argument("--check", action="store_true", help="Validate the existing output without rebuilding")
args = parser.parse_args()
if args.check:
validate(args.output, args.expected_chapters, args.expected_title)
else:
build(args.source, args.output, args.identifier_url, args.expected_chapters, args.expected_title)
print(args.output)
if __name__ == "__main__":
main()