EPUB Multirenamer

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.

EPUB Multirenamer Download Meine YAML Download
In den beiden großen Codeblöcken weiter unten findest Du oben rechts jeweils einen Copy-Button, um das Script oder die YAML-Datei direkt in die Zwischenablage zu kopieren.

1. Projektstruktur

EPUB-Multirenamer/ ├── epub_multi_replace.py ├── ersetzungen.yaml ├── EPUB-INPUT/ └── EPUB-OUTPUT/

Die Arbeitsweise ist dann immer gleich:

  1. EPUB-Dateien in EPUB-INPUT legen
  2. ersetzungen.yaml bei Bedarf anpassen
  3. Script im Terminal starten
  4. Ergebnisse in EPUB-OUTPUT prü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
Falls nur eine Meldung im Terminal erscheint und kein Fenster aufgeht, kann ein Reset helfen:
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
Wenn etwas wie Python 3.12.x oder Python 3.13.x erscheint, ist Python korrekt installiert.

2.4 PyYAML installieren

python3 -m pip install pyyaml
Das Paket 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:

3. Projektordner anlegen

Lege zum Beispiel auf dem Schreibtisch einen Ordner EPUB-Multirenamer an.

EPUB-Multirenamer/ ├── epub_multi_replace.py ├── ersetzungen.yaml ├── EPUB-INPUT/ └── EPUB-OUTPUT/

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"
In YAML müssen Backslashes doppelt geschrieben werden, also zum Beispiel \\b, \\1 oder \\u\\1.

6. Was das Script macht

7. EPUB-Dateien in den Eingabeordner legen

EPUB-Multirenamer/ ├── epub_multi_replace.py ├── ersetzungen.yaml ├── EPUB-INPUT/ │ ├── buch1.epub │ ├── buch2.epub │ └── buch3.epub └── EPUB-OUTPUT/

8. In den Projektordner wechseln

cd ~/Desktop/EPUB-Multirenamer

9. Testlauf ausführen

python3 epub_multi_replace.py --rules ersetzungen.yaml --dry-run
Das ist der empfohlene erste Schritt, bevor Du die Dateien wirklich erzeugen lässt.

10. EPUB-Dateien wirklich verarbeiten

python3 epub_multi_replace.py --rules ersetzungen.yaml

11. Typischer Workflow

  1. Neue EPUB-Dateien in EPUB-INPUT kopieren
  2. ersetzungen.yaml in eine Code-Editor (z.B Visual Studio Code) öffnen und anpassen
  3. Im Terminal in den Projektordner wechseln
  4. Dry-Run starten
  5. Danach echte Verarbeitung starten
  6. Ergebnis in EPUB-OUTPUT prüfen
  7. 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.