Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien, metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien. - Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten, Fortsetzungsdateien und Erkennung von Layout-Überschriften - Code mit exakter Einrückung, sicheren Fences und konservativer Spracherkennung - MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln bleiben Bilder - Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links - Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe - Sicher: kein Entpacken, Schutz vor Path Traversal und XXE, DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben - 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
6.3 KiB
Python
196 lines
6.3 KiB
Python
"""Small helpers: slugs, YAML emitting, path safety, element helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import posixpath
|
|
import re
|
|
import unicodedata
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
EPUB_NS = "http://www.idpf.org/2007/ops"
|
|
XHTML_NS = "http://www.w3.org/1999/xhtml"
|
|
MATHML_NS = "http://www.w3.org/1998/Math/MathML"
|
|
SVG_NS = "http://www.w3.org/2000/svg"
|
|
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
|
|
_DE_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"})
|
|
|
|
|
|
def slugify(text: str, language: str = "de", max_len: int = 60, fallback: str = "kapitel") -> str:
|
|
"""Readable, filesystem-safe slug.
|
|
|
|
de: German transliteration (ä -> ae, ß -> ss), then ASCII folding.
|
|
en: plain ASCII folding (ä -> a).
|
|
none: keep Unicode letters, only normalize separators.
|
|
"""
|
|
text = unicodedata.normalize("NFKC", text or "")
|
|
if language == "de":
|
|
text = text.translate(_DE_MAP)
|
|
elif language == "en":
|
|
text = text.replace("ß", "ss")
|
|
if language in ("de", "en"):
|
|
text = unicodedata.normalize("NFKD", text)
|
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
text = text.encode("ascii", "ignore").decode("ascii")
|
|
text = text.lower()
|
|
text = re.sub(r"[^\w\s-]", " ", text, flags=re.UNICODE)
|
|
text = re.sub(r"[\s_-]+", "-", text).strip("-")
|
|
if len(text) > max_len:
|
|
cut = text[:max_len]
|
|
text = cut.rsplit("-", 1)[0] if "-" in cut[max_len // 2 :] else cut
|
|
text = text.strip("-")
|
|
return text or fallback
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for block in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
# --------------------------------------------------------------------------- YAML
|
|
|
|
|
|
def _yaml_scalar(value: Any) -> str:
|
|
if value is None:
|
|
return "null"
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (int, float)):
|
|
return str(value)
|
|
# JSON string syntax is valid YAML double-quoted scalar syntax.
|
|
return json.dumps(str(value), ensure_ascii=False)
|
|
|
|
|
|
def to_yaml(data: Any, indent: int = 0) -> str:
|
|
"""Minimal YAML emitter for dicts/lists/scalars (no external dependency)."""
|
|
pad = " " * indent
|
|
lines: list[str] = []
|
|
if isinstance(data, dict):
|
|
if not data:
|
|
return pad + "{}\n"
|
|
for key, value in data.items():
|
|
k = key if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", str(key)) else json.dumps(str(key))
|
|
if isinstance(value, dict) and value:
|
|
lines.append(f"{pad}{k}:")
|
|
lines.append(to_yaml(value, indent + 1).rstrip("\n"))
|
|
elif isinstance(value, list) and value:
|
|
lines.append(f"{pad}{k}:")
|
|
lines.append(to_yaml(value, indent + 1).rstrip("\n"))
|
|
elif isinstance(value, (dict, list)):
|
|
lines.append(f"{pad}{k}: {'{}' if isinstance(value, dict) else '[]'}")
|
|
else:
|
|
lines.append(f"{pad}{k}: {_yaml_scalar(value)}")
|
|
elif isinstance(data, list):
|
|
for value in data:
|
|
if isinstance(value, (dict, list)) and value:
|
|
sub = to_yaml(value, indent + 1).rstrip("\n").split("\n")
|
|
first = sub[0][len(" " * (indent + 1)) :]
|
|
lines.append(f"{pad}- {first}")
|
|
lines.extend(sub[1:])
|
|
else:
|
|
lines.append(f"{pad}- {_yaml_scalar(value)}")
|
|
else:
|
|
lines.append(pad + _yaml_scalar(data))
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
# --------------------------------------------------------------------------- paths
|
|
|
|
|
|
def safe_zip_path(name: str) -> str | None:
|
|
"""Normalize a ZIP member name; return None if it is unsafe (absolute, '..', drive)."""
|
|
if not name or "\x00" in name:
|
|
return None
|
|
name = name.replace("\\", "/")
|
|
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
|
return None
|
|
norm = posixpath.normpath(name)
|
|
if norm == ".." or norm.startswith("../") or norm.startswith("/"):
|
|
return None
|
|
return norm
|
|
|
|
|
|
def resolve_href(base_doc: str, href_path: str) -> str | None:
|
|
"""Resolve a (URL-decoded) relative path against the directory of ``base_doc``."""
|
|
if href_path.startswith("/"):
|
|
joined = href_path.lstrip("/")
|
|
else:
|
|
joined = posixpath.join(posixpath.dirname(base_doc), href_path)
|
|
return safe_zip_path(joined)
|
|
|
|
|
|
def is_inside(child: Path, parent: Path) -> bool:
|
|
try:
|
|
child.resolve().relative_to(parent.resolve())
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def url_path(path: str) -> str:
|
|
"""Percent-encode a relative posix path for use as Markdown link target."""
|
|
return quote(path, safe="/-._~")
|
|
|
|
|
|
def rel_link(from_file: str, to_file: str) -> str:
|
|
"""Relative link between two output-relative posix paths."""
|
|
if from_file == to_file:
|
|
return ""
|
|
return posixpath.relpath(to_file, posixpath.dirname(from_file) or ".")
|
|
|
|
|
|
# --------------------------------------------------------------------------- elements
|
|
|
|
|
|
def local(tag: Any) -> str:
|
|
if not isinstance(tag, str):
|
|
return ""
|
|
return tag.rsplit("}", 1)[-1].lower()
|
|
|
|
|
|
def is_element(node: Any) -> bool:
|
|
return isinstance(getattr(node, "tag", None), str)
|
|
|
|
|
|
def epub_types(el: Any) -> list[str]:
|
|
value = el.get(f"{{{EPUB_NS}}}type") or el.get("epub:type") or ""
|
|
return value.split()
|
|
|
|
|
|
def roles(el: Any) -> list[str]:
|
|
return (el.get("role") or "").split()
|
|
|
|
|
|
def classes(el: Any) -> list[str]:
|
|
return (el.get("class") or "").lower().split()
|
|
|
|
|
|
def text_content(el: Any) -> str:
|
|
"""All text inside ``el`` (without its tail)."""
|
|
parts: list[str] = []
|
|
if el.text:
|
|
parts.append(el.text)
|
|
for child in el:
|
|
if is_element(child):
|
|
parts.append(text_content(child))
|
|
if child.tail:
|
|
parts.append(child.tail)
|
|
return "".join(parts)
|
|
|
|
|
|
def norm_ws(text: str) -> str:
|
|
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()
|
|
|
|
|
|
def iter_elements(el: Any):
|
|
"""Depth-first iteration over element descendants including ``el``."""
|
|
for node in el.iter():
|
|
if is_element(node):
|
|
yield node
|