EPUB Multirenamer für Mac
brought to you by dzhome.de
Stand: 08.04.2026
0. Einleitung
Mit Hilfe des Scriptes ist es möglich, mehrere EPUB-Dateien gleichzeitig zu bearbeiten. Man kann in der YAML-Datei festlegen, welche Begriffe gesucht und wie sie geändert werden sollen. So ist es z.B. möglich, eine EPUB-Datei schnell (teilweise) an die neue deutsche Rechtschreibung anzupassen. Meine YAML-Datei biete ich hier zum Download an. Diese wird stetig fortgeschrieben und erweitert. Es gibt auch eine Anleitung in englisch....
Hier kannst Du Dir anschauen, was das Script aus einem EPUB macht! Alle diese Korrekturen wurden mit Hilfe der YAML-Datei automatisch vorgenommen.
Das Script habe ich am 28.03.2026 von Chat GPT in Python erstellen lassen.
1. Projektstruktur
Die Arbeitsweise ist dann immer gleich:
- EPUB-Dateien in
EPUB-INPUTlegen ersetzungen.yamlbei Bedarf anpassen- Script im Terminal starten
- Ergebnisse in
EPUB-OUTPUTprüfen
2. Voraussetzungen auf dem Mac
2.1 Terminal öffnen
Öffne das Terminal über Spotlight: Cmd + Leertaste, dann Terminal eingeben.
2.2 Python prüfen
python3 --version
Wenn eine Python-Version angezeigt wird, kannst Du mit Schritt 2.4 weitermachen.
Wenn command not found erscheint, installiere Python wie unten in 2.3 beschrieben.
2.3 Python auf dem Mac installieren
2.3.1 Command Line Tools installieren
xcode-select --install
sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install
2.3.2 Homebrew installieren
Homebrew ist der einfachste Weg, Python auf dem Mac zu installieren.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
2.3.3 Python installieren
brew install python
2.3.4 Installation prüfen
python3 --version
Python 3.12.x oder Python 3.13.x erscheint, ist Python korrekt installiert.
2.4 PyYAML installieren
python3 -m pip install pyyaml
PyYAML wird ben=C3=B6tigt, damit Dein Script d=
ie Datei
ersetzungen.yaml lesen kann.
2.5 Welche Dateien innerhalb des EPUB's werden bearbeitet?
Eine EPUB-Datei ist =C3=A4hnlich wie eine ZIP-Datei. Innerhalb des E= PUB's befinden sich mehrere Dateien.
Bearbeitet werden standardm=C3=A4=C3=9Fig Dateien mit diesen End= ungen:
.xhtml.html.htm
3. Projektordner anlegen
Lege zum Beispiel auf dem Schreibtisch einen Ordner EPUB-Multirenamer an.
4. Das Python-Script
Speichere das folgende Script als epub_multi_replace.py.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import argparse
import re
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
import yaml
TEXT_EXTENSIONS = {".xhtml", ".html", ".htm"}
INPUT_DIR_NAME = "EPUB-INPUT"
OUTPUT_DIR_NAME = "EPUB-OUTPUT"
@dataclass
class ReplacementRule:
pattern: str
replacement: str
regex: bool = True
flags: int = 0
description: str = ""
def log(msg: str) -> None:
print(msg)
def is_text_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in TEXT_EXTENSIONS
def parse_flags(flag_names: list[str] | None) -> int:
if not flag_names:
return 0
flag_map = {
"IGNORECASE": re.IGNORECASE,
"MULTILINE": re.MULTILINE,
"DOTALL": re.DOTALL,
}
value = 0
for name in flag_names:
if name not in flag_map:
raise ValueError(f"Unbekanntes Regex-Flag: {name}")
value |= flag_map[name]
return value
def load_replacements(yaml_path: Path) -> list[ReplacementRule]:
try:
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise FileNotFoundError(f"YAML-Datei nicht gefunden: {yaml_path}") from exc
except Exception as exc:
raise ValueError(f"YAML-Datei konnte nicht gelesen werden: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("YAML-Datei hat kein gültiges Wurzelobjekt.")
items = data.get("replacements")
if not isinstance(items, list):
raise ValueError("YAML-Datei muss einen Schlüssel 'replacements' mit einer Liste enthalten.")
rules: list[ReplacementRule] = []
for idx, item in enumerate(items, start=1):
if not isinstance(item, dict):
raise ValueError(f"Eintrag #{idx} in 'replacements' ist kein Objekt.")
pattern = item.get("pattern")
replacement = item.get("replacement", "")
regex = item.get("regex", True)
description = item.get("description", "")
flags = parse_flags(item.get("flags"))
if not isinstance(pattern, str):
raise ValueError(f"Eintrag #{idx}: 'pattern' fehlt oder ist kein Text.")
if not isinstance(replacement, str):
raise ValueError(f"Eintrag #{idx}: 'replacement' ist kein Text.")
if not isinstance(regex, bool):
raise ValueError(f"Eintrag #{idx}: 'regex' muss true oder false sein.")
if not isinstance(description, str):
raise ValueError(f"Eintrag #{idx}: 'description' ist kein Text.")
rules.append(
ReplacementRule(
pattern=pattern,
replacement=replacement,
regex=regex,
flags=flags,
description=description,
)
)
return rules
def extract_epub(epub_path: Path, target_dir: Path) -> None:
with zipfile.ZipFile(epub_path, "r") as zf:
zf.extractall(target_dir)
def apply_replacements(text: str, rules: list[ReplacementRule]) -> tuple[str, int]:
total_changes = 0
updated = text
for rule in rules:
if rule.regex:
def repl(match: re.Match[str]) -> str:
result = rule.replacement
def upper_first(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
def lower_first(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def repl_u(m: re.Match[str]) -> str:
return upper_first(match.group(int(m.group(1))))
def repl_l(m: re.Match[str]) -> str:
return lower_first(match.group(int(m.group(1))))
def repl_U(m: re.Match[str]) -> str:
return match.group(int(m.group(1))).upper()
def repl_L(m: re.Match[str]) -> str:
return match.group(int(m.group(1))).lower()
result = re.sub(r"\\U\\(\d+)", repl_U, result)
result = re.sub(r"\\L\\(\d+)", repl_L, result)
result = re.sub(r"\\u\\(\d+)", repl_u, result)
result = re.sub(r"\\l\\(\d+)", repl_l, result)
result = match.expand(result)
return result
updated, count = re.subn(
rule.pattern,
repl,
updated,
flags=rule.flags,
)
else:
count = updated.count(rule.pattern)
if count:
updated = updated.replace(rule.pattern, rule.replacement)
total_changes += count
return updated, total_changes
def should_skip_element_text(tag_name: str) -> bool:
tag_name = tag_name.lower()
return tag_name in {"script", "style"}
def local_name(tag: str) -> str:
if "}" in tag:
return tag.split("}", 1)[1]
return tag
def apply_replacements_to_xhtml(
path: Path,
rules: list[ReplacementRule],
dry_run: bool = False,
) -> int:
parser = ET.XMLParser()
tree = ET.parse(path, parser=parser)
root = tree.getroot()
total_replacements = 0
for elem in root.iter():
tag = local_name(elem.tag) if isinstance(elem.tag, str) else ""
if not should_skip_element_text(tag):
if elem.text:
new_text, count = apply_replacements(elem.text, rules)
if count:
elem.text = new_text
total_replacements += count
if elem.tail:
parent_tag = local_name(elem.tag) if isinstance(elem.tag, str) else ""
if not should_skip_element_text(parent_tag):
new_tail, count = apply_replacements(elem.tail, rules)
if count:
elem.tail = new_tail
total_replacements += count
if total_replacements and not dry_run:
ET.register_namespace("", "http://www.w3.org/1999/xhtml")
tree.write(path, encoding="utf-8", xml_declaration=True)
return total_replacements
def process_extracted_files(
root_dir: Path,
rules: list[ReplacementRule],
dry_run: bool = False,
) -> tuple[int, int]:
changed_files = 0
total_replacements = 0
for path in sorted(root_dir.rglob("*")):
if not is_text_file(path):
continue
try:
replacements_in_file = apply_replacements_to_xhtml(path, rules, dry_run=dry_run)
except ET.ParseError as exc:
log(f"Übersprungen (kein sauberes XHTML/XML): {path.relative_to(root_dir)} -> {exc}")
continue
except Exception as exc:
log(f"Fehler beim Verarbeiten: {path.relative_to(root_dir)} -> {exc}")
continue
if replacements_in_file > 0:
changed_files += 1
total_replacements += replacements_in_file
log(f" Geändert: {path.relative_to(root_dir)} ({replacements_in_file} Treffer)")
return changed_files, total_replacements
def create_epub(source_dir: Path, output_epub: Path) -> None:
mimetype_path = source_dir / "mimetype"
with zipfile.ZipFile(output_epub, "w") as zf:
if mimetype_path.exists():
zf.write(
mimetype_path,
arcname="mimetype",
compress_type=zipfile.ZIP_STORED,
)
for path in sorted(source_dir.rglob("*")):
if not path.is_file():
continue
if path == mimetype_path:
continue
arcname = path.relative_to(source_dir)
zf.write(path, arcname=str(arcname), compress_type=zipfile.ZIP_DEFLATED)
def validate_extracted_epub_structure(work_dir: Path) -> None:
mimetype_path = work_dir / "mimetype"
if not mimetype_path.exists():
raise ValueError("EPUB ist ungültig: Datei 'mimetype' fehlt.")
try:
mimetype = mimetype_path.read_text(encoding="utf-8").strip()
except Exception as exc:
raise ValueError(f"EPUB ist ungültig: 'mimetype' konnte nicht gelesen werden: {exc}") from exc
if mimetype != "application/epub+zip":
raise ValueError(
f"EPUB ist ungültig: 'mimetype' hat unerwarteten Inhalt: {mimetype!r}"
)
container_xml = work_dir / "META-INF" / "container.xml"
if not container_xml.exists():
raise ValueError("EPUB ist ungültig: 'META-INF/container.xml' fehlt.")
def process_single_epub(
input_epub: Path,
output_epub: Path,
rules: list[ReplacementRule],
dry_run: bool = False,
) -> tuple[int, int]:
with tempfile.TemporaryDirectory(prefix="epub_replace_") as tmp:
work_dir = Path(tmp)
try:
extract_epub(input_epub, work_dir)
except zipfile.BadZipFile:
raise ValueError(f"Ungültiges EPUB/ZIP-Archiv: {input_epub.name}")
validate_extracted_epub_structure(work_dir)
changed_files, total_replacements = process_extracted_files(
work_dir,
rules,
dry_run=dry_run,
)
if not dry_run:
output_epub.parent.mkdir(parents=True, exist_ok=True)
create_epub(work_dir, output_epub)
return changed_files, total_replacements
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Verarbeitet alle EPUB-Dateien aus 'EPUB-INPUT' und schreibt "
"die Ergebnisse nach 'EPUB-OUTPUT'. Es werden nur sichtbare Texte "
"in XHTML/HTML-Dateien bearbeitet."
)
)
parser.add_argument(
"--rules",
default="ersetzungen.yaml",
help="Pfad zur YAML-Datei mit den Ersetzungen (Standard: ersetzungen.yaml)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was geändert würde, ohne neue EPUB-Dateien zu schreiben",
)
args = parser.parse_args()
script_dir = Path(__file__).resolve().parent
input_dir = script_dir / INPUT_DIR_NAME
output_dir = script_dir / OUTPUT_DIR_NAME
rules_path = Path(args.rules).expanduser().resolve()
dry_run = args.dry_run
try:
rules = load_replacements(rules_path)
except Exception as exc:
log(f"Fehler beim Laden der YAML-Datei: {exc}")
return 1
if not rules:
log("Fehler: Keine Ersetzungen in der YAML-Datei gefunden.")
return 1
if not input_dir.exists():
log(f"Fehler: Eingabeordner nicht gefunden: {input_dir}")
log(f"Bitte lege neben dem Script einen Ordner namens '{INPUT_DIR_NAME}' an.")
return 1
if not input_dir.is_dir():
log(f"Fehler: {input_dir} ist kein Ordner.")
return 1
epub_files = sorted(input_dir.glob("*.epub"))
if not epub_files:
log(f"Keine EPUB-Dateien in {input_dir} gefunden.")
return 0
if not dry_run:
output_dir.mkdir(parents=True, exist_ok=True)
log(f"Geladene Regeln: {len(rules)}")
log(f"Eingabeordner: {input_dir}")
log(f"Ausgabeordner: {output_dir}")
log(f"Gefundene EPUB-Dateien: {len(epub_files)}")
log("")
processed_count = 0
failed_count = 0
total_changed_files = 0
total_replacements = 0
for input_epub in epub_files:
output_epub = output_dir / input_epub.name
log(f"Verarbeite: {input_epub.name}")
try:
changed_files, replacements = process_single_epub(
input_epub=input_epub,
output_epub=output_epub,
rules=rules,
dry_run=dry_run,
)
total_changed_files += changed_files
total_replacements += replacements
processed_count += 1
if dry_run:
log(
f" Dry-Run fertig: {changed_files} Dateien mit Änderungen, "
f"{replacements} Ersetzungen"
)
else:
log(
f" Fertig: {output_epub.name} "
f"({changed_files} Dateien mit Änderungen, {replacements} Ersetzungen)"
)
except Exception as exc:
failed_count += 1
log(f" Fehler bei {input_epub.name}: {exc}")
log("")
log("Zusammenfassung")
log("--------------")
log(f"Erfolgreich verarbeitet: {processed_count}")
log(f"Fehlgeschlagen: {failed_count}")
log(f"Geänderte Dateien innerhalb aller EPUBs: {total_changed_files}")
log(f"Gesamte Ersetzungen: {total_replacements}")
return 0 if failed_count == 0 else 1
if __name__ == "__main__":
sys.exit(main())
5. Die YAML-Datei
Speichere die Regeln als ersetzungen.yaml.
replacements:
# ---------------------- BEISPIELE ------------------#
# ----------------------
# Photo -> Foto / photo -> foto
# ----------------------
- pattern: "Photo"
replacement: "Foto"
regex: true
- pattern: "photo"
replacement: "foto"
regex: true
# ----------------------
# Sulphat -> Sulfat / (Natrium)sulphat -> (Natrium)sulfat
# ----------------------
- pattern: "([Ss])ulphat"
replacement: "\\1ulfat"
regex: true
# ----------------------
# Schußlig -> Schusslig / schußlig -> schusslig
# ----------------------
- pattern: "\\b(S|s)chußlig"
replacement: "\\1chusslig"
regex: true
# ----------------------
# Anrede großschreiben
# ----------------------
- pattern: "\\b(du|dich|dir|euch|euer)\\b"
replacement: "\\u\\1"
regex: true
flags:
- IGNORECASE
description: "Anrede groß"
- pattern: "\\bdein(e|er|es|em|en)?\\b"
replacement: "Dein\\1"
regex: true
flags:
- IGNORECASE
description: "Dein-Formen groß"
# ----------------------
# Typographie, Leerzeichen und Zeilenumbrüche
# ----------------------
# 0) Drei Punkte verdichten ". . ." zu "..."
- pattern: '\.\s*\.\s*\.'
replacement: '…'
regex: true
description: 'Drei Punkte zu Auslassungszeichen'
# 1) Leerzeichen vor Satzzeichen entfernen
- pattern: '\s+([:,.;!?])'
replacement: '\1'
regex: true
description: 'Leerzeichen vor Satzzeichen entfernen'
# 2) Leerzeichen nach Satzzeichen ergänzen,
# aber nicht vor schließendem Anführungszeichen
- pattern: '([:,.;!?])([^\s"“”‚‘])'
replacement: '\1 \2'
regex: true
description: 'Leerzeichen nach Satzzeichen ergänzen'
# 3) Einzelnes Wort in "..." -> obere Quotes
- pattern: '"([A-Za-zÄÖÜäöüß-]+)"'
replacement: '“\1”'
regex: true
description: 'Einzelnes Wort in obere doppelte Anführungszeichen'
# 4) Allgemeine doppelte Anführungszeichen -> deutsch
- pattern: '"([^"\n]+)"'
replacement: '„\1“'
regex: true
description: 'Gerade doppelte Anführungszeichen -> deutsch'
# 5) Einzelnes Wort in '...' -> obere einfache Quotes
- pattern: "'([A-Za-zÄÖÜäöüß-]+)'"
replacement: '‘\1’'
regex: true
description: 'Einzelnes Wort in obere einfache Anführungszeichen'
# 6) Allgemeine einfache Anführungszeichen
- pattern: "'([^'\n]+)'"
replacement: '‚\1‘'
regex: true
description: 'Gerade einfache Anführungszeichen -> typografisch'
# 7) Typografische Apostrophe
- pattern: "'"
replacement: "’"
regex: false
description: "Apostroph"
# 8) Optional: harte Zeilenumbrüche glätten
- pattern: "\\n{3,}"
replacement: "\\n\\n"
regex: true
description: "Maximal zwei Leerzeilen"
\\b, \\1 oder \\u\\1.
6. Was das Script macht
- Es verarbeitet alle
.epub-Dateien ausEPUB-INPUT. - Es schreibt die bearbeiteten Dateien nach
EPUB-OUTPUT. - Die Originale in
EPUB-INPUTbleiben unverändert. - Es unterstützt Regex-Regeln und einfache Ersetzungen.
- Es versteht zusätzlich
\u\1,\l\1,\U\1und\L\1für Groß-/Kleinschreibung.
7. EPUB-Dateien in den Eingabeordner legen
8. In den Projektordner wechseln
cd ~/Desktop/EPUB-Multirenamer
9. Testlauf ausführen
python3 epub_multi_replace.py --rules ersetzungen.yaml --dry-run
10. EPUB-Dateien wirklich verarbeiten
python3 epub_multi_replace.py --rules ersetzungen.yaml
11. Typischer Workflow
- Neue EPUB-Dateien in
EPUB-INPUTkopieren ersetzungen.yamlin eine Code-Editor (z.B Visual Studio Code) öffnen und anpassen- Im Terminal in den Projektordner wechseln
- Dry-Run starten
- Danach echte Verarbeitung starten
- Ergebnis in
EPUB-OUTPUTprüfen - Bearbeitete EPUBs in Apple Books, Calibre oder Sigil testen
12. Nützliche Terminal-Befehle
Aktuellen Ordner anzeigen
pwd
Dateien im Ordner anzeigen
ls
Auch Unterordner anzeigen
ls -R
Python-Pfad prüfen
which python3
13. Häufige Fehler
Python wird nicht gefunden
python3 --version
PyYAML fehlt
python3 -m pip install pyyaml
Keine EPUB-Dateien gefunden
Prüfe, ob die Dateien wirklich im Ordner EPUB-INPUT liegen und
die Endung .epub haben.
YAML-Datei enthält Fehler
Achte auf saubere Einrückungen. YAML reagiert empfindlich auf Leerzeichen. Zwei Leerzeichen pro Ebene sind eine gute Gewohnheit.
Script im falschen Ordner gestartet
Prüfe mit pwd, ob Du wirklich in EPUB-Multirenamer bist.
14. Zusammenfassung
Sobald Dein Projektordner eingerichtet ist, besteht die tägliche Arbeit nur noch aus: EPUBs hineinlegen, Regeln prüfen, Dry-Run, echte Verarbeitung. Falls Du Fragen oder Anregungen hast, hinterlasse einfach einen Kommentar auf meiner Homepage.