#!/usr/bin/env python3 """epub-validate — Validate an EPUB file against structural rules. Usage: epub-validate [--json] [--dry-run] LLM mode (automatic when env vars are set): If EPUB_LLM_URL and EPUB_LLM_KEY are set, validation errors get LLM-generated repair suggestions. Without env vars, only structural checks run. export EPUB_LLM_URL="https://opencode.ai/zen/go/v1" export EPUB_LLM_KEY="sk-..." epub-validate broken.epub --json # includes repair suggestions Examples: epub-validate book.epub --json epub-validate book.epub Tries to use EPUBCheck if available (java -jar epubcheck.jar), falls back to pure-Python structural checks. Dependencies: Python stdlib only (no external packages required for structural checks). Optional: requests (for LLM repair suggestions). """ import argparse import json import os import re import shutil import sys import xml.etree.ElementTree as ET import zipfile 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 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 try_epubcheck(epub_path): """Try to validate with EPUBCheck if Java is available.""" epubcheck_jar = shutil.which('epubcheck') java = shutil.which('java') if not java: return None # Java not available if not epubcheck_jar: # Look in common locations candidates = [ 'epubcheck.jar', os.path.expanduser('~/epubcheck.jar'), os.path.expanduser('~/.local/bin/epubcheck.jar'), ] for c in candidates: if os.path.exists(c): epubcheck_jar = c break if not epubcheck_jar or not os.path.exists(epubcheck_jar): return None import subprocess try: result = subprocess.run( [java, '-jar', epubcheck_jar, '--json', epub_path], capture_output=True, text=True, timeout=60 ) if result.returncode == 0: return {'status': 'valid', 'validator': 'epubcheck', 'raw': 'EPUB is valid'} else: return {'status': 'invalid', 'validator': 'epubcheck', 'errors': result.stdout[:2000] if result.stdout else result.stderr[:2000]} except Exception as e: warn(f"EPUBCheck failed: {e}") return None def structural_check(epub_path): """Pure-Python structural validation.""" issues = [] if not os.path.exists(epub_path): return [{'severity': 'error', 'check': 'file_exists', 'message': 'File not found'}] if not epub_path.lower().endswith('.epub'): issues.append({'severity': 'warning', 'check': 'extension', 'message': 'File does not have .epub extension'}) try: with zipfile.ZipFile(epub_path, 'r') as zf: names = zf.namelist() # 1. mimetype must be first and uncompressed if not names or names[0] != 'mimetype': issues.append({'severity': 'error', 'check': 'mimetype_position', 'message': 'mimetype must be the first file in the ZIP'}) else: info = zf.getinfo('mimetype') if info.compress_type != zipfile.ZIP_STORED: issues.append({'severity': 'error', 'check': 'mimetype_compression', 'message': 'mimetype must be stored uncompressed (ZIP_STORED)'}) content = zf.read('mimetype').decode('ascii', errors='replace').strip() if content != 'application/epub+zip': issues.append({'severity': 'error', 'check': 'mimetype_content', 'message': f'mimetype content must be "application/epub+zip", got: {content}'}) # 2. container.xml must exist if 'META-INF/container.xml' not in names: issues.append({'severity': 'error', 'check': 'container_xml', 'message': 'META-INF/container.xml not found'}) else: # Parse container.xml and find OPF try: container_xml = zf.read('META-INF/container.xml') ns = {'c': 'urn:oasis:names:tc:opendocument:xmlns:container'} root = ET.fromstring(container_xml) rootfiles = root.findall('.//c:rootfile', ns) if not rootfiles: issues.append({'severity': 'error', 'check': 'container_rootfiles', 'message': 'No rootfile elements in container.xml'}) else: opf_path = rootfiles[0].get('full-path') if not opf_path: issues.append({'severity': 'error', 'check': 'opf_path', 'message': 'No full-path attribute on rootfile'}) elif opf_path not in names: issues.append({'severity': 'error', 'check': 'opf_exists', 'message': f'OPF file not found: {opf_path}'}) else: # 3. Parse OPF 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({'severity': 'error', 'check': 'metadata_title', 'message': 'Missing required dc:title'}) lang = opf_root.find('.//dc:language', opf_ns) if lang is None or not (lang.text or '').strip(): issues.append({'severity': 'error', 'check': 'metadata_language', 'message': 'Missing required dc:language'}) identifier = opf_root.find('.//dc:identifier', opf_ns) if identifier is None or not (identifier.text or '').strip(): issues.append({'severity': 'error', 'check': 'metadata_identifier', 'message': 'Missing required dc:identifier'}) # 4. Check manifest items manifest_items = {} opf_dir = os.path.dirname(opf_path) for item in opf_root.findall('.//opf:item', opf_ns): iid = item.get('id') href = item.get('href') if not iid or not href: continue manifest_items[iid] = href # Resolve relative path full_href = os.path.normpath( os.path.join(opf_dir, href)) if opf_dir else href if full_href not in names: issues.append({'severity': 'warning', 'check': 'manifest_file', 'message': f'Manifest item "{iid}" references missing file: {href}'}) # 5. Check spine references spine_ids = set() for itemref in opf_root.findall('.//opf:itemref', opf_ns): idref = itemref.get('idref') if idref: spine_ids.add(idref) for sid in spine_ids: if sid not in manifest_items: issues.append({'severity': 'error', 'check': 'spine_ref', 'message': f'Spine references unknown manifest item: {sid}'}) # 6. Check for NAV document has_nav = False for item in opf_root.findall('.//opf:item', opf_ns): props = item.get('properties', '') if 'nav' in props.split(): has_nav = True break if not has_nav: issues.append({'severity': 'warning', 'check': 'navigation', 'message': 'No NAV document found (properties="nav")'}) except ET.ParseError as e: issues.append({'severity': 'error', 'check': 'opf_parse', 'message': f'Failed to parse OPF: {e}'}) except ET.ParseError as e: issues.append({'severity': 'error', 'check': 'container_parse', 'message': f'Failed to parse container.xml: {e}'}) except zipfile.BadZipFile: issues.append({'severity': 'error', 'check': 'zip_format', 'message': 'File is not a valid ZIP archive'}) except Exception as e: issues.append({'severity': 'error', 'check': 'unknown', 'message': f'Unexpected error: {e}'}) return issues def main(): global DRY_RUN, JSON_OUTPUT parser = argparse.ArgumentParser( description='Validate an EPUB file against structural rules.', epilog='Examples:\n epub-validate book.epub\n epub-validate book.epub --json') parser.add_argument('epub', nargs='?', help='Path to EPUB file') parser.add_argument('--json', action='store_true', help='Output as JSON') parser.add_argument('--dry-run', '-n', action='store_true', help='Preview without reading') args = parser.parse_args() JSON_OUTPUT = args.json DRY_RUN = args.dry_run if not args.epub: parser.print_help() sys.exit(1) if DRY_RUN: emit({'status': 'dry_run', 'file': args.epub}, f'[dry-run] Would validate: {args.epub}') return # Try EPUBCheck first epubcheck_result = try_epubcheck(args.epub) if epubcheck_result and epubcheck_result['status'] == 'valid': emit(epubcheck_result, 'EPUB is valid (EPUBCheck) ✓') return # Fall back to structural check issues = structural_check(args.epub) errors = [i for i in issues if i['severity'] == 'error'] warnings_list = [i for i in issues if i['severity'] == 'warning'] result = { 'file': args.epub, 'status': 'valid' if not errors else 'invalid', 'validator': 'python-structural', 'errors': len(errors), 'warnings': len(warnings_list), 'issues': issues } emit(result) if not errors: print('EPUB passes structural checks ✓' if not JSON_OUTPUT else '') else: print(f'EPUB has {len(errors)} error(s), {len(warnings_list)} warning(s)' if not JSON_OUTPUT else '') if __name__ == '__main__': main()