Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57d485baa3 | |||
| 64d0cb0357 | |||
| 6fe422919f |
16 changed files with 278 additions and 48 deletions
|
|
@ -141,9 +141,10 @@ python3 epub2md_merge.py output/meinbuch --force
|
|||
|
||||
### Roman (Teile und Kapitel)
|
||||
|
||||
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei
|
||||
pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und
|
||||
empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so:
|
||||
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große
|
||||
Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro
|
||||
**Kapitel** an; kurze Abschnitte wie Vorwort oder Anhang bleiben eine Datei. Du musst dafür nichts
|
||||
einstellen. Soll wirklich jeder Abschnitt mit Unterüberschrift eine eigene Datei werden, gilt:
|
||||
|
||||
```bash
|
||||
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
||||
|
|
@ -388,8 +389,8 @@ gilt hier derselbe Umweg über HTML.
|
|||
- Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel,
|
||||
Herkunft) helfen beim Zuordnen von Fundstellen.
|
||||
- `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern.
|
||||
- Achte auf die Warnung `chapter-large`: Sehr große Kapiteldateien lassen sich schlecht zerlegen. Dann
|
||||
mit höherem `--chapter-level` neu umwandeln.
|
||||
- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest
|
||||
eingestellt und erscheint die Warnung `chapter-large`, dann mit dem empfohlenen Wert neu umwandeln.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -514,7 +515,7 @@ python3 epub2md_merge.py --help
|
|||
| `--dry-run` | nur anzeigen, nichts schreiben |
|
||||
| `--verbose` | ausführliche Ausgabe |
|
||||
| `--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 `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) |
|
||||
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
||||
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
||||
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
||||
|
|
|
|||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -5,6 +5,8 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
|
|||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.3.0] – 2026-10-06
|
||||
|
||||
### Neu
|
||||
|
||||
- `pandoc/book.lua`: Inhaltsverzeichnis wie im gedruckten Buch nach Cover, Titelseite und Impressum statt
|
||||
|
|
@ -22,6 +24,10 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
|
|||
|
||||
### Geändert
|
||||
|
||||
- `--chapter-level auto` ist der neue Standard: Ebene 1 wie bisher, aber Kapitel mit über 30 000 Wörtern
|
||||
und mindestens zwei Unterkapiteln (Teile eines Romans) werden an ihren Unterkapiteln geteilt; alle
|
||||
anderen Kapitel bleiben ganz (Info `chapter-split`). Der Roman im Test ergibt 78 statt 9 Dateien, die
|
||||
übrigen Beispielbücher bleiben unverändert. Eine feste Zahl verhält sich wie bisher (R2).
|
||||
- `epub2md_merge.py` kürzt ISO-Zeitstempel im Buch-Kopf (`2025-01-13T08:00:00+00:00` → `13.01.2025`, bei
|
||||
nicht-deutschen Büchern `2025-01-13`), damit die Pandoc-Titelseite ein lesbares Datum zeigt.
|
||||
- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel,
|
||||
|
|
@ -29,6 +35,15 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
|
|||
|
||||
### Behoben
|
||||
|
||||
- `epub2md_merge.py`: Kapitel-Startanker heißen jetzt `kap-003-name` statt `003-name`. Pandocs EPUB-Ausgabe
|
||||
löste Links auf IDs mit führender Ziffer nicht auf (C-Buch 32, Mathebuch 20 tote Links im EPUB).
|
||||
`pandoc/book.lua` erkennt beide Formen (QA3-05).
|
||||
- Buch-README: Der EPUB-Befehl enthält bei Büchern mit Formeln `--mathml`. Ohne blieben im Mathebuch
|
||||
1072 Formeln als TeX-Text stehen (QA3-04).
|
||||
- TeX aus `alt`-Texten wird für Pandoc portabel gemacht (`\"u` → `ü`, `\O` → `\emptyset`, `@{…}` und
|
||||
`\arraycolsep` in `array`, leeres `\displaystyle` vor Zellenende). Im Mathebuch kann Pandoc damit 4985 statt
|
||||
4977 Formeln setzen; vorher blieben 9 als TeX-Text stehen (QA3-06).
|
||||
- `pandoc/print.css` und `pandoc/book.lua`: keine Seitenzahl auf dem Cover.
|
||||
- CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `<link>` (oder `@import`) einbindet.
|
||||
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch
|
||||
machte das aus fetten Buchstaben Inline-Code (`` `st`an`d`ard ``) und setzte falsche `<mark>`; eine
|
||||
|
|
@ -88,6 +103,7 @@ Erste Fassung.
|
|||
- Alle Befunde der QA-Prüfungen (R1–R7, QA2-01 bis QA2-19) behoben oder abgemildert.
|
||||
- README und Bedienungsanleitung.
|
||||
|
||||
[Unveröffentlicht]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.2.0...main
|
||||
[Unveröffentlicht]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.3.0...main
|
||||
[0.3.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://kitux.de/forgejo/dschlueter/epub2md/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://kitux.de/forgejo/dschlueter/epub2md/src/tag/v0.1.0
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -71,7 +71,7 @@ python3 epub2md.py \
|
|||
--toc \
|
||||
--verbose
|
||||
|
||||
# Roman mit Teilen (h1) und Kapiteln (h2): ein File pro Kapitel
|
||||
# Jedes Kapitel unterhalb von h2 als eigene Datei (sonst teilt auto nur sehr große Kapitel)
|
||||
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
||||
|
||||
# Nur analysieren, nichts schreiben
|
||||
|
|
@ -88,7 +88,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
|
|||
| `--format {gfm,commonmark}` | `gfm` | `gfm`: Pipe-Tabellen, `~~durchgestrichen~~`, `[^fn]`-Fußnoten. `commonmark`: Tabellen als HTML, `<del>`, Fußnoten als Anker-Links |
|
||||
| `--extract-media` / `--no-extract-media` | an | Medien nach `media/` extrahieren. Ohne Extraktion stehen Bildreferenzen als Text `[Bild: alt – Pfad]` da (keine kaputten Links) |
|
||||
| `--copy-original` / `--no-copy-original` | aus | Original-EPUB nach `source/` kopieren |
|
||||
| `--chapter-level N` | `1` | Überschriftenebene (1–6), ab der eine neue Kapiteldatei beginnt |
|
||||
| `--chapter-level {auto,1-6}` | `auto` | Überschriftenebene, ab der eine neue Kapiteldatei beginnt. `auto`: Ebene 1; nur Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln (z. B. Teile eines Romans) werden an ihren Unterkapiteln geteilt (Info `chapter-split`). Eine Zahl gilt ausnahmslos für das ganze Buch |
|
||||
| `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl |
|
||||
| `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei |
|
||||
| `--footnotes {keep-links,end-of-chapter,end-of-book,inline}` | `end-of-chapter` | siehe [Fußnoten](#fußnoten-modi) |
|
||||
|
|
@ -158,7 +158,7 @@ output/mein-buch/
|
|||
## Wie Kapitel gebildet werden
|
||||
|
||||
1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis).
|
||||
2. Überschriften `h1`…`hN` (N = `--chapter-level`) und semantische Abschnitte (`epub:type`/`role`:
|
||||
2. Überschriften `h1`…`hN` (N = `--chapter-level`, bei `auto` 1 bzw. 2 in sehr großen Kapiteln) und semantische Abschnitte (`epub:type`/`role`:
|
||||
chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien,
|
||||
auch mitten in einer XHTML-Datei.
|
||||
3. Eine XHTML-Datei, die nur mit einer tieferen Überschrift beginnt (z. B. eine Datei pro Abschnitt
|
||||
|
|
@ -188,10 +188,13 @@ Verlagsspezifische Heuristiken: Layout-Tabellen mit Klassen wie `Head1`/`Title_d
|
|||
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.
|
||||
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
|
||||
|
||||
**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt
|
||||
`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil.
|
||||
Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet
|
||||
epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung.
|
||||
**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der
|
||||
Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den
|
||||
Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz.
|
||||
Verzeichnisse (Register, Literatur, Glossar) werden dabei nie zerlegt. `--chapter-level 2` teilt dagegen
|
||||
jedes Kapitel des Buches an `h2`. Ist bei einer festen Zahl eine Kapiteldatei größer als 30 000 Wörter und
|
||||
hat sie mindestens zwei `##`-Unterabschnitte, meldet epub2md eine Warnung `chapter-large` mit der
|
||||
passenden `--chapter-level`-Empfehlung.
|
||||
|
||||
## Math-Modi
|
||||
|
||||
|
|
@ -309,7 +312,8 @@ verwendet und das Verzeichnis gibt den Namen vor.
|
|||
Beim Zusammenfügen passiert Folgendes:
|
||||
|
||||
- Die Frontmatter der einzelnen Kapitel wird entfernt.
|
||||
- Jedes Kapitel erhält einen Startanker (`<a id="003-kapitelname"></a>`).
|
||||
- Jedes Kapitel erhält einen Startanker (`<a id="kap-003-kapitelname"></a>`). Er beginnt mit einem Buchstaben,
|
||||
weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst.
|
||||
- Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei.
|
||||
- Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden
|
||||
ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf.
|
||||
|
|
@ -387,9 +391,8 @@ Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bere
|
|||
**PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code.
|
||||
Dabei gehen HTML-Tabellen samt Inhalt verloren, und `<a id>`-Sprungziele fehlen, sodass interne Links im PDF
|
||||
ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele.
|
||||
Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen
|
||||
stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den
|
||||
passenden Befehl.
|
||||
Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen
|
||||
fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl.
|
||||
|
||||
Alternativ direkt aus den Kapiteldateien:
|
||||
|
||||
|
|
@ -404,8 +407,10 @@ pandoc output/programmierbuch/chapters/*.md \
|
|||
- Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`.
|
||||
- Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien
|
||||
direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden.
|
||||
- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er
|
||||
belässt sie als TeX, mit der Warnung „Could not convert TeX math“.
|
||||
- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`,
|
||||
`\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein
|
||||
leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`)
|
||||
bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln).
|
||||
|
||||
## Bericht und Diagnose
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""epub2md – konvertiert DRM-freie EPUB-Dateien in kapitelweise Markdown-Sammlungen."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
TOOL_NAME = "epub2md"
|
||||
|
|
|
|||
|
|
@ -105,9 +105,11 @@ def semantic_types(el: Any) -> list[str]:
|
|||
|
||||
class ChapterBuilder:
|
||||
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
|
||||
book_title: str = "", authors: list[str] | None = None) -> None:
|
||||
book_title: str = "", authors: list[str] | None = None, deep_starts: set[Any] | None = None) -> None:
|
||||
self.level = chapter_level
|
||||
self.diag = diag
|
||||
self.deep_starts = deep_starts or set() # chapter headings whose sections are split one level deeper
|
||||
self._deep = False # current chapter is one of them
|
||||
self._special_level = 0 # level of a split-out special heading whose section is still open
|
||||
self._in_special = False # current chapter itself is a bibliography/index/glossary
|
||||
self.book_title = book_title
|
||||
|
|
@ -129,6 +131,12 @@ class ChapterBuilder:
|
|||
if lvl and has_content(el):
|
||||
special = _special_heading(el)
|
||||
if lvl <= self.level:
|
||||
if update:
|
||||
self._special_level = 0
|
||||
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
||||
self._deep = el in self.deep_starts
|
||||
return Marker(el, "heading", lvl)
|
||||
if self._deep and lvl == self.level + 1:
|
||||
if update:
|
||||
self._special_level = 0
|
||||
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
||||
|
|
@ -196,7 +204,7 @@ class ChapterBuilder:
|
|||
body_segments = segments[1:]
|
||||
if _meaningful(head_items):
|
||||
first = _first_meaningful(head_items)
|
||||
if chapters and leading_heading_level(first) > self.level and not semantic_types(doc.body):
|
||||
if chapters and leading_heading_level(first) > self.level + self._deep and not semantic_types(doc.body):
|
||||
chapters[-1].items.extend(pending + head_items)
|
||||
pending = []
|
||||
if doc.path not in chapters[-1].docs:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ def code_language(value: str) -> str:
|
|||
return lang
|
||||
|
||||
|
||||
def _chapter_level(value: str) -> int | str:
|
||||
if value == "auto":
|
||||
return value
|
||||
if value.isdigit() and 1 <= int(value) <= 6:
|
||||
return int(value)
|
||||
raise argparse.ArgumentTypeError("auto oder eine Zahl zwischen 1 und 6 erwartet")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = _Parser(
|
||||
prog="epub2md.py",
|
||||
|
|
@ -61,9 +69,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
|
||||
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
|
||||
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
|
||||
p.add_argument("--chapter-level", type=int, default=1, metavar="N",
|
||||
help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). "
|
||||
"Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll")
|
||||
p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}",
|
||||
help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, "
|
||||
"nur sehr große Kapitel (über 30000 Wörter, z. B. Teile eines Romans) werden an ihren "
|
||||
"Unterkapiteln geteilt. Eine Zahl 1–6 gilt ausnahmslos für das ganze Buch")
|
||||
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
|
||||
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
|
||||
|
|
|
|||
|
|
@ -365,7 +365,54 @@ def strip_tex_delimiters(text: str) -> str:
|
|||
if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c):
|
||||
t = t[len(o) : len(t) - len(c)]
|
||||
break
|
||||
return t.strip()
|
||||
return portable_tex(t.strip())
|
||||
|
||||
|
||||
UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"}
|
||||
|
||||
|
||||
def portable_tex(tex: str) -> str:
|
||||
"""Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX:
|
||||
text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in
|
||||
array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes
|
||||
spacing, never content."""
|
||||
tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex)
|
||||
tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex)
|
||||
tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex)
|
||||
tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex)
|
||||
tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for m in re.finditer(r"\\begin\{array\}\{", tex):
|
||||
if m.start() < pos:
|
||||
continue
|
||||
end = _closing_brace(tex, m.end() - 1)
|
||||
if end < 0:
|
||||
break
|
||||
spec = tex[m.end():end]
|
||||
i = spec.find("@{")
|
||||
while i >= 0:
|
||||
close = _closing_brace(spec, i + 1)
|
||||
if close < 0:
|
||||
break
|
||||
spec = spec[:i] + spec[close + 1:]
|
||||
i = spec.find("@{")
|
||||
out.append(tex[pos:m.end()] + spec)
|
||||
pos = end
|
||||
return "".join(out) + tex[pos:]
|
||||
|
||||
|
||||
def _closing_brace(text: str, open_pos: int) -> int:
|
||||
"""Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced)."""
|
||||
depth = 0
|
||||
for i in range(open_pos, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
def is_display_tex(text: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int:
|
|||
for m in TOKEN.finditer(seg):
|
||||
if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids:
|
||||
ids.append(m.group("avalue"))
|
||||
start = stems[ch.rel]
|
||||
start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…')
|
||||
while start in used or start in ids:
|
||||
start = "kapitel-" + start
|
||||
ch.start_anchor = start
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from typing import Any
|
|||
from urllib.parse import unquote
|
||||
|
||||
from . import TOOL_NAME, __version__
|
||||
from .chapters import ChapterBuilder
|
||||
from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level
|
||||
from .diagnostics import Diagnostics
|
||||
from .css import StyleSheet, base_style, emphasis
|
||||
from .epub_reader import EpubReader, decode_markup, parse_xml
|
||||
|
|
@ -26,7 +26,8 @@ from .html_converter import RenderOptions, Renderer
|
|||
from .linkcheck import check_links
|
||||
from .media import MediaRegistry
|
||||
from .models import Chapter
|
||||
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
|
||||
from .normalize import (Doc, book_uses_title_tables, has_content, is_html_toc_doc, make_doc, normalize_doc,
|
||||
remove_keep_tail)
|
||||
from .references import LinkResolver
|
||||
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
|
||||
from . import writer
|
||||
|
|
@ -40,7 +41,7 @@ class Options:
|
|||
format: str = "gfm"
|
||||
extract_media: bool = True
|
||||
copy_original: bool = False
|
||||
chapter_level: int = 1
|
||||
chapter_level: int | str = "auto" # 1–6, or "auto": level 1 plus oversized chapters split one level deeper
|
||||
toc: bool = True
|
||||
frontmatter: bool = True
|
||||
footnotes: str = "end-of-chapter"
|
||||
|
|
@ -81,8 +82,8 @@ def check_paths(input_path: Path, output_dir: Path, opts: Options) -> None:
|
|||
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
|
||||
if not os.access(input_path, os.R_OK):
|
||||
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
|
||||
if not 1 <= opts.chapter_level <= 6:
|
||||
raise InputError("--chapter-level muss zwischen 1 und 6 liegen.")
|
||||
if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6):
|
||||
raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.")
|
||||
if opts.dry_run:
|
||||
return
|
||||
if output_dir.exists() and not output_dir.is_dir():
|
||||
|
|
@ -214,10 +215,9 @@ def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
|
|||
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)
|
||||
diag.step(f"{len(notes.notes)} Anmerkungen erkannt")
|
||||
chapters = ChapterBuilder(opts.chapter_level, package.toc, diag, md.title, md.authors).build(docs)
|
||||
chapters = _build_chapters(docs, package, opts, diag)
|
||||
_assign_filenames(chapters, opts.slug_language)
|
||||
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
|
||||
nav_targets: list[tuple[str, str]] = []
|
||||
|
|
@ -296,7 +296,7 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
|
|||
diag.step(f"Rendere {ch.filename}")
|
||||
ch.markdown = renderer.render_chapter(ch)
|
||||
ch.stats.word_count = _word_count(ch.markdown)
|
||||
_check_chapter_size(ch, opts.chapter_level, diag)
|
||||
_check_chapter_size(ch, _base_level(opts), diag)
|
||||
_report_wrap_markers(renderer, diag)
|
||||
if not opts.code_language:
|
||||
_suggest_code_language(renderer, diag)
|
||||
|
|
@ -433,16 +433,52 @@ def _assign_filenames(chapters: list[Chapter], slug_language: str) -> None:
|
|||
ch.filename = f"chapters/{candidate}.md"
|
||||
|
||||
|
||||
def _base_level(opts: Options) -> int:
|
||||
return 1 if opts.chapter_level == "auto" else int(opts.chapter_level)
|
||||
|
||||
|
||||
def _build_chapters(docs: list[Doc], package: Any, opts: Options, diag: Diagnostics) -> list[Chapter]:
|
||||
"""Chapter units at --chapter-level. With "auto", chapters over LARGE_CHAPTER_WORDS with at least two
|
||||
sub-headings (parts of a novel, say) are split one level deeper; all other chapters stay whole."""
|
||||
md = package.metadata
|
||||
level = _base_level(opts)
|
||||
if opts.chapter_level == "auto" and level < 6:
|
||||
probe = ChapterBuilder(level, package.toc, Diagnostics(), md.title, md.authors).build(docs)
|
||||
deep = {ch.title_el for ch in probe if _oversized(ch, level)}
|
||||
if deep:
|
||||
titles = ", ".join(f"„{ch.title}“" for ch in probe if ch.title_el in deep)
|
||||
diag.info("chapter-split", f"Sehr große Kapitel ({titles}) werden an ihren Überschriften der Ebene "
|
||||
f"{level + 1} aufgeteilt (--chapter-level auto).")
|
||||
return ChapterBuilder(level, package.toc, diag, md.title, md.authors, deep_starts=deep).build(docs)
|
||||
return ChapterBuilder(level, package.toc, diag, md.title, md.authors).build(docs)
|
||||
|
||||
|
||||
def _oversized(ch: Chapter, level: int) -> bool:
|
||||
"""Heading-started chapter (not glossary/index/…) with too many words and >= 2 next-level sub-headings."""
|
||||
if ch.title_el is None or ch.title_level > level or SPECIAL_KINDS & set(ch.types):
|
||||
return False
|
||||
words = 0
|
||||
sections = 0
|
||||
for _, it in ch.items:
|
||||
if isinstance(it, str):
|
||||
words += len(it.split())
|
||||
elif is_element(it):
|
||||
words += len(text_content(it).split())
|
||||
sections += sum(1 for e in iter_elements(it) if heading_level(e) == level + 1 and has_content(e))
|
||||
return words > LARGE_CHAPTER_WORDS and sections >= 2
|
||||
|
||||
|
||||
def _check_chapter_size(ch: Chapter, chapter_level: int, diag: Diagnostics) -> None:
|
||||
"""Suggest a deeper --chapter-level for very large chapters with enough sub-headings."""
|
||||
if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6:
|
||||
return
|
||||
sections = len(re.findall(r"^## ", ch.markdown, re.M))
|
||||
if sections >= 2:
|
||||
deeper = max(chapter_level, ch.title_level or 1) + 1
|
||||
diag.warn(
|
||||
"chapter-large",
|
||||
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
|
||||
f"--chapter-level {chapter_level + 1} würde sie als eigene Dateien ausgeben.",
|
||||
f"--chapter-level {deeper} würde sie als eigene Dateien ausgeben.",
|
||||
ch.docs[0] if ch.docs else "",
|
||||
ch.filename,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
|
|||
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
|
||||
"# danach in diesem Ordner:",
|
||||
f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
|
||||
f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}",
|
||||
f"pandoc {md_} --toc{math}{lua} -o {shlex.quote(stem + '.epub')}",
|
||||
] + (latex + weasy if math else weasy + latex)
|
||||
out.append("## Weiterverarbeitung")
|
||||
out.append("```bash\n" + "\n".join(commands) + "\n```")
|
||||
|
|
@ -166,7 +166,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
|
|||
out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus "
|
||||
"in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.")
|
||||
if stats.get("formulas"):
|
||||
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
|
||||
out.append("Das Buch enthält Formeln: Für HTML und EPUB `--mathml` (bei HTML auch `--mathjax`) verwenden, sonst belässt "
|
||||
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
|
||||
out.append("## Reproduktion")
|
||||
out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```")
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This filter, for HTML output:
|
|||
* builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured),
|
||||
* drops Pandoc's title block when the book has its own title page, or moves it to the placeholder,
|
||||
* shows the cover (first chapter with nothing but one picture) without the caption "Cover",
|
||||
* turns the chapter anchors <a id="001-…"></a> into <div class="chapter-start"> so that print.css can
|
||||
* turns the chapter anchors <a id="kap-001-…"></a> into <div class="chapter-start"> so that print.css can
|
||||
start every chapter – including cover, title page and imprint – on a new page.
|
||||
|
||||
For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes
|
||||
|
|
@ -55,7 +55,7 @@ local function title_block(meta)
|
|||
return pandoc.RawBlock("html", table.concat(html, "\n"))
|
||||
end
|
||||
|
||||
-- Paragraph that only holds chapter start anchors (<a id="001-cover"></a>) → id of the first one.
|
||||
-- Paragraph that only holds chapter start anchors (<a id="kap-001-cover"></a>) → id of the first one.
|
||||
local function chapter_anchor(block)
|
||||
if block.t ~= "Para" and block.t ~= "Plain" then
|
||||
return nil
|
||||
|
|
@ -65,7 +65,8 @@ local function chapter_anchor(block)
|
|||
if inl.t ~= "RawInline" then
|
||||
return nil
|
||||
end
|
||||
local found = inl.text:match('^<a id="(%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(%d%d%d%-[^"]*)"></a>$')
|
||||
local found = inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(kap%-%d%d%d%-[^"]*)"></a>$')
|
||||
or inl.text:match('^<a id="(%d%d%d%-[^"]*)">$') or inl.text:match('^<a id="(%d%d%d%-[^"]*)"></a>$') -- before 0.3.0
|
||||
if found then
|
||||
id = id or found
|
||||
elseif inl.text ~= "</a>" then
|
||||
|
|
@ -143,6 +144,7 @@ local function latex_book(doc)
|
|||
blocks:insert(pandoc.RawBlock("latex", "\\clearpage"))
|
||||
blocks:insert(block)
|
||||
elseif block.t == "Div" and block.classes:includes("cover") then
|
||||
blocks:insert(pandoc.RawBlock("latex", "\\thispagestyle{empty}")) -- no page number on the cover
|
||||
blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows
|
||||
img.attributes.width = nil
|
||||
img.attributes.height = "20cm"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
--pdf-engine=weasyprint -o buch.pdf
|
||||
book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */
|
||||
@page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } }
|
||||
@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */
|
||||
body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify;
|
||||
font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; }
|
||||
#title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; }
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ def test_glossary_anchor_is_placed_before_bold_term(convert):
|
|||
def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
|
||||
words = " ".join(["Wort"] * 11000)
|
||||
body = "<h1>Teil 1</h1>" + "".join(f"<h2>Kapitel {i}</h2><p>{words}</p>" for i in range(3))
|
||||
run = convert(Book(docs=[Doc("t.xhtml", body)]))
|
||||
run = convert(Book(docs=[Doc("t.xhtml", body)]), "--chapter-level", "1")
|
||||
hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"]
|
||||
assert len(hints) == 1
|
||||
assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"]
|
||||
|
|
@ -135,6 +135,33 @@ def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
|
|||
assert not any(w["code"] == "chapter-large" for w in small.report["warnings"])
|
||||
|
||||
|
||||
def test_auto_level_splits_only_oversized_chapters(convert):
|
||||
"""Default --chapter-level auto: a part over 30000 words is split at its h2, other chapters stay whole (R2)."""
|
||||
words = " ".join(["Wort"] * 11000)
|
||||
docs = [
|
||||
Doc("v.xhtml", "<h1>Vorwort</h1><p>kurz</p><h2>Dank</h2><p>danke</p><h2>Hinweise</h2><p>lesen</p>"),
|
||||
Doc("t1.xhtml", "<h1>Teil 1</h1><p>Einleitung</p>"),
|
||||
*[Doc(f"k{i}.xhtml", f"<h2>Kapitel {i}</h2><p>{words}</p><h3>Abschnitt</h3><p>x</p>") for i in range(1, 4)],
|
||||
Doc("t2.xhtml", "<h1>Teil 2</h1><h2>Kapitel 4</h2><p>kurz</p><h2>Kapitel 5</h2><p>kurz</p>"),
|
||||
]
|
||||
run = convert(Book(docs=docs))
|
||||
assert [p.name for p in run.chapters()] == [
|
||||
"001-vorwort.md", "002-teil-1.md", "003-kapitel-1.md", "004-kapitel-2.md", "005-kapitel-3.md", "006-teil-2.md"]
|
||||
assert "## Dank" in run.chapter("001") and "## Kapitel 5" in run.chapter("006")
|
||||
assert "Einleitung" in run.chapter("002") and "## Abschnitt" in run.chapter("003")
|
||||
assert run.chapter("003").count("\n# ") == 1
|
||||
assert not any(w["code"] == "chapter-large" for w in run.report["warnings"])
|
||||
assert any(i["code"] == "chapter-split" for i in run.report["infos"])
|
||||
assert run.report["options"]["chapter_level"] == "auto"
|
||||
explicit = convert(Book(docs=docs), "--chapter-level", "1")
|
||||
assert len(explicit.chapters()) == 3
|
||||
|
||||
|
||||
def test_chapter_level_rejects_invalid_values(convert):
|
||||
for value in ("0", "7", "x"):
|
||||
assert convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1>")]), "--chapter-level", value).rc == 1
|
||||
|
||||
|
||||
def test_titles_derived_from_content_when_no_other_source(convert):
|
||||
docs = [
|
||||
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import pytest
|
|||
from lxml import etree
|
||||
|
||||
from conftest import PNG, SVG, Book, Doc
|
||||
from epub2md.mathml import Unsupported, mathml_to_tex
|
||||
from epub2md.mathml import Unsupported, mathml_to_tex, portable_tex
|
||||
|
||||
M = 'xmlns="http://www.w3.org/1998/Math/MathML"'
|
||||
|
||||
|
|
@ -111,6 +111,24 @@ def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert):
|
|||
readme = (run.out / "README.md").read_text(encoding="utf-8")
|
||||
assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme
|
||||
assert "--mathml" in readme
|
||||
pandoc_lines = [ln for ln in readme.splitlines() if ln.startswith("pandoc ")]
|
||||
assert pandoc_lines and all("--mathml" in ln for ln in pandoc_lines) # EPUB too, else TeX stays text (QA3-04)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tex,expected", [
|
||||
(r'\mbox{gr\"un}, \text{F\"{u}r}', r"\mbox{grün}, \text{Für}"),
|
||||
(r'\text{ f{\"u}r } j', r"\text{ f{ü}r } j"), # braces stay balanced
|
||||
(r"{\mathscr L}(A,b)={{\O }}", r"{\mathscr L}(A,b)={{\emptyset }}"),
|
||||
(r"M\cap N=\text{\O }", r"M\cap N=\emptyset"),
|
||||
(r"\Omega\neq\O", r"\Omega\neq\emptyset"),
|
||||
(r"\begin{array}{@{}l@{\qquad \quad }l@{}} a & b\end{array}", r"\begin{array}{ll} a & b\end{array}"),
|
||||
(r"\arraycolsep8pt\begin{array}{c@{\,}r} x\end{array}", r"\begin{array}{cr} x\end{array}"),
|
||||
(r"a & =&\displaystyle b, \\ & \vdots &\displaystyle \\ c", r"a & =&\displaystyle b, \\ & \vdots &\\ c"),
|
||||
(r"\frac{a}{b}+\{x\}", r"\frac{a}{b}+\{x\}"),
|
||||
])
|
||||
def test_publisher_tex_is_made_portable(tex, expected):
|
||||
"""Pandoc's texmath rejects \\", \\O, @{…} and \\arraycolsep (Springer alt texts, QA3-06)."""
|
||||
assert portable_tex(tex) == expected
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ def test_merge_complete_book(convert, tmp_path):
|
|||
# links between chapters point into the merged file; colliding id "sec" renamed in chapter B
|
||||
assert '<a id="sec"></a>' in text and '<a id="c002-sec"></a>' in text
|
||||
assert "[B](#c002-sec)" in text and "[A](#sec)" in text and "[hier](#sec)" in text
|
||||
assert "[Kapitel B](#002-kapitel-b)" in text and '<a id="002-kapitel-b"></a>' in text
|
||||
assert "[Kapitel B](#kap-002-kapitel-b)" in text and '<a id="kap-002-kapitel-b"></a>' in text
|
||||
# media paths relative to the merged file, also inside image links
|
||||
assert "" in text
|
||||
assert "[](#002-kapitel-b)" in text
|
||||
assert "[](#kap-002-kapitel-b)" in text
|
||||
# code untouched
|
||||
assert '[kein](link.md) id="sec" `code`' in text
|
||||
# footnote defined in both chapters -> only once in the merged file
|
||||
|
|
@ -63,8 +63,8 @@ def test_merge_to_other_location_rewrites_media_paths(convert, tmp_path):
|
|||
text = target.read_text(encoding="utf-8")
|
||||
assert not text.startswith("---")
|
||||
assert f"" in text
|
||||
assert "1. [Kapitel A](#001-kapitel-a)" in text
|
||||
assert "001-kapitel-a" in anchors_in(text)
|
||||
assert "1. [Kapitel A](#kap-001-kapitel-a)" in text
|
||||
assert "kap-001-kapitel-a" in anchors_in(text)
|
||||
|
||||
|
||||
def test_merge_invalid_directory(tmp_path):
|
||||
|
|
|
|||
60
tests/test_pandoc.py
Normal file
60
tests/test_pandoc.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Pandoc path: merged file → EPUB/HTML with the filters from pandoc/ (skipped without pandoc)."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import Book, Doc
|
||||
from epub2md.merge import main as merge_main
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc nicht installiert")
|
||||
FILTERS = Path(__file__).resolve().parent.parent / "pandoc"
|
||||
|
||||
|
||||
def merged(convert, book: Book) -> Path:
|
||||
run = convert(book)
|
||||
assert merge_main([str(run.out)]) == 0
|
||||
return run.out / f"{run.report['input']['name'][:-5]}.md"
|
||||
|
||||
|
||||
def pandoc(md: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["pandoc", md.name, *args], cwd=md.parent, capture_output=True, text=True, check=True)
|
||||
|
||||
|
||||
def links_and_ids(epub: Path) -> tuple[list[tuple[str, str]], dict[str, set[str]]]:
|
||||
with zipfile.ZipFile(epub) as z:
|
||||
docs = {n: z.read(n).decode("utf-8") for n in z.namelist() if n.endswith(".xhtml")}
|
||||
ids = {n: set(re.findall(r'\bid="([^"]+)"', t)) for n, t in docs.items()}
|
||||
links = [(n, h) for n, t in docs.items() for h in re.findall(r'href="([^"]+)"', t)
|
||||
if not re.match(r"^[a-z]+:", h) and not h.endswith(".css")]
|
||||
return links, ids
|
||||
|
||||
|
||||
def test_epub_links_to_chapter_starts_resolve(convert):
|
||||
"""Chapter start anchors must begin with a letter: Pandoc's EPUB writer breaks '#039-…' (QA3-05)."""
|
||||
docs = [Doc("a.xhtml", '<h1>Kapitel A</h1><p>Siehe <a href="b.xhtml">Kapitel B</a> und <a href="c.xhtml">Bild</a>.</p>'),
|
||||
Doc("b.xhtml", "<h1>Kapitel B</h1><p>Text B.</p>"),
|
||||
Doc("c.xhtml", "<p>Nur eine Bildseite ohne Überschrift.</p>")]
|
||||
md = merged(convert, Book(docs=docs))
|
||||
pandoc(md, "--toc", "-o", "buch.epub")
|
||||
links, ids = links_and_ids(md.parent / "buch.epub")
|
||||
broken = []
|
||||
for doc, href in links:
|
||||
file, _, frag = href.partition("#")
|
||||
target = str(Path(doc).parent / file).replace("\\", "/") if file else doc
|
||||
target = re.sub(r"[^/]+/\.\./", "", target)
|
||||
if target not in ids or (frag and frag not in ids[target]):
|
||||
broken.append(f"{doc}: {href}")
|
||||
assert not broken
|
||||
|
||||
|
||||
def test_book_filter_turns_chapter_anchors_into_chapter_starts(convert):
|
||||
docs = [Doc("a.xhtml", "<h1>Kapitel A</h1><p>a</p>"), Doc("b.xhtml", "<h1>Kapitel B</h1><p>b</p>")]
|
||||
md = merged(convert, Book(docs=docs))
|
||||
html = pandoc(md, "-s", "--lua-filter", str(FILTERS / "book.lua"), "-t", "html").stdout
|
||||
assert html.count('class="chapter-start"') == 2
|
||||
assert 'id="TOC"' in html and "Kapitel B" in html
|
||||
Loading…
Reference in a new issue