From 1c8c7fcfe4b8df461763f82be6c3ed860b583d52 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 17 Aug 2026 21:15:57 -0400 Subject: add epubgen: python script to generate sample epub files --- .gitignore | 223 ++++++++++++++++++++++++++ README.md | 53 +++++++ epubgen.py | 492 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ paragraphs.txt | 12 ++ 4 files changed, 780 insertions(+) create mode 100644 .gitignore create mode 100755 epubgen.py create mode 100644 paragraphs.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c182678 --- /dev/null +++ b/.gitignore @@ -0,0 +1,223 @@ +*.epub +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + + diff --git a/README.md b/README.md index e69de29..8feee2f 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,53 @@ +# epubgen + +Python EPUB 3.3 generator for making test files. It will pull paragraphs from a text file randomly to generate the content of the book. It also has a torture mode which randomly adds unusual characters to try to break parsers. + +## Usage + +```sh +python epubgen.py -m normal -c 10 -p 5 +``` + +| Option | Default | Meaning | +| ------------------ | ------------------------------ | ------------------------------------------------- | +| `-o, --out` | `normal.epub` / `torture.epub` | output path (single mode) or name prefix (`both`) | +| `-m, --mode` | `both` | `normal`, `torture`, or `both` (one of each) | +| `-c, --chapters` | `5` | number of chapters | +| `-p, --paragraphs` | `10` | paragraphs per chapter | +| `-t, --title` | `The Testing EPUB` | book title | +| `-a, --author` | `Test Author` | book author | +| `--seed N` | random | RNG seed for reproducibility | +| `--texts FILE` | `paragraphs.txt` | one paragraph per line | +| `--no-ncx` | off | omit the EPUB 2 NCX fallback | +| `-v, --version` | | print version | + +## What the generated EPUB contains + +- `mimetype` — `application/epub+zip`, STORED, first entry, no extra fields +- `META-INF/container.xml` +- `OEBPS/content.opf` — EPUB 3.3 package: `urn:uuid` identifier, title + `title-type` + refine, creator + `marc:relators` role refine, language, `dcterms:conformsTo`, + publisher, date, `dcterms:modified` (required by spec), rights; manifest items with + `properties="nav"`, `cover-image`, `svg`; NCX in the manifest for legacy readers +- `OEBPS/nav.xhtml` — `epub:type="toc"` (front matter / chapters / back matter groups) + and `epub:type="landmarks"` guide +- `OEBPS/toc.ncx` — EPUB 2 NCX fallback so old readers work +- `OEBPS/cover.xhtml` + `OEBPS/cover.png` — SVG-wrapped procedural gradient cover +- `OEBPS/title.xhtml` — title page +- `OEBPS/chapNN.xhtml` — chapters with numbered paragraphs (anchors for link testing) +- `OEBPS/index.xhtml` — back-matter index with links into chapters +- `OEBPS/colophon.xhtml` — back matter, spine `linear="no"` +- `OEBPS/style.css` — stylesheet + +## torture mode + +A technically valid EPUB with syntax designed to trip up sloppy parsers: + +- bidi override/embedding characters, combining marks, zero-width characters +- mixed RTL/LTR, CJK, emoji, private-use characters, exotic space variants +- numeric character references (`&#xNNNN;`) injected mid-text +- deeply nested ``s, `
`, tables, comments
+- unusual-but-valid `xml:lang` (`zh-Hant-TW-u-ca-gregory`), `dir="rtl"` package
+- chapter filenames with spaces and `&` (percent-encoded in every href)
+- NCX with the DOCTYPE omitted and `playOrder` values out of document order
+- CSS `@import`, media queries, vendor prefixes, `content` unicode escapes, `!important`
diff --git a/epubgen.py b/epubgen.py
new file mode 100755
index 0000000..8599b35
--- /dev/null
+++ b/epubgen.py
@@ -0,0 +1,492 @@
+#!/usr/bin/env python3
+"""epubgen - generate spec-perfect EPUB 3.3 files for testing readers and validators.
+
+Two modes:
+  normal   - a clean, readable EPUB that uses the full feature set
+             (cover, title page, TOC, landmarks, chapters, index, colophon,
+             EPUB2 NCX fallback, EPUB3 nav, CSS, metadata refinements).
+  torture  - a technically valid EPUB with deliberately hostile syntax
+             (bidi overrides, combining chars, numeric char refs, deep nested
+             markup, unusual-but-valid xml:lang, weird filenames, ...).
+
+Every XML file is re-parsed with xml.etree before zipping, so the output is
+guaranteed well-formed. Zero dependencies beyond the Python standard library.
+
+Usage:
+  python3 epubgen.py [options]
+"""
+import argparse
+import html
+import os
+import random
+import re
+import struct
+import sys
+import uuid
+import xml.etree.ElementTree as ET
+import zipfile
+import zlib
+from datetime import datetime, timezone
+from urllib.parse import quote
+
+VERSION = "1.0"
+HERE = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_FILE = os.path.join(HERE, "paragraphs.txt")
+
+EMBEDDED = [
+    "The wind carried the smell of rain across the valley, and the last light of the afternoon lingered on the hills.",
+    "She opened the ledger and began to read, though the words had been written in another hand long ago.",
+    "It was not the first time the old clock had stopped, but it was the first time anyone had noticed.",
+]
+
+NS = 'xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"'
+
+# Hostile-but-valid Unicode (all fine in XML 1.0 / UTF-8)
+COMBINING = "\u0300\u0301\u0308\u030a\u20dd\u20e3"
+ZW = "\u200b\u200c\u200d\u200e\u200f"
+SPACES = "\u00a0\u2009\u200a\u202f\u3000"
+BIDI = "\u202a\u202b\u202d\u202e"
+PRIV = "\ue000\ue100\uf8ff"
+CJK = "\u6c49\u5b57\u6d4b\u8bd5\u30c6\u30b9\u30c8"
+EMOJI = "\U0001f600\U0001f389\U0001f680\U0001f480\U0001f518\U0001f30d"
+
+
+def esc(s):
+    """XML-escape text (& < > and quotes; escaping '>' makes ']]>' impossible)."""
+    return html.escape(s, quote=True)
+
+
+def xhtml(title, body, body_type=None, link_css=True):
+    """Wrap body content in a spec-compliant XHTML5 document."""
+    head = f'{esc(title)}'
+    if link_css:
+        head += ''
+    head += ''
+    btype = f' epub:type="{body_type}"' if body_type else ''
+    return ('\n'
+            f'{head}{body}')
+
+
+def first_word(texts, rng):
+    t = rng.choice(texts)
+    m = re.search(r"[A-Za-z0-9]+", t)
+    return m.group(0) if m else "word"
+
+
+# ---------------------------------------------------------------------------
+# torture helpers
+# ---------------------------------------------------------------------------
+
+def torture_text(s, rng):
+    """Inject combining marks, zero-width/bidi chars, exotic spaces, CJK/emoji/private-use."""
+    out = []
+    for ch in s:
+        out.append(ch)
+        if ch.isalnum() and rng.random() < 0.15:
+            out.append(rng.choice(COMBINING))
+        r = rng.random()
+        if r < 0.04:
+            out.append(rng.choice(ZW))
+        elif r < 0.05:
+            out.append(rng.choice(BIDI))
+        elif r < 0.055:
+            out.append(rng.choice(PRIV))
+        elif r < 0.06:
+            out.append(rng.choice(CJK + EMOJI))
+    s = "".join(out)
+    s = "".join(rng.choice(SPACES) if c == " " and rng.random() < 0.4 else c for c in s)
+    if len(s) > 40 and rng.random() < 0.3:
+        i = rng.randrange(0, len(s) - 20)
+        j = rng.randrange(i + 10, min(len(s), i + 60))
+        s = s[:i] + "\u202e" + s[i:j] + "\u202c" + s[j:]
+    return s
+
+
+def torture_entities(s, rng):
+    """Replace some chars with numeric character references (post-escaping, skips existing entities)."""
+    out, i, n = [], 0, len(s)
+    while i < n:
+        if s[i] == "&":
+            j = s.find(";", i)
+            if j != -1:
+                out.append(s[i:j + 1])
+                i = j + 1
+                continue
+        ch = s[i]
+        if (ch.isalnum() or ch == " ") and rng.random() < 0.12:
+            out.append(f"&#x{ord(ch):04x};")
+        else:
+            out.append(ch)
+        i += 1
+    return "".join(out)
+
+
+def torture_para(rng, p, kid):
+    kind = rng.random()
+    if kind < 0.18:
+        body = '
\n\t' + p + '\n
' + elif kind < 0.30: + depth = rng.randint(3, 9) + inner = p + for d in range(depth): + inner = f'{inner}' + body = f'

{inner}

' + elif kind < 0.36: + body = f'

{p}

' + else: + body = f'

{p}

' + return body + + +def torture_extra(rng): + if rng.random() < 0.5: + cells = "".join(f"k{k}{esc(str(rng.random()))}" for k in range(3)) + return f"{cells}
{esc('data')}
" + safe = "AZaz019()[]{}_" + return "" + + +# --------------------------------------------------------------------------- +# document builders +# --------------------------------------------------------------------------- + +def container_xml(): + return ('\n' + '' + '' + '') + + +def cover_xhtml(): + body = ('
' + '' + '' + '
') + return xhtml("Cover", body, link_css=False) + + +def title_xhtml(title, author): + body = ('
' + f'

{esc(title)}

' + f'' + '

Test Press

' + '
') + return xhtml(title, body) + + +def chapter_xhtml(mode, rng, idx, total, npar, ctitle, texts): + ps = [] + for k in range(1, npar + 1): + raw = rng.choice(texts) + if mode == "torture": + raw = torture_text(raw, rng) + p = esc(raw) + if mode == "torture": + p = torture_entities(p, rng) + ps.append(torture_para(rng, p, k)) + else: + ps.append(f'

{p}

') + body = "\n".join(ps) + if mode == "torture": + body += "\n" + torture_extra(rng) + section = (f'

{esc(ctitle)}

{body}' + f'
{esc("Chapter %d of %d" % (idx, total))}
') + return xhtml(ctitle, section, body_type="bodymatter chapter") + + +def nav_xhtml(first_href, chapters, back_hrefs): + def li(href, label): + return f'
  • {esc(label)}
  • ' + + fl = "\n".join(li(h, l) for h, l in + [("cover.xhtml", "Cover"), ("title.xhtml", "Title page"), ("nav.xhtml", "Contents")]) + cl = "\n".join(f'
  • {esc(t)}
  • ' for _, h, t in chapters) + bl = "\n".join(li(h, l) for h, l in back_hrefs) + toc = ('') + lms = "".join(f'
  • {esc(l)}
  • ' + for h, t, l in [("cover.xhtml", "cover", "Cover"), + ("title.xhtml", "titlepage", "Title page"), + ("nav.xhtml", "toc", "Table of contents"), + (first_href, "bodymatter", "Start"), + ("index.xhtml", "index", "Index")]) + landmarks = f'' + return xhtml("Contents", toc + landmarks, body_type="frontmatter") + + +def ncx_xml(mode, rng, title, chapters, uid): + po = [0] + + def point(pid, href, label, children=""): + po[0] += 1 + return (f'{esc(label)}' + f'{children}') + + cover = point("npx", "cover.xhtml", "Cover") + cps = "\n".join(point(f"np{i}", h, t) for i, (_, h, t) in enumerate(chapters)) + if mode == "torture": + cps = point("npWrap", chapters[0][1], rng.choice(["Chapters", "Contents", "Edição"]), cps) + navmap = f"{cover}{cps}" + head = (f'' + '' + '' + '') + doctype = '' + if mode == "normal": + doctype = '\n' + return ('\n' + doctype + + '' + f'{head}{esc(title)}{navmap}') + + +def opf_xml(mode, rng, title, author, uid, now, chapters, ncx): + meta = [ + f'{uid}', + f'{esc(title)}', + 'main', + f'{esc(author)}', + 'aut', + 'en', + 'EPUB 3.3', + 'Test Press', + f'{now}', + f'{now}', + 'Public domain test text.', + ] + if mode == "torture": + meta += [ + f'{esc(rng.choice(["A. Nonymous", "Écrivain", "テスト作者"]))}', + 'edt', + 'textual', + 'readingOrder', + 'testing, books, epub, torture', + f'{esc(torture_text("A book for testing parsers.", rng))}', + ] + metadata = f'{"".join(meta)}' + + manifest = [ + '', + '', + '', + '', + '', + '', + '', + ] + if ncx: + manifest.append('') + for i, (_, h, _) in enumerate(chapters): + manifest.append(f'') + manifest_xml = f'{"".join(manifest)}' + + spine = ['', '', ''] + for i in range(len(chapters)): + spine.append(f'') + spine.append('') + spine.append('') + spine_xml = f'{"".join(spine)}' + + pkg_lang = "zh-Hant-TW-u-ca-gregory" if mode == "torture" else "en" + pkg_dir = "rtl" if mode == "torture" else "ltr" + return ('\n' + '{metadata}{manifest_xml}{spine_xml}') + + +def index_xhtml(mode, rng, chapters, words): + entries = [] + for i, (_, h, _) in enumerate(chapters): + entries.append(f'
  • {esc(words[i])}{i + 1}
  • ') + if mode == "torture": + entries.append(f'
  • {esc("index of indexes")} - see {esc(words[0])}
  • ') + body = f'

    {esc("Index")}

      {"\n".join(entries)}
    ' + return xhtml("Index", body, body_type="backmatter") + + +def colophon_xhtml(mode, uid, now): + text = ("This ebook was generated by epubgen for testing EPUB readers and validators. " + f"Identifier: {uid}. Generated: {now}. Mode: {mode}.") + body = f'

    {esc("Colophon")}

    {esc(text)}

    ' + return xhtml("Colophon", body, body_type="backmatter") + + +def css(mode): + base = ( + 'body{margin:5% auto;max-width:34em;font-family:Georgia,"Times New Roman",serif;line-height:1.5}\n' + 'h1{font-size:1.8em;line-height:1.2}\n' + 'h2{font-size:1.3em}\n' + 'p{text-indent:1.2em;margin:0.4em 0}\n' + 'p:first-of-type{text-indent:0}\n' + 'blockquote{font-style:italic;border-left:3px solid #999;margin:1em 2em;padding-left:1em}\n' + 'footer{font-size:0.85em;color:#555;text-align:center;margin-top:2em}\n' + '.index li{list-style:none;margin:0.2em 0}\n' + '.index li span{color:#999;margin-left:0.5em}\n' + 'pre{white-space:pre-wrap;font-family:monospace}\n' + ) + if mode == "torture": + base = ('@import url("http://example.invalid/legacy.css");\n' + base + + 'p:first-of-type{text-indent:0!important}\n' + 'pre[lang]{white-space:pre!important}\n' + '@media screen and (min-width:1px){h1{-webkit-hyphens:auto;-ms-hyphens:auto;hyphens:auto}}\n' + 'h1::before{content:"\\00a7\\2002"}\n' + 'table{border-collapse:collapse}\n' + 'td,th{border:1px solid #ccc;padding:0.2em 0.5em}\n' + '@page{margin:5%}\n') + return base + + +def make_cover_png(w=600, h=800): + rows = [] + for y in range(h): + row = bytearray(b"\x00") + for x in range(w): + row += bytes((min(255, int(90 + 165 * x / w)), + min(255, int(60 + 120 * y / h)), + min(255, int(150 + 60 * (x + y) / (w + h))))) + rows.append(bytes(row)) + raw = b"".join(rows) + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +# --------------------------------------------------------------------------- +# orchestration +# --------------------------------------------------------------------------- + +def check_xml(label, data): + try: + ET.fromstring(data) + except Exception as ex: + print("ERROR: %s is not well-formed XML: %s" % (label, ex), file=sys.stderr) + sys.exit(1) + + +def build_zip(out, files): + os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True) + with zipfile.ZipFile(out, "w") as zf: + for name, data in files: + zi = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + zi.compress_type = zipfile.ZIP_STORED if name == "mimetype" else zipfile.ZIP_DEFLATED + zi.external_attr = 0o644 << 16 + zf.writestr(zi, data) + with zipfile.ZipFile(out) as zf: + names = zf.namelist() + if (not names or names[0] != "mimetype" or + zf.getinfo("mimetype").compress_type != zipfile.ZIP_STORED or + zf.read("mimetype") != b"application/epub+zip"): + print("ERROR: mimetype entry violates the OCF spec", file=sys.stderr) + sys.exit(1) + for i in zf.infolist(): + print(" %-42s %8d bytes %s" % ( + i.filename, i.file_size, + "STORED" if i.compress_type == zipfile.ZIP_STORED else "DEFLATED")) + + +def load_texts(path): + for p in (path, DEFAULT_FILE): + if p and os.path.exists(p): + with open(p, encoding="utf-8") as f: + lines = [ln.strip() for ln in f if ln.strip()] + if lines: + return lines + return EMBEDDED + + +def generate(out, rng, texts, title, author, mode, nchap, npar, ncx, seed): + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + uid = "urn:uuid:%s" % uuid.uuid4() + if mode == "torture": + title = torture_text(title, rng) + + chapters, words = [], [] + for i in range(1, nchap + 1): + fname = "chap %02d & tale.xhtml" % i if mode == "torture" else "chap%02d.xhtml" % i + href = quote(fname) + ctitle = "Chapter %d" % i if mode == "normal" else torture_text("Chapter %d" % i, rng) + chapters.append((fname, href, ctitle)) + words.append(first_word(texts, rng)) + + first_href = chapters[0][1] + back_hrefs = [("index.xhtml", "Index"), ("colophon.xhtml", "Colophon")] + + docs = { + "META-INF/container.xml": container_xml(), + "OEBPS/content.opf": opf_xml(mode, rng, title, author, uid, now, chapters, ncx), + "OEBPS/nav.xhtml": nav_xhtml(first_href, chapters, back_hrefs), + "OEBPS/style.css": css(mode), + "OEBPS/cover.xhtml": cover_xhtml(), + "OEBPS/title.xhtml": title_xhtml(title, author), + "OEBPS/index.xhtml": index_xhtml(mode, rng, chapters, words), + "OEBPS/colophon.xhtml": colophon_xhtml(mode, uid, now), + } + if ncx: + docs["OEBPS/toc.ncx"] = ncx_xml(mode, rng, title, chapters, uid) + for i, (fname, href, ctitle) in enumerate(chapters, 1): + docs["OEBPS/" + fname] = chapter_xhtml(mode, rng, i, nchap, npar, ctitle, texts) + + for label, data in docs.items(): + if label.endswith(".xml") or label.endswith((".opf", ".ncx", ".xhtml")): + check_xml(label, data) + + files = [("mimetype", b"application/epub+zip")] + files += [("META-INF/container.xml", docs["META-INF/container.xml"])] + files += [(name, data) for name, data in docs.items() if name.startswith("OEBPS/")] + files += [("OEBPS/cover.png", make_cover_png())] + + build_zip(out, files) + print("OK: %s (%d chapters x %d paragraphs, mode=%s, seed=%s)" % + (out, nchap, npar, mode, seed if seed is not None else "random")) + + +def resolve_out_paths(out, mode): + if mode != "both": + return [out or f"{mode}.epub"] + stem = out[:-5] if out and out.lower().endswith(".epub") else (out or "") + base = f"{stem}." if stem else "" + return [f"{base}normal.epub", f"{base}torture.epub"] + + +def main(): + ap = argparse.ArgumentParser( + description="Generate spec-perfect EPUB 3.3 files for testing readers and validators.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + ap.add_argument("-o", "--out", default=None, + help="output .epub path (single mode) or name prefix (both mode)") + ap.add_argument("-m", "--mode", choices=["normal", "torture", "both"], default="both", + help="normal = clean; torture = hostile-but-valid; both = one of each (default)") + ap.add_argument("-c", "--chapters", type=int, default=5, help="number of chapters") + ap.add_argument("-p", "--paragraphs", type=int, default=10, help="paragraphs per chapter") + ap.add_argument("-t", "--title", default="The Testing EPUB") + ap.add_argument("-a", "--author", default="Test Author") + ap.add_argument("--seed", type=int, default=None, help="RNG seed for reproducibility") + ap.add_argument("--texts", default=None, help="file with one paragraph per line") + ap.add_argument("--no-ncx", action="store_true", help="omit the EPUB2 NCX fallback") + ap.add_argument("-v", "--version", action="version", version="epubgen %s" % VERSION) + args = ap.parse_args() + + if args.chapters < 1 or args.paragraphs < 1: + print("ERROR: chapters and paragraphs must be >= 1", file=sys.stderr) + sys.exit(1) + + rng = random.Random(args.seed) + texts = load_texts(args.texts) + ncx = not args.no_ncx + modes = ["normal", "torture"] if args.mode == "both" else [args.mode] + for mode, path in zip(modes, resolve_out_paths(args.out, args.mode)): + generate(path, rng, texts, args.title, args.author, + mode, args.chapters, args.paragraphs, ncx, args.seed) + + +if __name__ == "__main__": + main() diff --git a/paragraphs.txt b/paragraphs.txt new file mode 100644 index 0000000..396e961 --- /dev/null +++ b/paragraphs.txt @@ -0,0 +1,12 @@ +The wind carried the smell of rain across the valley, and the last light of the afternoon lingered on the hillsides. +She opened the ledger and began to read, though the words had been written in another hand many years before. +It was not the first time the old clock had stopped, but it was the first time anyone in the house had noticed. +By morning the market would be open again, and the quiet of the night would be traded for the noise of the day. +He counted the steps twice, once going up and once coming down, and each time he reached the same impossible number. +The letter mentioned a debt, a storm, and a key, but said nothing about when either of them might return. +Water pooled in the hollows of the flagstones, reflecting a sky that had gone the color of slate. +Every spring the same argument resumed in the kitchen, about the garden, about the fence, about the neighbor's dog. +The librarian placed the book on the cart with the others, then, after a moment, slipped it back onto the shelf. +Somewhere above the roof a bird called twice and fell silent, and the whole street seemed to listen. +The telegram was short, and the handwriting that accompanied it was shorter still, but the meaning took years to arrive. +At the far end of the harbor a single lamp burned green, marking the channel that no ship had used in a decade. -- cgit v1.2.3