Compare commits

..
Author SHA1 Message Date
84821a2fcd refactor: pipeline._convert in Teilschritte zerlegt (QA R5)
_convert (zuletzt 153 Zeilen) orchestriert jetzt nur noch die Teilschritte
_prepare_docs, _build_structure, _render (mit _report_wrap_markers und
_collect_remaining_media) und _assemble. Längste Funktion im Modul: 48 Zeilen.
Keine Verhaltensänderung: Die Ausgabe aller vier Beispielbücher (Standard
und end-of-book) ist bis auf Zeitstempel und Pfade byteidentisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:50:19 +02:00
bdc611eefc feat: Abschnitt Weiterverarbeitung in der Buch-README (QA R7)
Die erzeugte README.md nennt den Merge-Befehl mit dem tatsächlichen
Ausgabeordner und passende Pandoc-Befehle. Bei Büchern mit Formeln
enthalten sie --mathml, ergänzt um einen Hinweis zur Formeldarstellung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:48:41 +02:00
5f58589c2e feat: Leerzeichen zwischen aneinanderstoßenden Feld-Spans (QA R4)
Zwei <span>-Geschwister mit unterschiedlichen Klassen, ohne Text
dazwischen und mit Großbuchstabe oder Ziffer rechts bekommen ein
trennendes Leerzeichen. Initialen (D|as) und Wortteile (Haupt|bahnhof)
bleiben unberührt. Im Mathebuch werden so 49 Stellen korrigiert, die
anderen drei Beispielbücher bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:47:40 +02:00
3ac5e1a8cc feat: Titel überschriftsloser Seiten aus dem Inhalt ableiten (QA R3)
Seiten ohne Überschrift, NAV-Eintrag und <title> werden am Inhalt erkannt:
- Bild am Buchanfang -> Cover
- Buchtitel und Autor -> Titelseite
- ©/ISBN/urheberrechtlich -> Impressum
- sonst die ersten acht Wörter
Erst danach greift der Dateiname. Im Roman-Beispiel werden aus
cinnertitle/cimprint so 'Titelseite' und 'Impressum'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:46:34 +02:00
6db834addd feat: Druck-Umbruchzeichen (↪/➥) in Codeblöcken auflösen (QA R6)
Fortsetzungszeilen werden wieder mit genau einem Leerzeichen zur
ursprünglichen Codezeile zusammengefügt. Im Python-Beispielbuch sind das
195 Stellen; danach lassen sich 247 statt 203 von 255 Python-Blöcken
parsen. Abschaltbar mit --keep-wrap-markers; der Bericht enthält
wrap_lines_joined pro Block und einen Hinweis pro Kapitel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:45:16 +02:00
12 changed files with 275 additions and 45 deletions

View file

@ -164,6 +164,10 @@ python3 epub2md.py python-buch.epub output/python-buch --code-language python
Das hilft Editoren beim Einfärben des Codes. Nachteil: Auch Programmausgaben und Shell-Befehle im Buch Das hilft Editoren beim Einfärben des Codes. Nachteil: Auch Programmausgaben und Shell-Befehle im Buch
werden dann als Python markiert. werden dann als Python markiert.
Zu lange Codezeilen sind im gedruckten Buch oft umbrochen und die Fortsetzung mit `↪` markiert. Das
Programm fügt solche Zeilen automatisch wieder zusammen, damit der Code lauffähig ist. Wer die
Druckfassung unverändert haben möchte, gibt `--keep-wrap-markers` an.
### Mathematikbuch ### Mathematikbuch
```bash ```bash
@ -309,13 +313,13 @@ Nein. Das Programm arbeitet vollständig offline und greift nicht aufs Netz zu.
**Wird das Original-E-Book verändert?** **Wird das Original-E-Book verändert?**
Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in den Ausgabeordner gelegt. Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in den Ausgabeordner gelegt.
**Warum heißen manche Kapiteldateien seltsam (z. B. `cinnertitle`)?** **Warum heißen manche Kapiteldateien „Titelseite“, „Impressum“ oder nach ihren ersten Wörtern?**
Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der Manche E-Book-Seiten haben weder Überschrift noch Titel. Das Programm erkennt sie dann am Inhalt.
interne Dateiname verwendet. Gelingt das nicht, werden die ersten Wörter der Seite verwendet, nur im Notfall der interne Dateiname.
**Warum steht in einem Codebeispiel ein `↪` oder ist es falsch eingerückt?** **Warum ist ein Codebeispiel falsch eingerückt?**
So steht es im E-Book selbst (Umbruchzeichen bzw. Satzfehler des Verlags). Der Code wird bewusst So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu
zeichengetreu übernommen. übernommen. Nur die Druck-Umbruchzeichen `↪` werden aufgelöst (abschaltbar mit `--keep-wrap-markers`).
**Können Dateinamen Umlaute enthalten?** **Können Dateinamen Umlaute enthalten?**
Standardmäßig werden Umlaute umschrieben (`ä` → `ae`). Mit `--slug-language none` bleiben sie erhalten, Standardmäßig werden Umlaute umschrieben (`ä` → `ae`). Mit `--slug-language none` bleiben sie erhalten,
@ -345,6 +349,7 @@ python3 epub2md_merge.py --help
| `--force` | frühere Ausgabe ersetzen | | `--force` | frühere Ausgabe ersetzen |
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | | `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
| `--math auto/tex/mathml/image` | Formeldarstellung | | `--math auto/tex/mathml/image` | Formeldarstellung |
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung | | `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |

View file

@ -96,6 +96,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/<Pfad im EPUB>` | | `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/<Pfad im EPUB>` |
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | | `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` | | `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) | | `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
| `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) |
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
@ -158,7 +159,12 @@ output/mein-buch/
3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt 3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt
`1.2`), wird an das laufende Kapitel angehängt. `1.2`), wird an das laufende Kapitel angehängt.
4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus 4. Dateien ohne Überschrift (Cover, Titelei, Widmung) werden eigene Kapitel. Ihr Titel kommt aus
NAV/NCX, `<title>`, `epub:type` oder dem Dateinamen. NAV/NCX, `<title>` oder `epub:type`. Fehlt all das, entscheidet der Inhalt:
- nur ein Bild am Buchanfang → „Cover“
- Buchtitel und Autorenname → „Titelseite“
- ©/ISBN/„urheberrechtlich“ → „Impressum“
- sonst die ersten acht Wörter
- erst als letzte Notlösung der Dateiname
5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe). 5. Ein HTML-Inhaltsverzeichnis wird übersprungen, wenn NAV/NCX vorhanden ist (keine doppelte Ausgabe).
Links darauf zeigen auf `README.md`. Links darauf zeigen auf `README.md`.
6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`. 6. Überschriftenebenen werden je Datei verschoben: Der Kapiteltitel ist `#`, alle weiteren beginnen bei `##`.
@ -335,12 +341,14 @@ Verlustfreiheit behauptet (`lossless: false`).
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- Überschriftslose Dokumente ohne NAV-Eintrag und ohne `<title>` werden nach dem Dateinamen benannt - Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste
(z. B. `cinnertitle`). Wörter). Das ist eine Heuristik und trifft nicht jede Seite.
- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen - Aneinanderstoßende Feld-Spans ohne Leerraum (z. B. Autor | Titel | Reihe in Springer-Kapitelköpfen)
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). erhalten ein trennendes Leerzeichen. Das gilt nur bei unterschiedlichen Klassen und wenn rechts ein
- Druck-Fortsetzungszeichen (`↪`) in Listings und fehlerhafte Einrückungen der Vorlage werden Großbuchstabe oder eine Ziffer folgt. Andere direkt aneinanderstoßende Inline-Elemente bleiben
zeichengetreu übernommen. unverändert, damit Initialen und Wortteile nicht zerrissen werden.
- Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen
(`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`).
- Formelbilder ohne TeX-Alternativtext bleiben Bilder. - Formelbilder ohne TeX-Alternativtext bleiben Bilder.
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Für Bildbände gibt es - Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Für Bildbände gibt es
bislang keine Testbücher. bislang keine Testbücher.

View file

@ -86,9 +86,12 @@ def semantic_types(el: Any) -> list[str]:
class ChapterBuilder: class ChapterBuilder:
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics) -> None: def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
book_title: str = "", authors: list[str] | None = None) -> None:
self.level = chapter_level self.level = chapter_level
self.diag = diag self.diag = diag
self.book_title = book_title
self.author_names = _surnames(authors or [])
self.nav_titles: dict[str, str] = {} self.nav_titles: dict[str, str] = {}
self._index_nav(nav) self._index_nav(nav)
@ -230,6 +233,10 @@ class ChapterBuilder:
ch.title = _type_label(types) or nav or doc.title ch.title = _type_label(types) or nav or doc.title
else: else:
ch.title = nav or doc.title or _type_label(types) ch.title = nav or doc.title or _type_label(types)
if not ch.title:
ch.title, content_type = self._title_from_content(items, number)
if content_type:
types.append(content_type)
ch.title = ch.title or doc.path.rsplit("/", 1)[-1].rsplit(".", 1)[0] ch.title = ch.title or doc.path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
ch.synthetic_title = True ch.synthetic_title = True
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
@ -237,6 +244,26 @@ class ChapterBuilder:
ch.types = list(dict.fromkeys(types)) ch.types = list(dict.fromkeys(types))
return ch return ch
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
"""Title for a document without heading, nav entry and <title>: classify its content."""
text = norm_ws(" ".join(it if isinstance(it, str) else text_content(it)
for _, it in items if isinstance(it, str) or is_element(it)))
has_image = any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it))
for _, it in items)
if not text and has_image:
return ("Cover", "cover") if number == 1 else ("Bild", "")
low = text.lower()
if self.book_title and len(self.book_title) >= 4 and self.book_title.lower() in low \
and any(name.lower() in low for name in self.author_names):
return "Titelseite", "titlepage"
if re.search(r"©|\bisbn\b|copyright|urheberrechtlich|alle rechte vorbehalten|all rights reserved", low):
return "Impressum", "colophon"
words = text.split()
if not words:
return "", ""
short = " ".join(words[:8])
return (short + " …" if len(words) > 8 else short), ""
def _finalize(self, ch: Chapter) -> None: def _finalize(self, ch: Chapter) -> None:
kind = classify_title(ch.title) kind = classify_title(ch.title)
if kind and kind not in ch.types: if kind and kind not in ch.types:
@ -276,6 +303,17 @@ def leading_heading_level(item: Any) -> int:
return 0 return 0
def _surnames(authors: list[str]) -> list[str]:
"""'Adler-Olsen, Jussi' -> 'Adler-Olsen'; 'Erika Musterfrau' -> 'Musterfrau'."""
names = []
for a in authors:
a = a.strip()
name = a.split(",")[0].strip() if "," in a else (a.split()[-1] if a.split() else "")
if len(name) >= 3:
names.append(name)
return names
def _meaningful(items: list[tuple[str, Any]]) -> bool: def _meaningful(items: list[tuple[str, Any]]) -> bool:
return _first_meaningful(items) is not None return _first_meaningful(items) is not None

View file

@ -82,6 +82,9 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--code-language", type=code_language, default=None, metavar="SPRACHE", p.add_argument("--code-language", type=code_language, default=None, metavar="SPRACHE",
help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c " help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c "
"(erkannte Labels haben Vorrang; Standard: kein Label)") "(erkannte Labels haben Vorrang; Standard: kein Label)")
p.add_argument("--keep-wrap-markers", action="store_true",
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
p.add_argument("--strict", action="store_true", p.add_argument("--strict", action="store_true",
help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen") help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen")
p.add_argument("--force", action="store_true", p.add_argument("--force", action="store_true",
@ -114,6 +117,7 @@ def main(argv: list[str] | None = None) -> int:
slug_language=args.slug_language, slug_language=args.slug_language,
keep_html=args.keep_html, keep_html=args.keep_html,
code_language=args.code_language or "", code_language=args.code_language or "",
keep_wrap_markers=args.keep_wrap_markers,
strict=args.strict, strict=args.strict,
verbose=args.verbose, verbose=args.verbose,
dry_run=args.dry_run, dry_run=args.dry_run,

View file

@ -180,6 +180,27 @@ def detect_language_from_content(code: str) -> str:
return "" return ""
WRAP_MARKER = re.compile(r"^[ \t]*[↪➥][ \t]?")
def join_wrap_markers(code: str) -> tuple[str, int]:
"""Undo print line wrapping: a line starting with ↪/➥ continues the previous line.
Publishers wrap over-long code lines at a space and mark the continuation; the logical line is
restored with exactly one space. Returns (code, number of joined lines).
"""
out: list[str] = []
joined = 0
for line in code.split("\n"):
m = WRAP_MARKER.match(line)
if m and out:
out[-1] = out[-1].rstrip(" \t") + " " + line[m.end():].lstrip(" \t")
joined += 1
else:
out.append(line)
return "\n".join(out), joined
def fence_for(code: str) -> str: def fence_for(code: str) -> str:
longest = max((len(m) for m in re.findall(r"`+", code)), default=0) longest = max((len(m) for m in re.findall(r"`+", code)), default=0)
return "`" * max(3, longest + 1) return "`" * max(3, longest + 1)

View file

@ -24,6 +24,7 @@ from .codeblocks import (
fence_for, fence_for,
inline_code, inline_code,
is_block_code_container, is_block_code_container,
join_wrap_markers,
is_inline_code, is_inline_code,
) )
from .diagnostics import Diagnostics from .diagnostics import Diagnostics
@ -90,6 +91,7 @@ class RenderOptions:
keep_html: bool = False keep_html: bool = False
language: str = "de" language: str = "de"
code_language: str = "" # --code-language: label for blocks without a detected language code_language: str = "" # --code-language: label for blocks without a detected language
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
def md_escape(text: str, table: bool = False) -> str: def md_escape(text: str, table: bool = False) -> str:
@ -418,6 +420,9 @@ class Renderer:
out = [anchors] if anchors else [] out = [anchors] if anchors else []
if not code.strip(): if not code.strip():
return out return out
joined = 0
if not self.opt.keep_wrap_markers:
code, joined = join_wrap_markers(code)
lang, how = detect_language(el) lang, how = detect_language(el)
if not lang: if not lang:
lang = detect_language_from_content(code) lang = detect_language_from_content(code)
@ -429,7 +434,7 @@ class Renderer:
if not lang: if not lang:
self.ch.stats.code_blocks_unlabeled += 1 self.ch.stats.code_blocks_unlabeled += 1
self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang, self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang,
"detected_by": how, "lines": code.count("\n") + 1}) "detected_by": how, "lines": code.count("\n") + 1, "wrap_lines_joined": joined})
fence = fence_for(code) fence = fence_for(code)
out.append(f"{fence}{lang}\n{code}\n{fence}") out.append(f"{fence}{lang}\n{code}\n{fence}")
return out return out

View file

@ -102,6 +102,35 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id")) doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
remove_keep_tail(el) remove_keep_tail(el)
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path) diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
separated = _separate_field_spans(body)
if separated:
diag.info("inline-separator-added",
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path)
def _separate_field_spans(body: Any) -> int:
"""Insert a space between adjacent class-styled <span> "fields" that touch without whitespace.
Publishers sometimes lay out metadata as sibling spans (author | title | series) separated only by
CSS. Narrow rule to keep drop caps ("D|as") and split words ("Haupt|bahnhof") intact: both spans
carry different classes, no text between them, both have >= 2 characters and the right one starts
with an uppercase letter or digit.
"""
count = 0
for parent in iter_elements(body):
children = [c for c in parent if is_element(c)]
for left, right in zip(children, children[1:]):
if local(left.tag) != "span" or local(right.tag) != "span" or left.getnext() is not right:
continue
if left.tail or not classes(left) or not classes(right) or classes(left) == classes(right):
continue
lt, rt = text_content(left), text_content(right)
if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace():
continue
if rt[0].isupper() or rt[0].isdigit():
left.tail = " "
count += 1
return count
def _attached(el: Any, body: Any) -> bool: def _attached(el: Any, body: Any) -> bool:

View file

@ -46,6 +46,7 @@ class Options:
slug_language: str = "de" slug_language: str = "de"
keep_html: bool = False keep_html: bool = False
code_language: str = "" code_language: str = ""
keep_wrap_markers: bool = False
strict: bool = False strict: bool = False
verbose: bool = False verbose: bool = False
dry_run: bool = False dry_run: bool = False
@ -130,11 +131,36 @@ def _load_docs(reader: EpubReader, package: Any, diag: Diagnostics) -> list[Doc]
def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics,
generated_at: str, sha: str) -> Result: generated_at: str, sha: str) -> Result:
package = reader.load_package() package = reader.load_package()
md = package.metadata diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
diag.step(f"EPUB {package.version}: {md.title!r}, {len(package.spine)} Spine-Dokumente")
docs = _load_docs(reader, package, diag) docs = _load_docs(reader, package, diag)
_prepare_docs(docs, package, diag)
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag)
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
notes, chapters, resolver, rendered)
if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
# --- normalization and HTML-TOC handling _write_output(reader, input_path, output_dir, opts, files, rendered.media)
if opts.report:
report_path = Path(opts.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8")
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
@dataclass
class _Rendered:
renderer: Renderer
media: MediaRegistry
cover_out: str
endnotes: Chapter | None
endnotes_md: str
skipped_formula_images: int
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None:
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
title_tables = book_uses_title_tables(docs) title_tables = book_uses_title_tables(docs)
for doc in docs: for doc in docs:
normalize_doc(doc, diag, title_tables) normalize_doc(doc, diag, title_tables)
@ -155,13 +181,16 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
for d in docs: for d in docs:
d.skipped = "" d.skipped = ""
# --- notes, chapters, links
def _build_structure(docs: list[Doc], package: Any, opts: Options,
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
md = package.metadata
notes = collect_notes(docs, opts.footnotes, diag) notes = collect_notes(docs, opts.footnotes, diag)
diag.step(f"{len(notes.notes)} Anmerkungen erkannt") diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag).build(docs) chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
_assign_filenames(chapters, opts.slug_language) _assign_filenames(chapters, opts.slug_language)
readme_path = "README.md" if opts.toc else None resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
resolver = LinkResolver(docs, chapters, notes, readme_path)
nav_targets: list[tuple[str, str]] = [] nav_targets: list[tuple[str, str]] = []
def walk(entries: list[Any]) -> None: def walk(entries: list[Any]) -> None:
@ -173,8 +202,13 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
walk(package.toc) walk(package.toc)
resolver.add_targets(nav_targets) resolver.add_targets(nav_targets)
diag.step(f"{len(chapters)} Kapitel gebildet") diag.step(f"{len(chapters)} Kapitel gebildet")
return notes, chapters, resolver
# --- rendering
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
opts: Options, diag: Diagnostics) -> _Rendered:
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
md = package.metadata
media = MediaRegistry(package, opts.images, opts.slug_language) media = MediaRegistry(package, opts.images, opts.slug_language)
cover_out = "" cover_out = ""
if md.cover_path and opts.extract_media and reader.exists(md.cover_path): if md.cover_path and opts.extract_media and reader.exists(md.cover_path):
@ -183,7 +217,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
renderer = Renderer( renderer = Renderer(
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math, RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language, extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
code_language=opts.code_language), code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers),
resolver, media, notes, diag, set(reader.names), resolver, media, notes, diag, set(reader.names),
) )
width = max(3, len(str(len(chapters) + 1))) width = max(3, len(str(len(chapters) + 1)))
@ -195,6 +229,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
ch.markdown = renderer.render_chapter(ch) ch.markdown = renderer.render_chapter(ch)
ch.stats.word_count = _word_count(ch.markdown) ch.stats.word_count = _word_count(ch.markdown)
_check_chapter_size(ch, opts.chapter_level, diag) _check_chapter_size(ch, opts.chapter_level, diag)
_report_wrap_markers(renderer, diag)
endnotes: Chapter | None = None endnotes: Chapter | None = None
endnotes_md = "" endnotes_md = ""
if opts.footnotes == "end-of-book" and renderer.book_notes: if opts.footnotes == "end-of-book" and renderer.book_notes:
@ -203,8 +238,23 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
endnotes.filename = renderer.endnotes_file endnotes.filename = renderer.endnotes_file
endnotes.stats.footnotes = len(renderer.book_notes) endnotes.stats.footnotes = len(renderer.book_notes)
endnotes.stats.word_count = _word_count(endnotes_md) endnotes.stats.word_count = _word_count(endnotes_md)
skipped = _collect_remaining_media(reader, package, media, renderer, opts)
return _Rendered(renderer, media, cover_out, endnotes, endnotes_md, skipped)
# --- remaining media (fonts, css, audio, unreferenced images)
def _report_wrap_markers(renderer: Renderer, diag: Diagnostics) -> None:
wraps: dict[str, int] = {}
for block in renderer.code_blocks:
if block["wrap_lines_joined"]:
wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"]
for chapter_file, joined in wraps.items():
diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.",
chapter_file, chapter_file)
def _collect_remaining_media(reader: EpubReader, package: Any, media: MediaRegistry, renderer: Renderer,
opts: Options) -> int:
"""Register fonts, CSS, audio and unreferenced images; returns the number of formula images replaced by TeX."""
skipped_formula_images = 0 skipped_formula_images = 0
if opts.extract_media: if opts.extract_media:
for item in package.manifest.values(): for item in package.manifest.values():
@ -221,10 +271,17 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
for mf in media.files.values(): for mf in media.files.values():
if mf.data is None and reader.exists(mf.source): if mf.data is None and reader.exists(mf.source):
mf.size = reader.names[mf.source].file_size mf.size = reader.names[mf.source].file_size
return skipped_formula_images
# --- assemble output files in memory
unresolved_notes = [n for n in notes.notes.values() if n.ref_count and not n.label and notes.extracted] def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnostics, generated_at: str, sha: str,
for n in unresolved_notes: package: Any, docs: list[Doc], notes: Any, chapters: list[Chapter], resolver: LinkResolver,
rendered: _Rendered) -> tuple[dict[str, str], list[Chapter], dict[str, Any]]:
"""Build all output files in memory (chapters, README, metadata, manifest, report, diagnostics)."""
md = package.metadata
renderer, media, endnotes = rendered.renderer, rendered.media, rendered.endnotes
for n in notes.notes.values():
if n.ref_count and not n.label and notes.extracted:
diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key) diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key)
opf_dir = posixpath.dirname(package.opf_path) opf_dir = posixpath.dirname(package.opf_path)
files: dict[str, str] = {} files: dict[str, str] = {}
@ -235,13 +292,13 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
files[ch.filename] = front + ch.markdown files[ch.filename] = front + ch.markdown
if endnotes is not None: if endnotes is not None:
front = writer.chapter_frontmatter(endnotes, md, input_path.name, opf_dir, 0) if opts.frontmatter else "" front = writer.chapter_frontmatter(endnotes, md, input_path.name, opf_dir, 0) if opts.frontmatter else ""
files[endnotes.filename] = front + endnotes_md files[endnotes.filename] = front + rendered.endnotes_md
all_chapters = chapters + ([endnotes] if endnotes else []) all_chapters = chapters + ([endnotes] if endnotes else [])
stats = _book_stats(all_chapters, media, renderer, diag) stats = _book_stats(all_chapters, media, renderer, diag)
if opts.toc: if opts.toc:
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, cover_out, files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out,
package.version, input_path.name, opts.command) package.version, input_path.name, opts.command, str(output_dir))
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set() existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
if opts.copy_original: if opts.copy_original:
existing.add(f"source/{input_path.name}") existing.add(f"source/{input_path.name}")
@ -253,23 +310,14 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
stats["warnings"] = diag.count("warning") stats["warnings"] = diag.count("warning")
report = _report(input_path, sha, generated_at, package, opts, all_chapters, media, renderer, notes, diag, report = _report(input_path, sha, generated_at, package, opts, all_chapters, media, renderer, notes, diag,
docs, broken, skipped_formula_images) docs, broken, rendered.skipped_formula_images)
files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, cover_out, files["metadata.yaml"] = writer.metadata_yaml(md, input_path.name, sha, package.version, rendered.cover_out,
opts.public(), generated_at) opts.public(), generated_at)
files["manifest.json"] = writer.to_json(_manifest(md, all_chapters, media, diag)) files["manifest.json"] = writer.to_json(_manifest(md, all_chapters, media, diag))
files["conversion-report.json"] = writer.to_json(report) files["conversion-report.json"] = writer.to_json(report)
files["diagnostics/warnings.md"] = writer.warnings_md(diag) files["diagnostics/warnings.md"] = writer.warnings_md(diag)
files["diagnostics/unsupported-elements.md"] = writer.unsupported_md(diag) files["diagnostics/unsupported-elements.md"] = writer.unsupported_md(diag)
return files, all_chapters, report
if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
_write_output(reader, input_path, output_dir, opts, files, media)
if opts.report:
report_path = Path(opts.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8")
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
def _mostly_links(el: Any) -> bool: def _mostly_links(el: Any) -> bool:

View file

@ -87,7 +87,7 @@ def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0)
def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry], def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry],
resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str, resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str,
command: list[str]) -> str: command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS") -> str:
out = [f"# {md_escape(md.title or source_name)}"] out = [f"# {md_escape(md.title or source_name)}"]
if md.subtitle: if md.subtitle:
out.append(f"*{md_escape(md.subtitle)}*") out.append(f"*{md_escape(md.subtitle)}*")
@ -128,6 +128,20 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
out.append("\n".join(["| Kennzahl | Anzahl |", "| --- | ---: |"] + [f"| {lbl} | {stats.get(k, 0)} |" for k, lbl in labels])) out.append("\n".join(["| Kennzahl | Anzahl |", "| --- | ---: |"] + [f"| {lbl} | {stats.get(k, 0)} |" for k, lbl in labels]))
out.append("Details: [conversion-report.json](conversion-report.json), " out.append("Details: [conversion-report.json](conversion-report.json), "
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)") "[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
math = " --mathml" if stats.get("formulas") else ""
commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:",
f"pandoc {shlex.quote(stem + '.md')} --toc -s{math} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}",
]
out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```")
if math:
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
out.append("## Reproduktion") out.append("## Reproduktion")
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```") out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")
return "\n\n".join(out) + "\n" return "\n\n".join(out) + "\n"

View file

@ -133,3 +133,34 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"] assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"]
small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")])) small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")]))
assert not any(w["code"] == "chapter-large" for w in small.report["warnings"]) assert not any(w["code"] == "chapter-large" for w in small.report["warnings"])
def test_titles_derived_from_content_when_no_other_source(convert):
docs = [
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
Doc("cinner.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p><p>Roman</p>"),
Doc("cimp.xhtml", "<p>© 2024 Testverlag</p><p>Das Werk ist urheberrechtlich geschützt.</p>"),
Doc("cwid.xhtml", "<p>Für Anna, die immer an mich geglaubt hat, und für alle anderen.</p>"),
Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>"),
]
from conftest import PNG
run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG}))
names = [p.name for p in run.chapters()]
assert names == ["001-cover.md", "002-titelseite.md", "003-impressum.md",
"004-fuer-anna-die-immer-an-mich-geglaubt-hat.md", "005-eins.md"]
types = {c["file"]: c["types"] for c in run.report["chapters"]}
assert "cover" in types["chapters/001-cover.md"]
assert "titlepage" in types["chapters/002-titelseite.md"]
assert "colophon" in types["chapters/003-impressum.md"]
def test_adjacent_field_spans_get_a_separator(convert):
body = ('<h1>K</h1><div class="ContextInformation"><span class="Names">J. Liesen, V. Mehrmann</span>'
'<span class="BookTitles"><span class="BookTitle">Lineare Algebra</span></span>'
'<span class="Series">Springer Studium</span></div>'
'<p><span class="dropcap">D</span><span class="rest">as Wetter</span> war schlecht.</p>'
'<p><span class="a">Haupt</span><span class="b">bahnhof</span> und x<sub>i</sub>.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text
assert "Das Wetter war schlecht." in text
assert "Hauptbahnhof und x<sub>i</sub>." in text

View file

@ -101,3 +101,20 @@ def test_code_language_option_rejects_unknown_values(convert):
run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch") run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch")
assert run.rc == 1 assert run.rc == 1
assert "quatsch" in run.stderr assert "quatsch" in run.stderr
def test_print_wrap_markers_are_joined(convert):
body = ("<h1>W</h1><pre> return (s * (s -\n ↪ distance(a, b)) *\n s)\n"
"x = ['a', 'b',\n↪ 'c']</pre>")
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, _, code), = fenced_blocks(run.chapter("001"))
assert code == " return (s * (s - distance(a, b)) *\n s)\nx = ['a', 'b', 'c']"
item = run.report["code_blocks"]["items"][0]
assert item["wrap_lines_joined"] == 2
def test_print_wrap_markers_can_be_kept(convert):
body = "<h1>W</h1><pre>a = (1,\n↪ 2)</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers")
(_, _, code), = fenced_blocks(run.chapter("001"))
assert code == "a = (1,\n↪ 2)"

View file

@ -104,3 +104,13 @@ def test_svg_file_formula_is_reported_as_svg(convert):
report = convert(math_book()).report report = convert(math_book()).report
origins = {f["origin"] for f in report["formulas"]["items"]} origins = {f["origin"] for f in report["formulas"]["items"]}
assert "svg" in origins assert "svg" in origins
def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert):
run = convert(math_book())
readme = (run.out / "README.md").read_text(encoding="utf-8")
assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme
assert "--mathml" in readme
plain = convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>Text</p>")]))
plain_readme = (plain.out / "README.md").read_text(encoding="utf-8")
assert "## Weiterverarbeitung" in plain_readme and "--mathml" not in plain_readme