Files
Magnus Hedemark 88e92f4472 feat: add epub skill v2 — 11 scripts, 9 references, 46 tests
Complete EPUB creation, editing, validation, and knowledge extraction
skill for the Agent Skills open format. Built from spec research, real
EPUB testing on 2.1MB commercial Apress title, and Apple Books
compatibility verification on macOS 26.

Scripts (11):
  epub-scaffold    — Create valid EPUB3 with cover XHTML, Apple Books CSS
  epub-edit        — Surgical editing (8 subcommands, epublib)
  epub-info        — Structure/metadata dump as JSON
  epub-text        — Clean text extraction, per-chapter or single-file
  epub-extract-knowledge — Heuristic + LLM extraction (env var auto-detect)
  epub-validate    — EPUBCheck or Python fallback validation
  epub-images      — List/extract all images with cover detection
  epub-batch       — Multi-file processing (extract-text, validate, metadata)
  epub-convert     — EPUB2→EPUB3 conversion with validation
  epub-repair      — Diagnose & auto-fix common structural issues
  epub-cover       — Add cover XHTML wrapper for Apple Books compatibility

References (9):
  epub-format-internals.md, python-libraries.md, spec-and-validation.md,
  tutorials-and-guides.md, agent-capability-discovery.md,
  fixed-layout-epub.md, accessibility.md, media-overlays.md,
  apple-books-compatibility.md (NEW — verified on macOS 26)

Test: 46/46 passing (test_epub_skill.sh)
2026-05-23 18:08:48 -04:00

299 lines
9.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""epub-scaffold — Create a minimal valid EPUB3 from scratch.
All content lives inside OEBPS/ — required for Apple Books compatibility.
Chapters go in OEBPS/Text/, CSS in OEBPS/Styles/, images in OEBPS/Images/.
Usage:
epub-scaffold --title "Title" --author "Name" [--language en] [--output FILE]
[--chapters N] [--cover IMAGE] [--toc-visible/--toc-hidden]
[--json] [--dry-run]
Examples:
epub-scaffold --title "My Book" --author "Jane Doe" --output book.epub
epub-scaffold --title "Novel" --author "Me" --chapters 12 --json
epub-scaffold --title "Guide" --author "Me" --cover cover.jpg --dry-run
epub-scaffold --title "Guide" --author "Me" --cover cover.jpg --toc-hidden
Dependencies: Python stdlib only (no external packages required).
"""
import argparse
import json
import sys
import os
import zipfile
import uuid
import warnings
warnings.filterwarnings("ignore")
DRY_RUN = False
JSON_OUTPUT = False
def die(msg):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
def emit(json_data, text=""):
if JSON_OUTPUT:
print(json.dumps(json_data, indent=2, default=str))
else:
print(text)
def make_epub(output_path, title, author, language, num_chapters, cover_path, toc_visible):
"""Create a minimal valid EPUB3 file with Apple Books compatibility."""
book_id = f"urn:uuid:{uuid.uuid4()}"
nav_linear = "yes" if toc_visible else "no"
cover_ext = None
cover_mime = None
if cover_path:
cover_ext = os.path.splitext(cover_path)[1].lower()
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.svg': 'image/svg+xml'}
cover_mime = mime_map.get(cover_ext, 'image/png')
# ── OPF ──
opf_parts = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">',
' <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">',
f' <dc:identifier id="book-id">{book_id}</dc:identifier>',
f' <dc:title>{_xml_escape(title)}</dc:title>',
f' <dc:creator>{_xml_escape(author)}</dc:creator>',
f' <dc:language>{language}</dc:language>',
f' <meta property="dcterms:modified">{_now_iso()}</meta>',
' </metadata>',
' <manifest>',
' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
' <item id="css" href="Styles/default.css" media-type="text/css"/>',
]
if cover_path:
opf_parts.append(f' <item id="cover-img" href="Images/cover{cover_ext}" media-type="{cover_mime}" properties="cover-image"/>')
opf_parts.append(f' <item id="cover-page" href="Text/cover.xhtml" media-type="application/xhtml+xml"/>')
chapter_nav_items = []
for i in range(1, num_chapters + 1):
ch_id = f"chapter{i}"
ch_file = f"Text/chapter{i}.xhtml"
opf_parts.append(f' <item id="{ch_id}" href="{ch_file}" media-type="application/xhtml+xml"/>')
chapter_nav_items.append(f' <li><a href="{ch_file}">Chapter {i}</a></li>')
opf_parts.append(' </manifest>')
opf_parts.append(' <spine>')
if cover_path:
opf_parts.append(' <itemref idref="cover-page"/>')
opf_parts.append(f' <itemref idref="nav" linear="{nav_linear}"/>')
for i in range(1, num_chapters + 1):
opf_parts.append(f' <itemref idref="chapter{i}"/>')
opf_parts.append(' </spine>')
opf_parts.append('</package>')
opf = '\n'.join(opf_parts)
# ── NAV ──
nav = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="{language}" lang="{language}">
<head>
<title>Contents</title>
<link rel="stylesheet" href="Styles/default.css" type="text/css"/>
</head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
{chr(10).join(chapter_nav_items)}
</ol>
</nav>
</body>
</html>'''
# ── Container ──
container = '''<?xml version="1.0" encoding="UTF-8"?>
<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>'''
# ── Chapters ──
chapters_content = {}
for i in range(1, num_chapters + 1):
chapters_content[f"OEBPS/Text/chapter{i}.xhtml"] = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{language}" lang="{language}">
<head>
<title>Chapter {i}</title>
<link rel="stylesheet" href="../Styles/default.css" type="text/css"/>
</head>
<body>
<h1>Chapter {i}</h1>
<p>Content for chapter {i}.</p>
</body>
</html>'''
# ── Cover XHTML wrapper ──
if cover_path:
chapters_content[f"OEBPS/Text/cover.xhtml"] = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{language}" lang="{language}">
<head>
<title>Cover</title>
<style>body{{margin:0;padding:0;text-align:center;display:flex;align-items:center;justify-content:center;min-height:100vh}}img{{max-width:100%;max-height:100vh;width:auto;height:auto}}</style>
</head>
<body>
<img src="../Images/cover{cover_ext}" alt="Cover: {_xml_escape(title)}"/>
</body>
</html>'''
# ── ZIP ──
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr(zipfile.ZipInfo('mimetype'), 'application/epub+zip',
compress_type=zipfile.ZIP_STORED)
zf.writestr('META-INF/container.xml', container)
zf.writestr('OEBPS/content.opf', opf)
zf.writestr('OEBPS/nav.xhtml', nav)
for path, content in chapters_content.items():
zf.writestr(path, content)
zf.writestr('OEBPS/Styles/default.css', _default_stylesheet())
if cover_path and os.path.exists(cover_path):
zf.write(cover_path, f'OEBPS/Images/cover{cover_ext}')
return output_path
def _now_iso():
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def _xml_escape(s):
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _default_stylesheet():
"""Apple Books compatible default CSS — no deprecated properties."""
return '''/* Default EPUB stylesheet — Apple Books compatible */
body {
font-family: serif;
font-size: 1em;
line-height: 1.6;
margin: 0;
padding: 0 0.5em;
color: #1a1a1a;
}
h1 {
font-size: 1.5em;
margin: 0.8em 0 0.4em 0;
line-height: 1.3;
}
h2 {
font-size: 1.25em;
margin: 0.8em 0 0.3em 0;
line-height: 1.3;
}
h3 {
font-size: 1.1em;
margin: 0.6em 0 0.2em 0;
}
p {
margin: 0 0 0.7em 0;
orphans: 2;
widows: 2;
}
ul, ol {
margin: 0.4em 0;
padding-left: 1.2em;
}
li {
margin-bottom: 0.2em;
}
strong { font-weight: bold; }
em { font-style: italic; }
blockquote {
margin: 0.8em 0;
padding: 0.4em 0.8em;
border-left: 3px solid #ccc;
font-style: italic;
}
img {
max-width: 100%;
height: auto;
}
'''
def main():
global DRY_RUN, JSON_OUTPUT
parser = argparse.ArgumentParser(
description='Create a minimal valid EPUB3 from scratch.',
epilog='Examples:\n epub-scaffold --title "My Book" --author "Jane Doe"\n epub-scaffold --title "Novel" --author "Me" --chapters 12')
parser.add_argument('--title', required=True, help='Book title')
parser.add_argument('--author', required=True, help='Author name')
parser.add_argument('--language', default='en', help='Language code (default: en)')
parser.add_argument('--output', '-o', default='book.epub', help='Output file path (default: book.epub)')
parser.add_argument('--chapters', type=int, default=1, help='Number of empty chapters (default: 1)')
parser.add_argument('--cover', help='Path to cover image (JPG, PNG, GIF, SVG)')
parser.add_argument('--toc-visible', action='store_true', default=True, help='ToC visible in reading flow (default)')
parser.add_argument('--toc-hidden', action='store_true', help='ToC hidden from reading flow (accessible via app browser)')
parser.add_argument('--json', action='store_true', help='Output as JSON')
parser.add_argument('--dry-run', '-n', action='store_true', help='Preview without creating file')
args = parser.parse_args()
JSON_OUTPUT = args.json
DRY_RUN = args.dry_run
toc_visible = not args.toc_hidden
if args.cover and not args.dry_run and not os.path.exists(args.cover):
die(f"Cover image not found: {args.cover}")
if DRY_RUN:
emit({
'status': 'dry_run',
'title': args.title,
'author': args.author,
'language': args.language,
'output': args.output,
'chapters': args.chapters,
'cover': args.cover or None,
'toc_visible': toc_visible,
}, f'[dry-run] Would create EPUB: {args.output}')
return
output = make_epub(args.output, args.title, args.author, args.language,
args.chapters, args.cover, toc_visible)
emit({
'status': 'created',
'output': output,
'title': args.title,
'author': args.author,
'language': args.language,
'chapters': args.chapters,
'has_cover': bool(args.cover),
'toc_visible': toc_visible,
}, f'Created EPUB: {output} ({args.chapters} chapter(s))')
if __name__ == '__main__':
main()