aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-17 21:15:57 -0400
committerhistoria <historiavg@proton.me>2026-08-17 21:15:57 -0400
commit1c8c7fcfe4b8df461763f82be6c3ed860b583d52 (patch)
treee8a11a6913668cc306aa7be96606f47ef29f2666
parent68fb1dd78e4a8b83d742c19c5a1150b79872fbbf (diff)
downloadepub-generator-1c8c7fcfe4b8df461763f82be6c3ed860b583d52.tar.gz
add epubgen: python script to generate sample epub files
-rw-r--r--.gitignore223
-rw-r--r--README.md53
-rwxr-xr-xepubgen.py492
-rw-r--r--paragraphs.txt12
4 files changed, 780 insertions, 0 deletions
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 `<span>`s, `<pre xml:space="preserve">`, 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'<head><meta charset="utf-8"/><title>{esc(title)}</title>'
+ if link_css:
+ head += '<link rel="stylesheet" type="text/css" href="style.css"/>'
+ head += '</head>'
+ btype = f' epub:type="{body_type}"' if body_type else ''
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n'
+ f'<html {NS} lang="en" xml:lang="en">{head}<body{btype}>{body}</body></html>')
+
+
+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 = '<pre xml:space="preserve">\n\t' + p + '\n</pre>'
+ elif kind < 0.30:
+ depth = rng.randint(3, 9)
+ inner = p
+ for d in range(depth):
+ inner = f'<span class="t{d}" data-x="{d}">{inner}</span>'
+ body = f'<p id="p{kid}" dir="auto" xml:lang="{rng.choice(["en-US", "de-AT-1901", "fr", "en"])}">{inner}</p>'
+ elif kind < 0.36:
+ body = f'<blockquote><p id="p{kid}">{p}</p></blockquote>'
+ else:
+ body = f'<p id="p{kid}">{p}</p>'
+ return body
+
+
+def torture_extra(rng):
+ if rng.random() < 0.5:
+ cells = "".join(f"<tr><th>k{k}</th><td>{esc(str(rng.random()))}</td></tr>" for k in range(3))
+ return f"<table><caption>{esc('data')}</caption>{cells}</table>"
+ safe = "AZaz019()[]{}_"
+ return "<!--" + "".join(rng.choice(safe) for _ in range(rng.randint(10, 30))) + "-->"
+
+
+# ---------------------------------------------------------------------------
+# document builders
+# ---------------------------------------------------------------------------
+
+def container_xml():
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n'
+ '<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 cover_xhtml():
+ body = ('<section epub:type="cover">'
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
+ 'version="1.1" width="100%" height="100%" viewBox="0 0 600 800">'
+ '<image width="600" height="800" xlink:href="cover.png"/></svg>'
+ '</section>')
+ return xhtml("Cover", body, link_css=False)
+
+
+def title_xhtml(title, author):
+ body = ('<section epub:type="titlepage">'
+ f'<h1 class="book-title">{esc(title)}</h1>'
+ f'<p class="byline">{esc(author)}</p>'
+ '<p class="imprint">Test Press</p>'
+ '</section>')
+ 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 id="p{k}">{p}</p>')
+ body = "\n".join(ps)
+ if mode == "torture":
+ body += "\n" + torture_extra(rng)
+ section = (f'<section epub:type="chapter"><h1>{esc(ctitle)}</h1>{body}'
+ f'<footer>{esc("Chapter %d of %d" % (idx, total))}</footer></section>')
+ return xhtml(ctitle, section, body_type="bodymatter chapter")
+
+
+def nav_xhtml(first_href, chapters, back_hrefs):
+ def li(href, label):
+ return f'<li><a href="{href}">{esc(label)}</a></li>'
+
+ fl = "\n".join(li(h, l) for h, l in
+ [("cover.xhtml", "Cover"), ("title.xhtml", "Title page"), ("nav.xhtml", "Contents")])
+ cl = "\n".join(f'<li><a href="{h}">{esc(t)}</a></li>' for _, h, t in chapters)
+ bl = "\n".join(li(h, l) for h, l in back_hrefs)
+ toc = ('<nav epub:type="toc" id="toc">'
+ f'<h1>{esc("Table of Contents")}</h1><ol>'
+ f'<li><span>Front matter</span><ol>{fl}</ol></li>'
+ f'<li><span>Chapters</span><ol>{cl}</ol></li>'
+ f'<li><span>Back matter</span><ol>{bl}</ol></li>'
+ '</ol></nav>')
+ lms = "".join(f'<li><a href="{h}" epub:type="{t}">{esc(l)}</a></li>'
+ 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'<nav epub:type="landmarks" id="landmarks"><h2>{esc("Guide")}</h2><ol>{lms}</ol></nav>'
+ 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'<navPoint id="{pid}" playOrder="{po[0]}"><navLabel><text>{esc(label)}</text></navLabel>'
+ f'<content src="{href}"/>{children}</navPoint>')
+
+ 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"<navMap>{cover}{cps}</navMap>"
+ head = (f'<head><meta name="dtb:uid" content="{uid}"/>'
+ '<meta name="dtb:depth" content="1"/>'
+ '<meta name="dtb:totalPageCount" content="0"/>'
+ '<meta name="dtb:maxPageNumber" content="0"/></head>')
+ doctype = ''
+ if mode == "normal":
+ doctype = '<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.dtd-helper.com/ncx/ncx-2005-1.dtd">\n'
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n' + doctype +
+ '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="en">'
+ f'{head}<docTitle><text>{esc(title)}</text></docTitle>{navmap}</ncx>')
+
+
+def opf_xml(mode, rng, title, author, uid, now, chapters, ncx):
+ meta = [
+ f'<dc:identifier id="pub-id">{uid}</dc:identifier>',
+ f'<dc:title id="t1">{esc(title)}</dc:title>',
+ '<meta refines="#t1" property="title-type">main</meta>',
+ f'<dc:creator id="c1">{esc(author)}</dc:creator>',
+ '<meta refines="#c1" property="role" scheme="marc:relators">aut</meta>',
+ '<dc:language>en</dc:language>',
+ '<meta property="dcterms:conformsTo">EPUB 3.3</meta>',
+ '<dc:publisher>Test Press</dc:publisher>',
+ f'<dc:date>{now}</dc:date>',
+ f'<meta property="dcterms:modified">{now}</meta>',
+ '<dc:rights>Public domain test text.</dc:rights>',
+ ]
+ if mode == "torture":
+ meta += [
+ f'<dc:creator id="c2">{esc(rng.choice(["A. Nonymous", "Écrivain", "テスト作者"]))}</dc:creator>',
+ '<meta refines="#c2" property="role" scheme="marc:relators">edt</meta>',
+ '<meta property="schema:accessMode">textual</meta>',
+ '<meta property="schema:accessibilityFeature">readingOrder</meta>',
+ '<dc:subject>testing, books, epub, torture</dc:subject>',
+ f'<dc:description>{esc(torture_text("A book for testing parsers.", rng))}</dc:description>',
+ ]
+ metadata = f'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">{"".join(meta)}</metadata>'
+
+ manifest = [
+ '<item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/>',
+ '<item id="cover-page" href="cover.xhtml" media-type="application/xhtml+xml" properties="svg"/>',
+ '<item id="title-page" href="title.xhtml" media-type="application/xhtml+xml"/>',
+ '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
+ '<item id="css" href="style.css" media-type="text/css"/>',
+ '<item id="index" href="index.xhtml" media-type="application/xhtml+xml"/>',
+ '<item id="colophon" href="colophon.xhtml" media-type="application/xhtml+xml"/>',
+ ]
+ if ncx:
+ manifest.append('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>')
+ for i, (_, h, _) in enumerate(chapters):
+ manifest.append(f'<item id="chap{i}" href="{h}" media-type="application/xhtml+xml"/>')
+ manifest_xml = f'<manifest>{"".join(manifest)}</manifest>'
+
+ spine = ['<itemref idref="cover-page"/>', '<itemref idref="title-page"/>', '<itemref idref="nav"/>']
+ for i in range(len(chapters)):
+ spine.append(f'<itemref idref="chap{i}" linear="yes"/>')
+ spine.append('<itemref idref="index" linear="yes"/>')
+ spine.append('<itemref idref="colophon" linear="no"/>')
+ spine_xml = f'<spine>{"".join(spine)}</spine>'
+
+ pkg_lang = "zh-Hant-TW-u-ca-gregory" if mode == "torture" else "en"
+ pkg_dir = "rtl" if mode == "torture" else "ltr"
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n'
+ '<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" '
+ f'xml:lang="{pkg_lang}" dir="{pkg_dir}">{metadata}{manifest_xml}{spine_xml}</package>')
+
+
+def index_xhtml(mode, rng, chapters, words):
+ entries = []
+ for i, (_, h, _) in enumerate(chapters):
+ entries.append(f'<li><a href="{h}#p1">{esc(words[i])}</a><span>{i + 1}</span></li>')
+ if mode == "torture":
+ entries.append(f'<li>{esc("index of indexes")} - see <a href="{chapters[0][1]}#p1">{esc(words[0])}</a></li>')
+ body = f'<section epub:type="index"><h1>{esc("Index")}</h1><ul class="index">{"\n".join(entries)}</ul></section>'
+ 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'<section epub:type="colophon"><h1>{esc("Colophon")}</h1><p>{esc(text)}</p></section>'
+ 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.