EPUB Ebooks skill for Codex

Ebooks that pass EPUBCheck the first time. Codex builds ebooks that stores accept: it cleans the manuscript structure, generates EPUB 3 with Pandoc or a hand-built package, packages the mimetype entry correctly, adds schema.org accessibility metadata for the European Accessibility Act, and validates with a bundled offline checker before running EPUBCheck. Failures map to concrete fixes rather than guesswork.

  • Documents & Office
  • $epub-ebooks
  • 238 lines
  • 3 files
  • ~2,350 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- epub-ebooks

Installs to ~/.agents/skills/epub-ebooks/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $epub-ebooks, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and validates EPUB 3 ebooks from Markdown, HTML or DOCX with Pandoc or a hand-built package (OPF, nav document, XHTML, CSS), adds cover, metadata and accessibility metadata, and checks them with EPUBCheck plus a bundled offline sanity script. Use when asked to make an ebook, convert a manuscript or docs to EPUB, fix an EPUB rejected by KDP, Apple Books or Kobo, or prepare an accessible EPUB for the European Accessibility Act.

Use it when

  • A manuscript in Markdown, HTML or DOCX must become an EPUB
  • An EPUB fails EPUBCheck or a store's ingestion
  • An ebook needs accessibility metadata, alt text or a page list
  • You need to inspect or repair an existing EPUB package by hand

Use something else when

How to check the result

Run the bundled epub_check.py for structure, then EPUBCheck for zero errors and warnings, and open the book in two reading systems to confirm the table of contents, cover and footnotes work.

The mistake it prevents

Zipping the book folder normally puts mimetype in the wrong position or compresses it, so stores reject an EPUB that looks fine in a desktop reader.

~/.agents/skills/epub-ebooks/SKILL.md

--- name: epub-ebooks description: Builds and validates EPUB 3 ebooks from Markdown, HTML or DOCX with Pandoc or a hand-built package (OPF, nav document, XHTML, CSS), adds cover,… license: MIT ---

An EPUB is done when EPUBCheck reports zero errors and zero warnings, it opens with a working table of contents in at least two reading systems, the cover and metadata are right in a library view, and accessibility metadata describes the book honestly.

Decide the route

SourceRouteNotes
Markdown or a few HTML filesPandoc (-t epub3)Default; handles nav, OPF, footnotes, math
DOCX manuscriptPandoc from docx, then fix stylesWord styles map to headings only if the author used Heading 1/2
Complex layout, custom semantics, existing EPUB to fixHand-built package (below)Full control over OPF and XHTML
Illustrated/children’s/comicsFixed-layout EPUB (rendition:layout pre-paginated)Only when reflow truly cannot work
Calibre user wanting format conversionebook-convert in.docx out.epubConvenient, but check its output with EPUBCheck too

Kindle: KDP ingests EPUB directly; do not produce MOBI (no longer accepted for new titles). Kindle-specific tweaks are rarely needed for reflowable text.

Workflow

- [ ] 1. Gather inputs: manuscript, cover, metadata, rights, ISBN or UUID
- [ ] 2. Clean the source structure (headings, images, footnotes)
- [ ] 3. Build the EPUB
- [ ] 4. Add accessibility metadata and alt text
- [ ] 5. Validate (bundled script, then EPUBCheck)
- [ ] 6. Test in reading systems and report

1. Inputs

  • Title, subtitle, author(s) with sort names, language (BCP 47, e.g. en-GB), publisher, publication date, description, subjects.
  • Identifier: ISBN for retail (urn:isbn:9781234567897), otherwise a stable UUID (urn:uuid:...). Never change it between editions of the same file.
  • Cover: JPEG or PNG, about 1600 x 2560 px (1:1.6), RGB, under 5 MB, title legible as a thumbnail. Stores add their own cover; do not put a separate “cover page” image inside the text as well unless the store asks.

2. Clean the source

  • One # heading per chapter; the split level and the TOC depend on this.
  • Images: real files, sensible size (max about 1600 px on the long side), alt text for every informative image, empty alt for decorative ones.
  • No hard page breaks, tabs or manual numbering; no text in images.
  • Footnotes as real footnotes (Markdown [^1]), not superscript numbers.

3a. Build with Pandoc

pandoc metadata.yaml front.md ch*.md back.md \
  -f markdown -t epub3 -o book.epub \
  --toc --toc-depth=2 --split-level=1 \
  --css=epub.css --epub-cover-image=cover.jpg \
  --epub-embed-font='fonts/*.woff2'

--split-level replaced --epub-chapter-level in Pandoc 3. Pandoc may not be installed; installing it needs network and approval.

# metadata.yaml
---
title: Field Notes on Shipping Software
subtitle: Essays
creator:
  - role: author
    text: Ana Example
    file-as: Example, Ana
identifier:
  - scheme: ISBN-13
    text: urn:isbn:9781234567897
publisher: Example Press
date: 2026-11-01
lang: en-GB
rights: (c) 2026 Ana Example. All rights reserved.
description: Twelve essays on building and running software teams.
subject: [Software engineering, Management]
---

Minimal reading-friendly CSS (never set body font size in px or force colours; reading systems let users change both):

body { margin: 0; line-height: 1.4; widows: 2; orphans: 2; }
h1 { break-before: page; margin: 2em 0 1em; font-size: 1.6em; }
p { margin: 0; text-indent: 1.2em; }
h1 + p, .noindent { text-indent: 0; }
img { max-width: 100%; height: auto; }
pre, code { white-space: pre-wrap; font-size: 0.85em; }
figure { margin: 1em 0; break-inside: avoid; }

3b. Hand-built package

Layout and required pieces (EPUB 3.3):

book/
├── mimetype                    # exactly "application/epub+zip", no newline
├── META-INF/container.xml      # points to OEBPS/content.opf
└── OEBPS/
    ├── content.opf             # metadata, manifest, spine
    ├── nav.xhtml               # <nav epub:type="toc">, required
    ├── css/book.css
    ├── images/cover.jpg        # manifest item properties="cover-image"
    └── text/ch01.xhtml ...     # XHTML5, well-formed XML

Key OPF parts:

<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="en">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:identifier id="bookid">urn:uuid:7f1c2a10-5b7e-4c1e-9a64-0d1b2c3d4e5f</dc:identifier>
    <dc:title>Field Notes</dc:title>
    <dc:language>en</dc:language>
    <dc:creator>Ana Example</dc:creator>
    <meta property="dcterms:modified">2026-10-28T09:00:00Z</meta>
  </metadata>
  <manifest>
    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
    <item id="cover" href="images/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
    <item id="css" href="css/book.css" media-type="text/css"/>
    <item id="ch01" href="text/ch01.xhtml" media-type="application/xhtml+xml"/>
  </manifest>
  <spine>
    <itemref idref="ch01"/>
  </spine>
</package>

Package it. Order and compression of mimetype matter; build from inside book/:

cd book
rm -f ../book.epub
zip -X0 ../book.epub mimetype            # first entry, stored, no extra fields
zip -Xr9D ../book.epub META-INF OEBPS    # everything else, compressed, no dir entries

Never zip the folder from a GUI or with zip -r book.epub book/; both put mimetype in the wrong place or compress it.

4. Accessibility

The European Accessibility Act applies to ebooks sold in the EU since 28 June 2025. Target EPUB Accessibility 1.1 with WCAG 2.x AA:

  • Language set on the package and on each XHTML <html lang xml:lang>.
  • Real headings in order, semantic lists and tables, epub:type and ARIA roles for footnotes, chapters and landmarks.
  • Alt text on images; long descriptions for complex charts.
  • A page list (<nav epub:type="page-list">) when the book has a print edition.
  • Metadata in the OPF describing the book honestly:
<meta property="schema:accessMode">textual</meta>
<meta property="schema:accessMode">visual</meta>
<meta property="schema:accessModeSufficient">textual</meta>
<meta property="schema:accessibilityFeature">structuralNavigation</meta>
<meta property="schema:accessibilityFeature">tableOfContents</meta>
<meta property="schema:accessibilityFeature">alternativeText</meta>
<meta property="schema:accessibilityHazard">none</meta>
<meta property="schema:accessibilitySummary">Reflowable text with full navigation; all images described.</meta>
<meta property="dcterms:conformsTo">EPUB Accessibility 1.1 - WCAG 2.2 Level AA</meta>

Only claim conformsTo after checking with Ace by DAISY (npx @daisy/ace book.epub, needs network to install).

5. Validate

Bundled offline check first (fast, standard library only):

python3 scripts/epub_check.py book.epub          # exit 1 on errors
python3 scripts/epub_check.py --json book.epub

It catches the frequent structural defects: wrong mimetype entry, missing required metadata, missing nav document, broken manifest or spine references, malformed XHTML, broken internal links and images without alt. Then run the authoritative validator:

java -jar epubcheck.jar book.epub                 # EPUBCheck 5.x, needs Java
java -jar epubcheck.jar book.epub --json report.json

EPUBCheck is a download (network). If it is unavailable, say the book passed the offline script only and is not yet store-validated.

Common EPUBCheck failures and fixes:

Message (abridged)Fix
mimetype entry missing or not firstRebuild with the two zip commands above
Fragment identifier not definedLink to an id that exists; Pandoc ids change with headings
Referenced resource missing from manifestAdd the file to the manifest or remove the reference
dcterms:modified missing or malformedYYYY-MM-DDThh:mm:ssZ, UTC
Non-XHTML markup (<br>, &nbsp;)Self-close tags; use &#160; (XHTML has no HTML named entities)
Remote resource not declaredEmbed fonts and images; avoid remote URLs in content

6. Test and report

Open in at least two reading systems available to the user (Apple Books, Thorium, Kobo, Calibre viewer, Kindle Previewer). Check: TOC links, cover in library view, footnote pop-ups, image sizing on a phone-width viewport, dark mode (no forced black text on transparent images), and that the language triggers correct hyphenation.

Gotchas

  • Smart quotes and apostrophes typed as straight quotes look amateurish in ebooks; Pandoc’s smart extension handles Markdown sources.
  • Fonts: embed only fonts licensed for ebook embedding; the reader may override them anyway.
  • Tables wider than a phone screen become unreadable; simplify or provide as an image with full alt text plus a linked data table.
  • File names with spaces or non-ASCII characters break some readers; use ch01.xhtml.
  • Changing the identifier makes stores and readers treat the update as a new book.

Output

## EPUB: field-notes.epub (EPUB 3.3, reflowable, 312 KB)
- Built with Pandoc 3.x from 14 Markdown files; split at level 1; TOC depth 2
- Metadata: ISBN 9781234567897, en-GB, cover 1600x2560
- Accessibility: alt text on 9/9 images, schema.org metadata, Ace: 0 violations
- Validation: epub_check.py 0 errors; EPUBCheck 5.x 0 errors, 0 warnings
- Tested: Apple Books (macOS), Thorium; TOC and footnotes work

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "EPUB Ebooks"
  short_description: "Ebooks that pass EPUBCheck the first time"
  default_prompt: "$epub-ebooks Turn the Markdown chapters in manuscript/ into an EPUB with a cover and proper table of contents"
scripts/epub_check.py160 lines, 6.4 KB
#!/usr/bin/env python3
"""Fast offline sanity check for an EPUB 3 file (standard library only).

Usage:
  python3 epub_check.py book.epub
  python3 epub_check.py --json book.epub

Checks the container rules that most often break readers and stores: mimetype entry
first, stored and exact; META-INF/container.xml present; OPF has required metadata
(identifier, title, language, dcterms:modified); a nav document exists; every manifest
item and spine itemref resolves; referenced XHTML files are well-formed; local links and
images inside XHTML resolve. It is NOT a replacement for EPUBCheck.

Exit codes: 0 = no errors (warnings allowed), 1 = errors found, 2 = cannot read file.
"""
import argparse
import json
import posixpath
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from urllib.parse import unquote, urldefrag

NS = {
    "c": "urn:oasis:names:tc:opendocument:xmlns:container",
    "opf": "http://www.idpf.org/2007/opf",
    "dc": "http://purl.org/dc/elements/1.1/",
    "x": "http://www.w3.org/1999/xhtml",
}
REMOTE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.I)


def check(path):
    errors, warnings = [], []
    try:
        z = zipfile.ZipFile(path)
    except (OSError, zipfile.BadZipFile) as exc:
        return None, [f"cannot open as zip: {exc}"], []
    infos = z.infolist()
    names = set(z.namelist())

    first = infos[0] if infos else None
    if not first or first.filename != "mimetype":
        errors.append("first zip entry must be 'mimetype'")
    else:
        if first.compress_type != zipfile.ZIP_STORED:
            errors.append("'mimetype' must be stored uncompressed")
        if first.extra:
            warnings.append("'mimetype' has a zip extra field; build with zip -X")
        if z.read("mimetype") != b"application/epub+zip":
            errors.append("'mimetype' content must be exactly application/epub+zip (no newline)")

    if "META-INF/container.xml" not in names:
        errors.append("missing META-INF/container.xml")
        return z, errors, warnings
    container = ET.fromstring(z.read("META-INF/container.xml"))
    rootfile = container.find(".//c:rootfile", NS)
    if rootfile is None:
        errors.append("container.xml has no rootfile")
        return z, errors, warnings
    opf_path = rootfile.get("full-path")
    if opf_path not in names:
        errors.append(f"package document {opf_path} not found")
        return z, errors, warnings
    opf_dir = posixpath.dirname(opf_path)
    opf = ET.fromstring(z.read(opf_path))

    version = opf.get("version", "")
    if not version.startswith("3"):
        warnings.append(f"package version is {version or 'missing'}; EPUB 3 expected")
    md = opf.find("opf:metadata", NS)
    if md is None:
        errors.append("OPF has no metadata element")
        md = ET.Element("empty")
    for tag in ("identifier", "title", "language"):
        el = md.find(f"dc:{tag}", NS)
        if el is None or not (el.text or "").strip():
            errors.append(f"metadata missing dc:{tag}")
    uid = opf.get("unique-identifier")
    if uid and md.find(f"dc:identifier[@id='{uid}']", NS) is None:
        errors.append(f"unique-identifier '{uid}' does not match a dc:identifier id")
    modified = [m for m in md.findall("opf:meta", NS) if m.get("property") == "dcterms:modified"]
    if not modified:
        errors.append("metadata missing meta property=dcterms:modified")
    elif not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", (modified[0].text or "").strip()):
        errors.append("dcterms:modified must look like 2026-01-31T12:00:00Z")
    if not any(m.get("property", "").startswith("schema:access") for m in md.findall("opf:meta", NS)):
        warnings.append("no schema.org accessibility metadata (accessMode, accessibilityFeature, ...)")

    manifest = {}
    has_nav = False
    for item in opf.findall("opf:manifest/opf:item", NS):
        href = posixpath.normpath(posixpath.join(opf_dir, unquote(item.get("href", ""))))
        manifest[item.get("id")] = (href, item.get("media-type", ""))
        if "nav" in (item.get("properties") or "").split():
            has_nav = True
        if href not in names:
            errors.append(f"manifest item {item.get('id')} -> {href} missing from zip")
    if not has_nav:
        errors.append("no manifest item with properties=\"nav\" (EPUB 3 navigation document)")

    spine = opf.findall("opf:spine/opf:itemref", NS)
    if not spine:
        errors.append("spine is empty")
    for ref in spine:
        if ref.get("idref") not in manifest:
            errors.append(f"spine itemref {ref.get('idref')} not in manifest")

    listed = {h for h, _ in manifest.values()}
    for n in names:
        if n in ("mimetype", opf_path) or n.startswith("META-INF/") or n.endswith("/"):
            continue
        if n not in listed:
            warnings.append(f"{n} is in the zip but not in the manifest")

    for href, mt in manifest.values():
        if mt != "application/xhtml+xml" or href not in names:
            continue
        try:
            doc = ET.fromstring(z.read(href))
        except ET.ParseError as exc:
            errors.append(f"{href} is not well-formed XHTML: {exc}")
            continue
        base = posixpath.dirname(href)
        for el in doc.iter():
            for attr in ("href", "src"):
                val = el.get(attr)
                if not val or REMOTE.match(val) or val.startswith("#"):
                    continue
                target = posixpath.normpath(posixpath.join(base, unquote(urldefrag(val)[0])))
                if target not in names:
                    errors.append(f"{href}: broken {attr} -> {val}")
            if el.tag == f"{{{NS['x']}}}img" and el.get("alt") is None:
                warnings.append(f"{href}: <img src={el.get('src')}> has no alt attribute")
    return z, errors, warnings


def main():
    ap = argparse.ArgumentParser(description="Offline EPUB 3 sanity check")
    ap.add_argument("epub")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()
    z, errors, warnings = check(args.epub)
    if z is None:
        print(f"error: {errors[0]}", file=sys.stderr)
        return 2
    if args.json:
        print(json.dumps({"errors": errors, "warnings": warnings}, indent=2))
    else:
        for e in errors:
            print(f"ERROR   {e}")
        for w in warnings:
            print(f"WARNING {w}")
        print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc