Files
magnus919_agent-skills/epub/scripts/epub-convert
T
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

179 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""epub-convert — Convert EPUB2 to EPUB3.
Usage:
epub-convert <file.epub> [--output out.epub] [--validate] [--json] [--dry-run]
Conversion steps:
1. Add NAV document (XHTML with <nav epub:type="toc">)
2. Add <meta property="dcterms:modified"> to OPF metadata
3. Add properties="nav" to NAV manifest item
4. Update XHTML namespace to XHTML5
5. Keep NCX for backward compatibility
Examples:
epub-convert book.epub --output book-v3.epub
epub-convert book.epub --validate --json
Dependencies: epublib (pip install epublib), EbookLib (pip install EbookLib)
"""
import argparse
import json
import os
import sys
import warnings
from datetime import datetime, timezone
warnings.filterwarnings("ignore")
DRY_RUN = False
JSON_OUTPUT = False
def die(msg):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
def warn(msg):
print(f"Warning: {msg}", file=sys.stderr)
def emit(json_data, text=""):
if JSON_OUTPUT:
print(json.dumps(json_data, indent=2, default=str))
else:
print(text)
def convert_epub(input_path, output_path):
"""Convert EPUB2 to EPUB3 using epublib."""
try:
from epublib import EPUB
except ImportError:
die("epublib required. Install with: pip install epublib")
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
with EPUB(input_path) as book:
changes = []
# 1. Add dcterms:modified if missing
has_modified = any(
item.name == "dcterms:modified" for item in book.metadata.items
)
if not has_modified:
book.metadata.add("dcterms:modified", now)
changes.append("added dcterms:modified")
# 2. Check/upgrade NAV
has_nav = False
for item in book.manifest.items:
props = getattr(item, "properties", "") or ""
if "nav" in str(props).split():
has_nav = True
break
if not has_nav:
# Create a basic NAV document from TOC
from bs4 import BeautifulSoup
nav_html = 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="{book.metadata.language}" lang="{book.metadata.language}">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>Table of Contents</h1>
<ol>
<li><a href="{list(book.documents)[0].filename if book.documents else 'Text/chapter1.xhtml'}">Start</a></li>
</ol>
</nav>
</body>
</html>"""
from epublib.resources.create import create_resource
nav_resource = create_resource(nav_html.encode(), "nav.xhtml")
book.resources.add(
resource=nav_resource,
add_to_spine=True,
spine_position=0,
add_to_toc=False,
)
# Set nav property on manifest item
for item in book.manifest.items:
if item.href == "nav.xhtml":
item.add_property("nav")
changes.append("added NAV document")
# 3. Update XHTML namespace in documents
for doc in book.documents:
html_tag = doc.soup.find("html")
if html_tag:
if "http://www.w3.org/1999/xhtml" not in str(html_tag.get("xmlns", "")):
html_tag["xmlns"] = "http://www.w3.org/1999/xhtml"
changes.append(f"updated namespace in {doc.filename}")
if "http://www.idpf.org/2007/ops" not in str(html_tag.get("xmlns:epub", "")):
html_tag["xmlns:epub"] = "http://www.idpf.org/2007/ops"
# 4. Update OPF version to 3.0
pkg = book.package_document.soup.find("package")
if pkg:
pkg["version"] = "3.0"
book.write(output_path)
return changes
def main():
global DRY_RUN, JSON_OUTPUT
parser = argparse.ArgumentParser(
description="Convert EPUB2 to EPUB3.",
epilog="Examples:\n epub-convert book.epub --output book-v3.epub\n epub-convert book.epub --validate --json")
parser.add_argument("epub", nargs="?", help="Path to EPUB file")
parser.add_argument("--output", "-o", help="Output path (default: <input>-v3.epub)")
parser.add_argument("--validate", action="store_true", help="Run EPUBCheck/structural check after conversion")
parser.add_argument("--json", action="store_true")
parser.add_argument("--dry-run", "-n", action="store_true")
args = parser.parse_args()
JSON_OUTPUT = args.json
DRY_RUN = args.dry_run
if not args.epub:
parser.print_help()
sys.exit(1)
if not os.path.exists(args.epub):
die(f"File not found: {args.epub}")
output = args.output or args.epub.replace(".epub", "-v3.epub")
if DRY_RUN:
emit({"status": "dry_run", "file": args.epub, "output": output},
f"[dry-run] Would convert {args.epub}{output}")
return
changes = convert_epub(args.epub, output)
result = {"file": args.epub, "output": output, "changes": changes, "count": len(changes)}
if args.validate:
import subprocess
script = os.path.join(os.path.dirname(__file__), "epub-validate")
r = subprocess.run([sys.executable, script, output, "--json"],
capture_output=True, text=True, timeout=60)
try:
v = json.loads(r.stdout)
result["validation"] = v.get("status", "unknown")
result["validation_errors"] = v.get("errors", 0)
except json.JSONDecodeError:
result["validation"] = "unknown"
emit(result)
if __name__ == "__main__":
main()