epub2md/epub2md/utils.py
dschlueter 02449734a2 feat: epub2md – EPUB nach kapitelweisem Markdown
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>
2026-10-05 19:33:42 +02:00

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