mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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)
299 lines
9.5 KiB
Python
Executable File
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('&', '&').replace('<', '<').replace('>', '>')
|
|
|
|
|
|
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()
|