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

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()