#!/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")
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"
# ---------------------------------------------------------------------------
# lorem ipsum generator
# ---------------------------------------------------------------------------
LOREM_WORDS = ("lorem ipsum dolor sit amet consectetur adipiscing elit sed do "
"eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad "
"minim veniam quis nostrud exercitation ullamco laboris nisi aliquip "
"ex ea commodo consequat duis aute irure in reprehenderit in voluptate "
"velit esse cillum dolore eu fugiat nulla pariatur excepteur sint "
"occaecat cupidatat non proident sunt in culpa qui officia deserunt "
"mollit anim id est laborum").split()
def lorem_sentence(rng, min_w=8, max_w=20):
words = [rng.choice(LOREM_WORDS) for _ in range(rng.randint(min_w, max_w))]
words[0] = words[0].capitalize()
return " ".join(words) + "."
def lorem_paragraph(rng, min_s=3, max_s=7):
return " ".join(lorem_sentence(rng) for _ in range(rng.randint(min_s, max_s)))
def lorem_texts(rng, count):
return [lorem_paragraph(rng) for _ in range(count)]
# ---------------------------------------------------------------------------
# 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"{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"
'
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, lorem, rng, count):
if lorem:
return lorem_texts(rng, count)
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
print("WARNING: no paragraph text file found; generating lorem ipsum instead",
file=sys.stderr)
return lorem_texts(rng, count)
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("--lorem", action="store_true",
help="generate lorem ipsum text instead of reading a file "
"(default source: paragraphs.txt)")
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, args.lorem, rng, args.chapters * args.paragraphs)
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()