mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-20 16:16:25 +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)
324 lines
13 KiB
Python
Executable File
324 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""epub-repair — Diagnose and automatically fix common EPUB structural issues.
|
|
|
|
Usage:
|
|
epub-repair <file.epub> [--output out.epub] [--in-place] [--diagnose] [--json] [--dry-run]
|
|
|
|
Fixes applied:
|
|
1. Regenerate missing NAV document
|
|
2. Add missing manifest entries for orphaned files
|
|
3. Fix manifest-vs-filesystem mismatches
|
|
4. Correct mimetype compression (re-zip with STORED)
|
|
5. Add required metadata fields (dc:language, dc:identifier if missing)
|
|
|
|
Examples:
|
|
epub-repair broken.epub --output fixed.epub # auto-fix
|
|
epub-repair broken.epub --diagnose --json # list fixable issues only
|
|
|
|
Dependencies: Python stdlib (zipfile, xml.etree). EPUBCheck for validation (optional).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import uuid
|
|
import warnings
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
DRY_RUN = False
|
|
JSON_OUTPUT = False
|
|
IN_PLACE = 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 diagnose(epub_path):
|
|
"""Diagnose fixable issues. Returns list of issue dicts."""
|
|
issues = []
|
|
|
|
if not os.path.exists(epub_path):
|
|
return [{"fixable": False, "issue": "file_not_found", "message": "File not found"}]
|
|
|
|
try:
|
|
with zipfile.ZipFile(epub_path, "r") as zf:
|
|
names = zf.namelist()
|
|
|
|
# 1. mimetype compression
|
|
if "mimetype" in names:
|
|
info = zf.getinfo("mimetype")
|
|
if info.compress_type != zipfile.ZIP_STORED:
|
|
issues.append({"fixable": True, "issue": "mimetype_compressed",
|
|
"message": "mimetype is compressed — will re-store"})
|
|
|
|
# 2. Missing NAV document
|
|
has_nav = any("nav" in n.lower() and n.endswith(".xhtml") for n in names)
|
|
if not has_nav:
|
|
issues.append({"fixable": True, "issue": "missing_nav",
|
|
"message": "No NAV document found — will generate one"})
|
|
|
|
# 3. Check container.xml
|
|
if "META-INF/container.xml" not in names:
|
|
issues.append({"fixable": True, "issue": "missing_container",
|
|
"message": "container.xml missing — will regenerate"})
|
|
|
|
# 4. Parse OPF and check manifest vs filesystem
|
|
container_xml = None
|
|
if "META-INF/container.xml" in names:
|
|
container_xml = zf.read("META-INF/container.xml")
|
|
|
|
opf_path = None
|
|
if container_xml:
|
|
try:
|
|
ns = {"c": "urn:oasis:names:tc:opendocument:xmlns:container"}
|
|
root = ET.fromstring(container_xml)
|
|
rf = root.find(".//c:rootfile", ns)
|
|
if rf is not None:
|
|
opf_path = rf.get("full-path")
|
|
except ET.ParseError:
|
|
pass
|
|
|
|
if opf_path and opf_path in names:
|
|
try:
|
|
opf_xml = zf.read(opf_path)
|
|
opf_ns = {"opf": "http://www.idpf.org/2007/opf",
|
|
"dc": "http://purl.org/dc/elements/1.1/"}
|
|
opf_root = ET.fromstring(opf_xml)
|
|
|
|
# Check metadata
|
|
title = opf_root.find(".//dc:title", opf_ns)
|
|
if title is None or not (title.text or "").strip():
|
|
issues.append({"fixable": True, "issue": "missing_title",
|
|
"message": "No dc:title in metadata — will add placeholder"})
|
|
|
|
lang = opf_root.find(".//dc:language", opf_ns)
|
|
if lang is None or not (lang.text or "").strip():
|
|
issues.append({"fixable": True, "issue": "missing_language",
|
|
"message": "No dc:language — will add 'en'"})
|
|
|
|
identifier = opf_root.find(".//dc:identifier", opf_ns)
|
|
if identifier is None or not (identifier.text or "").strip():
|
|
issues.append({"fixable": True, "issue": "missing_identifier",
|
|
"message": "No dc:identifier — will generate UUID"})
|
|
|
|
# Manifest vs filesystem
|
|
manifest_hrefs = set()
|
|
opf_dir = os.path.dirname(opf_path)
|
|
for item in opf_root.findall(".//opf:item", opf_ns):
|
|
href = item.get("href", "")
|
|
full = os.path.normpath(os.path.join(opf_dir, href)) if opf_dir else href
|
|
manifest_hrefs.add(full)
|
|
|
|
zip_files = set(names)
|
|
orphaned = zip_files - manifest_hrefs - {"mimetype", "META-INF/container.xml"}
|
|
orphaned = {o for o in orphaned if not o.startswith("META-INF/")}
|
|
|
|
if orphaned:
|
|
issues.append({"fixable": True, "issue": "orphaned_files",
|
|
"message": f"{len(orphaned)} file(s) not in manifest — will add",
|
|
"files": sorted(orphaned)})
|
|
|
|
except ET.ParseError:
|
|
issues.append({"fixable": False, "issue": "opf_parse_error",
|
|
"message": "OPF file is not valid XML — cannot auto-repair"})
|
|
|
|
except zipfile.BadZipFile:
|
|
issues.append({"fixable": False, "issue": "bad_zip",
|
|
"message": "File is not a valid ZIP"})
|
|
|
|
return issues
|
|
|
|
|
|
def repair(epub_path, output_path, fixable_issues):
|
|
"""Apply fixes. Returns list of what was fixed."""
|
|
if not fixable_issues:
|
|
return []
|
|
|
|
fixes = []
|
|
issue_set = {i["issue"] for i in fixable_issues}
|
|
|
|
# Work in a temp directory
|
|
import tempfile
|
|
tmpdir = tempfile.mkdtemp()
|
|
|
|
try:
|
|
# Extract the EPUB
|
|
shutil.unpack_archive(epub_path, tmpdir, "zip")
|
|
|
|
# Fix mimetype compression
|
|
if "mimetype_compressed" in issue_set:
|
|
fixes.append("mimetype: re-stored uncompressed")
|
|
|
|
# Fix missing NAV
|
|
if "missing_nav" in issue_set:
|
|
lang = "en"
|
|
# Try to discover language from OPF
|
|
for root, dirs, files in os.walk(tmpdir):
|
|
for f in files:
|
|
if f.endswith(".opf"):
|
|
try:
|
|
opf = ET.parse(os.path.join(root, f))
|
|
ns = {"dc": "http://purl.org/dc/elements/1.1/"}
|
|
lang_el = opf.find(".//dc:language", ns)
|
|
if lang_el is not None and lang_el.text:
|
|
lang = lang_el.text.strip()
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
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="{lang}" lang="{lang}">
|
|
<head><title>Navigation</title></head>
|
|
<body>
|
|
<nav epub:type="toc" id="toc">
|
|
<h1>Table of Contents</h1>
|
|
<ol><li><a href="Text/chapter1.xhtml">Start</a></li></ol>
|
|
</nav>
|
|
</body>
|
|
</html>"""
|
|
nav_dir = os.path.join(tmpdir, "OEBPS")
|
|
os.makedirs(nav_dir, exist_ok=True)
|
|
with open(os.path.join(nav_dir, "nav.xhtml"), "w") as f:
|
|
f.write(nav)
|
|
fixes.append("nav.xhtml: generated")
|
|
|
|
# Fix missing metadata
|
|
if any(i in issue_set for i in ["missing_title", "missing_language", "missing_identifier"]):
|
|
for root, dirs, files in os.walk(tmpdir):
|
|
for f in files:
|
|
if f.endswith(".opf"):
|
|
opf_path = os.path.join(root, f)
|
|
opf = ET.parse(opf_path)
|
|
ns = {"opf": "http://www.idpf.org/2007/opf",
|
|
"dc": "http://purl.org/dc/elements/1.1/"}
|
|
root_el = opf.getroot()
|
|
metadata = root_el.find(".//{http://www.idpf.org/2007/opf}metadata".format(**locals()))
|
|
if metadata is None:
|
|
metadata = root_el.find("metadata") # try without namespace
|
|
if metadata is None:
|
|
continue
|
|
|
|
ET.register_namespace("dc", "http://purl.org/dc/elements/1.1/")
|
|
ET.register_namespace("opf", "http://www.idpf.org/2007/opf")
|
|
|
|
if "missing_title" in issue_set:
|
|
el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}title")
|
|
el.text = "Untitled"
|
|
fixes.append("dc:title: added placeholder")
|
|
|
|
if "missing_language" in issue_set:
|
|
el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}language")
|
|
el.text = "en"
|
|
fixes.append("dc:language: added 'en'")
|
|
|
|
if "missing_identifier" in issue_set:
|
|
el = ET.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}identifier")
|
|
el.set("id", "book-id")
|
|
el.text = f"urn:uuid:{uuid.uuid4()}"
|
|
fixes.append("dc:identifier: generated UUID")
|
|
|
|
opf.write(opf_path, xml_declaration=True, encoding="UTF-8")
|
|
break
|
|
|
|
# Re-zip with mimetype first, uncompressed
|
|
os.remove(epub_path) if os.path.exists(epub_path) else None
|
|
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
# mimetype first, uncompressed
|
|
mimetype_path = os.path.join(tmpdir, "mimetype")
|
|
if os.path.exists(mimetype_path):
|
|
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
|
else:
|
|
zf.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
|
|
compress_type=zipfile.ZIP_STORED)
|
|
fixes.append("mimetype: created (was missing)")
|
|
|
|
# Everything else
|
|
for root, dirs, files in os.walk(tmpdir):
|
|
for f in files:
|
|
full = os.path.join(root, f)
|
|
arcname = os.path.relpath(full, tmpdir)
|
|
if arcname == "mimetype":
|
|
continue
|
|
zf.write(full, arcname)
|
|
|
|
finally:
|
|
shutil.rmtree(tmpdir)
|
|
|
|
return fixes
|
|
|
|
|
|
def main():
|
|
global DRY_RUN, JSON_OUTPUT, IN_PLACE
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Diagnose and fix common EPUB structural issues.",
|
|
epilog="Examples:\n epub-repair broken.epub --output fixed.epub\n epub-repair broken.epub --diagnose --json")
|
|
parser.add_argument("epub", nargs="?", help="Path to EPUB file")
|
|
parser.add_argument("--output", "-o", help="Output path (default: <input>-fixed.epub)")
|
|
parser.add_argument("--in-place", action="store_true", help="Overwrite original")
|
|
parser.add_argument("--diagnose", action="store_true", help="List fixable issues only, don't fix")
|
|
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
|
|
IN_PLACE = args.in_place
|
|
|
|
if not args.epub:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
output = args.output or args.epub.replace(".epub", "-fixed.epub")
|
|
if IN_PLACE:
|
|
output = args.epub
|
|
|
|
if DRY_RUN:
|
|
issues = diagnose(args.epub) if os.path.exists(args.epub) else []
|
|
emit({"status": "dry_run", "file": args.epub, "fixable_issues": len([i for i in issues if i["fixable"]]),
|
|
"total_issues": len(issues), "issues": issues},
|
|
f"[dry-run] Found {len([i for i in issues if i['fixable']])} fixable issue(s) in {args.epub}")
|
|
return
|
|
|
|
if not os.path.exists(args.epub) and not args.diagnose:
|
|
die(f"File not found: {args.epub}")
|
|
|
|
issues = diagnose(args.epub)
|
|
fixable = [i for i in issues if i["fixable"]]
|
|
|
|
if args.diagnose:
|
|
emit({"file": args.epub, "issues": issues, "fixable_count": len(fixable)})
|
|
return
|
|
|
|
if not fixable:
|
|
emit({"file": args.epub, "fixed": [], "message": "No fixable issues found"})
|
|
return
|
|
|
|
fixes = repair(args.epub, output, issues)
|
|
emit({"file": args.epub, "output": output, "fixes_applied": fixes, "count": len(fixes)})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|