mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-15 21:46:29 +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)
179 lines
5.6 KiB
Python
Executable File
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()
|