Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84821a2fcd | |||
| bdc611eefc | |||
| 5f58589c2e | |||
| 3ac5e1a8cc | |||
| 6db834addd |
12 changed files with 275 additions and 45 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,11 +271,18 @@ 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,
|
||||||
diag.warn("note-not-rendered", "Anmerkung wurde referenziert, aber nicht ausgegeben.", n.key)
|
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)
|
||||||
opf_dir = posixpath.dirname(package.opf_path)
|
opf_dir = posixpath.dirname(package.opf_path)
|
||||||
files: dict[str, str] = {}
|
files: dict[str, str] = {}
|
||||||
for ch in chapters:
|
for ch in chapters:
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue