EPUB Ebooks skill for Claude Code

Ebooks that pass epubcheck and read well anywhere. Claude structures a manuscript into chapters with semantic markup and alt text, writes metadata and reader-friendly CSS, and builds the EPUB with one pandoc command. A bundled linter checks mimetype, OPF manifest, spine, nav and broken links in seconds before epubcheck and Ace by DAISY run, and the book is opened in real readers before it goes to KDP, Apple Books or Kobo.

  • nameepub-ebooks
  • size205 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Produces valid, accessible EPUB 3 ebooks - source structure in Markdown or HTML, pandoc builds with metadata files, chapter splitting, cover, CSS and embedded fonts, the EPUB package anatomy (mimetype, container.xml, OPF manifest and spine, nav document), accessibility metadata and semantics, manual fixes in Sigil, conversions with Calibre, validation with epubcheck and Ace by DAISY, plus a bundled structural lint script, and store requirements (KDP, Apple Books, Kobo, Google Play). Use when creating an ebook from a manuscript, fixing epubcheck errors, a store rejects an EPUB, or automating ebook builds.

Try asking

  • Turn this folder of Markdown chapters into an EPUB with a cover and TOC
  • KDP rejected our EPUB - fix the epubcheck errors
  • Add accessibility metadata so our ebooks meet the European Accessibility Act

Use it when

  • Creating an ebook from Markdown, DOCX, or HTML sources
  • epubcheck reports errors or a store rejects an upload
  • Adding accessibility metadata and semantics to ebooks
  • Automating repeatable ebook builds

Skip it when

How to check the result

scripts/epub_lint.py exits 0, epubcheck reports no errors or warnings, Ace shows no serious violations, and cover, TOC and links work in two reading apps.

The common mistake it prevents

Zipping the book folder with a normal zip command, which compresses mimetype or puts it after other entries, so readers and stores reject the file.

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

--- name: epub-ebooks description: Produces valid, accessible EPUB 3 ebooks - source structure in Markdown or HTML, pandoc builds with metadata files, chapter splitting,… license: MIT ---

An ebook is done when epubcheck reports zero errors and zero warnings, the navigation document lists every chapter, the cover and metadata are correct in a real reading app, text reflows cleanly at large font sizes, images have alt text, and the build is one repeatable command from source.

Workflow

- [ ] 1. Gather inputs: manuscript, cover image, metadata (title, author, language, ISBN/UUID, publisher, date)
- [ ] 2. Structure the source: one file or heading level per chapter, front and back matter
- [ ] 3. Write metadata and CSS files
- [ ] 4. Build with pandoc (default) or hand-assemble for fixed-layout or complex cases
- [ ] 5. Lint quickly with scripts/epub_lint.py, then epubcheck
- [ ] 6. Check accessibility with Ace; fix semantics and metadata
- [ ] 7. Open in two or more readers; check store-specific requirements

2. Source structure

book/
  metadata.yaml
  epub.css
  cover.jpg                # 1600x2560 px (1:1.6) is a safe default; check each store
  chapters/
    00-front.md            # title page, copyright, dedication
    01-introduction.md
    02-first-steps.md
    99-acknowledgments.md
  images/
  fonts/                   # only fonts whose license allows ebook embedding
  • One # heading per chapter; use ##/### inside. Do not skip levels.
  • Write semantic Markdown: real lists, real headings, footnotes with [^1], emphasis instead of manual bold for titles. Do not fake layout with line breaks or spaces.
  • Images with descriptive alt text: ![Bar chart of monthly sales, peaking in March](images/sales.png). Decorative images get empty alt text.
  • Tables must be small enough to read on a phone; convert wide tables to lists or images with a text alternative.

3. Metadata and CSS

# metadata.yaml
title: "Field Notes on Data"
subtitle: "A practical guide"
author: ["Ana Lopez"]
lang: en-US
identifier:
  - scheme: ISBN-13
    text: "978-1-23456-789-7"
publisher: "Example Press"
date: "2026-10-20"
rights: "Copyright 2026 Ana Lopez. All rights reserved."
description: "A practical guide to collecting and cleaning data."
subject: ["Data science", "Reference"]
/* epub.css - let the reader control size and font; style structure only */
body { margin: 0; line-height: 1.5; }
h1 { break-before: page; margin-top: 3em; font-size: 1.6em; }
h1, h2, h3 { break-after: avoid; hyphens: none; }
p { margin: 0; text-indent: 1.5em; orphans: 2; widows: 2; }
h1 + p, h2 + p, h3 + p { text-indent: 0; }
img { max-width: 100%; height: auto; }
figure { break-inside: avoid; margin: 1em 0; }
pre, code { font-family: monospace; white-space: pre-wrap; font-size: 0.85em; }

Avoid fixed font sizes in px, fixed widths, absolute positioning, and dark backgrounds; reading apps apply their own themes and night modes.

4. Build with pandoc

pandoc chapters/*.md \
  --metadata-file=metadata.yaml \
  --toc --toc-depth=2 \
  --split-level=1 \
  --epub-cover-image=cover.jpg \
  --css=epub.css \
  --epub-embed-font='fonts/*.woff2' \
  --resource-path=.:images \
  -o build/field-notes.epub
  • Output is EPUB 3 by default (-t epub3); -t epub2 only for a legacy requirement.
  • --split-level (pandoc 3.x) controls which heading level starts a new XHTML file; pandoc 2.x used --epub-chapter-level. Check pandoc --version.
  • Extra OPF metadata pandoc does not model (accessibility, series) can be supplied with --epub-metadata=extra.xml containing Dublin Core and <meta> elements.
  • Glob order sets chapter order; prefix filenames with numbers.
  • Keep the build in a Makefile or script and put build/ in .gitignore.

Package anatomy (for debugging and hand builds)

mimetype                       # first entry, uncompressed, content: application/epub+zip
META-INF/container.xml         # points to the package document
OEBPS/content.opf              # metadata, manifest (every file), spine (reading order)
OEBPS/nav.xhtml                # EPUB 3 navigation (toc, landmarks, page-list)
OEBPS/text/*.xhtml             # content documents (XHTML, must be well-formed XML)
OEBPS/styles, images, fonts

Zip it correctly when assembling by hand:

cd book-src
rm -f ../book.epub
zip -X0 ../book.epub mimetype
zip -Xr9D ../book.epub META-INF OEBPS

Required EPUB 3 metadata: dc:identifier matching the package unique-identifier, dc:title, dc:language, and <meta property="dcterms:modified">2026-10-20T10:00:00Z</meta> updated on every build.

5. Validate

python3 scripts/epub_lint.py build/field-notes.epub      # seconds; structural pre-check
epubcheck build/field-notes.epub                          # or: java -jar epubcheck.jar ...
epubcheck build/field-notes.epub --json build/epubcheck.json

epub_lint.py checks mimetype placement and compression, container and OPF references, required metadata, manifest coverage, spine, nav presence, XML well-formedness, broken local links, and missing accessibility metadata. It exits 1 on errors. It is a quick pre-check; epubcheck (the W3C/DAISY validator, current 5.x line) is authoritative and stores run it on upload.

Common epubcheck errors and fixes:

Message (paraphrased)Fix
mimetype entry missing, compressed, or not firstrezip with the commands above
Referenced resource not found / not declared in manifestfix the path or add a manifest item with the right media-type
Fragment identifier not defineda link points to an #id that does not exist; fix TOC or cross-references
XHTML parsing error (unclosed tag, &nbsp;)make content well-formed XML; use &#160; instead of HTML-only entities
dcterms:modified missing or malformedadd with format CCYY-MM-DDThh:mm:ssZ
Non-unique idduplicate id attributes after merging chapters; make them unique
Font or image media type mismatchdeclared media-type must match the file (font/woff2, image/jpeg)

6. Accessibility

EPUB Accessibility 1.1 expects discoverability metadata and real semantics; the European Accessibility Act made this a sales requirement for many ebooks sold in the EU from June 2025.

<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">alternativeText</meta>
<meta property="schema:accessibilityFeature">tableOfContents</meta>
<meta property="schema:accessibilityHazard">none</meta>
<meta property="schema:accessibilitySummary">Headings and navigation follow the book structure; all images have text alternatives.</meta>
  • Set lang on the package and xml:lang/lang on content; mark foreign-language passages.
  • Add landmarks in the nav (cover, toc, bodymatter) and a page-list if the book has a print edition with page numbers.
  • Run Ace by DAISY (ace -o build/ace-report build/field-notes.epub) and fix violations; only claim conformance (dcterms:conformsTo) after checking manually.

7. Readers and stores

  • Open in at least two engines (Apple Books, Thorium, Kobo, Calibre viewer) and check: cover, TOC navigation, links, footnote pop-ups, images at small screen sizes, large font.
  • Amazon KDP accepts EPUB uploads (MOBI is no longer accepted for new titles) and converts internally; preview in Kindle Previewer.
  • Each store has its own cover size and metadata rules; read their current guidelines before upload rather than assuming.
  • Calibre (ebook-convert in.docx out.epub) is handy for conversions but produces generic markup; prefer pandoc or clean source for production books. Use Sigil for targeted manual fixes, then re-validate.

Gotchas

  • Smart quotes, dashes, and non-breaking spaces from Word paste can carry hidden formatting; convert DOCX with pandoc rather than copy-paste.
  • Embedded fonts require a license that allows ebook embedding; many desktop licenses do not.
  • SVG covers and complex CSS (flex/grid, floats) render inconsistently across readers.
  • Fixed-layout EPUB (comics, picture books) needs different metadata (rendition:layout pre-paginated) and per-page viewport settings; do not use it for prose.
  • DRM is applied by stores, not in the file you build.

Verification

  • scripts/epub_lint.py exits 0 and epubcheck reports 0 errors and 0 warnings.
  • Ace report has no serious or critical violations; accessibility metadata present.
  • Manual check in two readers: cover, TOC, three internal links, one footnote, one image.
  • The full build reruns from a clean checkout with one command.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/epub_lint.py 186 lines · 7.7 KB
#!/usr/bin/env python3
"""Fast structural pre-check for EPUB files (stdlib only). Not a replacement for epubcheck.

Usage:
  python3 epub_lint.py book.epub [more.epub ...]

Checks:
  - mimetype is the first ZIP entry, stored uncompressed, exactly "application/epub+zip"
  - META-INF/container.xml points to an existing package document (OPF)
  - OPF metadata: unique identifier, title, language, dcterms:modified (EPUB 3)
  - every manifest href exists; files in the ZIP missing from the manifest
  - spine itemrefs resolve; EPUB 3 nav document present; cover image declared
  - XHTML/SVG/NCX/OPF files are well-formed XML
  - local href/src references inside content documents resolve
  - accessibility metadata (schema:accessMode, accessibilityFeature, accessibilityHazard,
    accessibilitySummary) present (warning only)

Exit 0 when no errors (warnings allowed), 1 when any error is found or the file is unreadable.
"""
import posixpath
import re
import sys
import zipfile
from urllib.parse import unquote, urldefrag
from xml.etree import ElementTree as ET

NS = {
    "c": "urn:oasis:names:tc:opendocument:xmlns:container",
    "opf": "http://www.idpf.org/2007/opf",
    "dc": "http://purl.org/dc/elements/1.1/",
}
XML_TYPES = ("application/xhtml+xml", "image/svg+xml", "application/x-dtbncx+xml")
A11Y = ["schema:accessMode", "schema:accessibilityFeature", "schema:accessibilityHazard", "schema:accessibilitySummary"]
REF_RE = re.compile(r'\b(?:href|src)\s*=\s*"([^"]+)"', re.I)


def lint(path):
    errors, warnings = [], []
    try:
        zf = zipfile.ZipFile(path)
    except (OSError, zipfile.BadZipFile) as e:
        return [f"cannot open as ZIP: {e}"], []
    infos = zf.infolist()
    names = set(zf.namelist())

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

    # container
    if "META-INF/container.xml" not in names:
        errors.append("missing META-INF/container.xml")
        return errors, warnings
    try:
        container = ET.fromstring(zf.read("META-INF/container.xml"))
    except ET.ParseError as e:
        return errors + [f"container.xml not well-formed: {e}"], warnings
    rootfile = container.find(".//c:rootfile", NS)
    if rootfile is None or not rootfile.get("full-path"):
        return errors + ["container.xml has no rootfile full-path"], warnings
    opf_path = rootfile.get("full-path")
    if opf_path not in names:
        return errors + [f"package document not found: {opf_path}"], warnings
    opf_dir = posixpath.dirname(opf_path)

    try:
        opf = ET.fromstring(zf.read(opf_path))
    except ET.ParseError as e:
        return errors + [f"{opf_path} not well-formed: {e}"], warnings
    version = opf.get("version", "?")
    uid_ref = opf.get("unique-identifier")

    md = opf.find("opf:metadata", NS)
    if md is None:
        return errors + ["OPF has no <metadata>"], warnings
    ids = md.findall("dc:identifier", NS)
    if not ids:
        errors.append("metadata: missing dc:identifier")
    elif uid_ref and not any(i.get("id") == uid_ref for i in ids):
        errors.append(f"metadata: unique-identifier '{uid_ref}' does not match any dc:identifier id")
    if not (md.findtext("dc:title", default="", namespaces=NS) or "").strip():
        errors.append("metadata: missing dc:title")
    if not (md.findtext("dc:language", default="", namespaces=NS) or "").strip():
        errors.append("metadata: missing dc:language")
    metas = md.findall("opf:meta", NS)
    props = {m.get("property"): (m.text or "").strip() for m in metas if m.get("property")}
    if version.startswith("3"):
        mod = props.get("dcterms:modified", "")
        if not mod:
            errors.append("metadata: EPUB 3 requires meta property='dcterms:modified'")
        elif not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", mod):
            errors.append(f"metadata: dcterms:modified must be CCYY-MM-DDThh:mm:ssZ, got '{mod}'")
        missing = [a for a in A11Y if a not in props]
        if missing:
            warnings.append("accessibility metadata missing: " + ", ".join(missing))
    if not md.findall("dc:creator", NS):
        warnings.append("metadata: no dc:creator (author)")

    # manifest
    manifest = {}
    man = opf.find("opf:manifest", NS)
    if man is None:
        return errors + ["OPF has no <manifest>"], warnings
    for item in man.findall("opf:item", NS):
        iid, href, mt = item.get("id"), item.get("href"), item.get("media-type", "")
        if not iid or not href:
            errors.append("manifest item without id or href")
            continue
        full = posixpath.normpath(posixpath.join(opf_dir, unquote(href)))
        manifest[iid] = (full, mt, item.get("properties", ""))
        if full not in names and not href.startswith(("http://", "https://")):
            errors.append(f"manifest href not in ZIP: {href}")

    declared = {v[0] for v in manifest.values()}
    for n in sorted(names):
        if n.endswith("/") or n == "mimetype" or n.startswith("META-INF/") or n == opf_path:
            continue
        if n not in declared:
            warnings.append(f"file not declared in manifest: {n}")

    if version.startswith("3"):
        if not any("nav" in p.split() for _, _, p in manifest.values()):
            errors.append("EPUB 3 manifest needs an item with properties='nav'")
        if not any("cover-image" in p.split() for _, _, p in manifest.values()):
            warnings.append("no manifest item with properties='cover-image'")

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

    # well-formedness and local references
    for full, mt, _ in manifest.values():
        if full not in names:
            continue
        if mt in XML_TYPES:
            data = zf.read(full)
            try:
                ET.fromstring(data)
            except ET.ParseError as e:
                errors.append(f"not well-formed XML: {full}: {e}")
                continue
            if mt == "application/xhtml+xml":
                base = posixpath.dirname(full)
                for ref in REF_RE.findall(data.decode("utf-8", "replace")):
                    target, _frag = urldefrag(ref)
                    if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I):
                        continue
                    tp = posixpath.normpath(posixpath.join(base, unquote(target)))
                    if tp not in names:
                        errors.append(f"broken reference in {full}: {ref}")
    return errors, warnings


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        return 1
    failed = False
    for path in sys.argv[1:]:
        errors, warnings = lint(path)
        print(f"== {path}: {len(errors)} error(s), {len(warnings)} warning(s)")
        for e in errors:
            print(f"  ERROR   {e}")
        for w in warnings:
            print(f"  WARNING {w}")
        failed |= bool(errors)
    print("\nRun epubcheck for full validation before publishing.")
    return 1 if failed else 0


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

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc