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.
- name
epub-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
- You need a print-ready PDF - use html-to-pdf or latex-documents
- You want HTML or DOCX output from Markdown - use markdown-publishing
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.
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:
. 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 epub2only 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. Checkpandoc --version.- Extra OPF metadata pandoc does not model (accessibility, series) can be supplied with
--epub-metadata=extra.xmlcontaining 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 first | rezip with the commands above |
| Referenced resource not found / not declared in manifest | fix the path or add a manifest item with the right media-type |
| Fragment identifier not defined | a link points to an #id that does not exist; fix TOC or cross-references |
XHTML parsing error (unclosed tag, ) | make content well-formed XML; use   instead of HTML-only entities |
| dcterms:modified missing or malformed | add with format CCYY-MM-DDThh:mm:ssZ |
| Non-unique id | duplicate id attributes after merging chapters; make them unique |
| Font or image media type mismatch | declared 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
langon the package andxml:lang/langon content; mark foreign-language passages. - Add
landmarksin the nav (cover, toc, bodymatter) and apage-listif 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.pyexits 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
#!/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.