feat: --chapter-level auto teilt nur sehr große Kapitel (R2)
Neuer Standard auto: Ebene 1 wie bisher, aber Kapitel mit über 30 000 Wörtern und mindestens zwei Unterkapiteln der nächsten Ebene werden an diesen geteilt (Teile eines Romans). Alle anderen Kapitel bleiben ganz; Register, Literatur und Glossar werden nie zerlegt. Ein Probedurchlauf des ChapterBuilder bestimmt die Kapitel, der zweite teilt sie (deep_starts). Info chapter-split; eine feste Zahl verhält sich wie bisher. Roman (Adler-Olsen): 78 statt 9 Dateien, wortgleich, 0 kaputte Links. Klein, Krooß, Lerch, Liesen: Kapiteldateien byte-identisch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b3d753b56
commit
6fe422919f
7 changed files with 116 additions and 28 deletions
|
|
@ -141,9 +141,10 @@ python3 epub2md_merge.py output/meinbuch --force
|
||||||
|
|
||||||
### Roman (Teile und Kapitel)
|
### Roman (Teile und Kapitel)
|
||||||
|
|
||||||
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Standardmäßig entsteht eine Datei
|
Viele Romane gliedern sich in „Teil 1, Teil 2 …“ mit Kapiteln darin. Das Programm erkennt sehr große
|
||||||
pro **Teil**. Das können sehr große Dateien werden. Das Programm warnt dann mit `chapter-large` und
|
Teile (über 30 000 Wörter) selbst und legt dann eine kurze Datei für den Teil und eine Datei pro
|
||||||
empfiehlt die passende Einstellung. Eine Datei pro **Kapitel** bekommst du so:
|
**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
|
```bash
|
||||||
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
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,
|
- Die einzelnen Kapiteldateien eignen sich als Abschnitte. Die Metadaten im Datenkopf (Buch, Kapitel,
|
||||||
Herkunft) helfen beim Zuordnen von Fundstellen.
|
Herkunft) helfen beim Zuordnen von Fundstellen.
|
||||||
- `manifest.json` listet alle Kapitel mit Titel, Datei, Wortanzahl und Ankern.
|
- `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
|
- Sehr große Kapitel teilt das Programm selbst an ihren Unterkapiteln. Hast du `--chapter-level` fest
|
||||||
mit höherem `--chapter-level` neu umwandeln.
|
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 |
|
| `--dry-run` | nur anzeigen, nichts schreiben |
|
||||||
| `--verbose` | ausführliche Ausgabe |
|
| `--verbose` | ausführliche Ausgabe |
|
||||||
| `--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 `auto`: Ebene 1, sehr große Kapitel werden an ihren Unterkapiteln geteilt) |
|
||||||
| `--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 |
|
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
||||||
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
### Geändert
|
### 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
|
- `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.
|
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,
|
- Seitenmarken (`--page-markers`) aus einem Kastentitel stehen jetzt vor dem Kasten statt im fetten Titel,
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -71,7 +71,7 @@ python3 epub2md.py \
|
||||||
--toc \
|
--toc \
|
||||||
--verbose
|
--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
|
python3 epub2md.py roman.epub output/roman --chapter-level 2
|
||||||
|
|
||||||
# Nur analysieren, nichts schreiben
|
# 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 |
|
| `--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) |
|
| `--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 |
|
| `--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 |
|
| `--toc` / `--no-toc` | an | `README.md` mit Inhaltsverzeichnis, Statistik und Reproduktionsbefehl |
|
||||||
| `--frontmatter` / `--no-frontmatter` | an | YAML-Frontmatter in jeder Kapiteldatei |
|
| `--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) |
|
| `--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
|
## Wie Kapitel gebildet werden
|
||||||
|
|
||||||
1. Die **OPF-Spine** bestimmt die Lesereihenfolge (nicht Dateinamen, nicht das Inhaltsverzeichnis).
|
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,
|
chapter, part, appendix, glossary, bibliography, index, preface, …) beginnen neue Kapiteldateien,
|
||||||
auch mitten in einer XHTML-Datei.
|
auch mitten in einer XHTML-Datei.
|
||||||
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
|
||||||
|
|
@ -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.
|
Überschriften. `<pre>` mit `<br/>`-Zeilen und `div.*programlisting* > span.pre` werden zu Codeblöcken.
|
||||||
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
|
Literaturlisten mit eigenen Labels (`[BryL06]`) werden nicht doppelt nummeriert.
|
||||||
|
|
||||||
**Tipp:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), erzeugt
|
**Teile und Kapitel:** Hat ein Buch Teile als `h1` und Kapitel als `h2` (typisch bei Romanen), teilt der
|
||||||
`--chapter-level 2` eine Datei pro Kapitel. Mit dem Standard `1` entsteht eine Datei pro Teil.
|
Standard `auto` jeden Teil mit über 30 000 Wörtern und mindestens zwei Kapiteln in eine kurze Datei für den
|
||||||
Ist eine Kapiteldatei größer als 30 000 Wörter und hat sie mindestens zwei `##`-Unterabschnitte, meldet
|
Teil (Überschrift, Einleitung) und eine Datei pro Kapitel; kleinere Kapitel (Vorwort, Anhang) bleiben ganz.
|
||||||
epub2md eine Warnung `chapter-large` mit der passenden `--chapter-level`-Empfehlung.
|
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
|
## Math-Modi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,11 @@ def semantic_types(el: Any) -> list[str]:
|
||||||
|
|
||||||
class ChapterBuilder:
|
class ChapterBuilder:
|
||||||
def __init__(self, chapter_level: int, nav: list[NavEntry], diag: Diagnostics,
|
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.level = chapter_level
|
||||||
self.diag = diag
|
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._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._in_special = False # current chapter itself is a bibliography/index/glossary
|
||||||
self.book_title = book_title
|
self.book_title = book_title
|
||||||
|
|
@ -129,6 +131,12 @@ class ChapterBuilder:
|
||||||
if lvl and has_content(el):
|
if lvl and has_content(el):
|
||||||
special = _special_heading(el)
|
special = _special_heading(el)
|
||||||
if lvl <= self.level:
|
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:
|
if update:
|
||||||
self._special_level = 0
|
self._special_level = 0
|
||||||
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
self._in_special = classify_title(_heading_text(el)) in SPECIAL_KINDS
|
||||||
|
|
@ -196,7 +204,7 @@ class ChapterBuilder:
|
||||||
body_segments = segments[1:]
|
body_segments = segments[1:]
|
||||||
if _meaningful(head_items):
|
if _meaningful(head_items):
|
||||||
first = _first_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)
|
chapters[-1].items.extend(pending + head_items)
|
||||||
pending = []
|
pending = []
|
||||||
if doc.path not in chapters[-1].docs:
|
if doc.path not in chapters[-1].docs:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ def code_language(value: str) -> str:
|
||||||
return lang
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
p = _Parser(
|
p = _Parser(
|
||||||
prog="epub2md.py",
|
prog="epub2md.py",
|
||||||
|
|
@ -61,9 +69,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
|
help="Bilder/Medien nach OUTPUT/media/ extrahieren (Standard: an)")
|
||||||
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
|
p.add_argument("--copy-original", action=argparse.BooleanOptionalAction, default=False,
|
||||||
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
|
help="Original-EPUB nach OUTPUT/source/ kopieren (Standard: aus)")
|
||||||
p.add_argument("--chapter-level", type=int, default=1, metavar="N",
|
p.add_argument("--chapter-level", type=_chapter_level, default="auto", metavar="{auto,1-6}",
|
||||||
help="Überschriftenebene 1–6, ab der neue Kapiteldateien entstehen (Standard: 1). "
|
help="Überschriftenebene, ab der neue Kapiteldateien entstehen. auto (Standard): Ebene 1, "
|
||||||
"Für Bücher mit Teilen (h1) und Kapiteln (h2) ist 2 sinnvoll")
|
"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,
|
p.add_argument("--toc", action=argparse.BooleanOptionalAction, default=True,
|
||||||
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
|
help="README.md mit Inhaltsverzeichnis erzeugen (Standard: an)")
|
||||||
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
|
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from typing import Any
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from . import TOOL_NAME, __version__
|
from . import TOOL_NAME, __version__
|
||||||
from .chapters import ChapterBuilder
|
from .chapters import SPECIAL_KINDS, ChapterBuilder, heading_level
|
||||||
from .diagnostics import Diagnostics
|
from .diagnostics import Diagnostics
|
||||||
from .css import StyleSheet, base_style, emphasis
|
from .css import StyleSheet, base_style, emphasis
|
||||||
from .epub_reader import EpubReader, decode_markup, parse_xml
|
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 .linkcheck import check_links
|
||||||
from .media import MediaRegistry
|
from .media import MediaRegistry
|
||||||
from .models import Chapter
|
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 .references import LinkResolver
|
||||||
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
|
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
|
||||||
from . import writer
|
from . import writer
|
||||||
|
|
@ -40,7 +41,7 @@ class Options:
|
||||||
format: str = "gfm"
|
format: str = "gfm"
|
||||||
extract_media: bool = True
|
extract_media: bool = True
|
||||||
copy_original: bool = False
|
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
|
toc: bool = True
|
||||||
frontmatter: bool = True
|
frontmatter: bool = True
|
||||||
footnotes: str = "end-of-chapter"
|
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}")
|
raise InputError(f"Eingabedatei nicht gefunden oder keine Datei: {input_path}")
|
||||||
if not os.access(input_path, os.R_OK):
|
if not os.access(input_path, os.R_OK):
|
||||||
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
|
raise InputError(f"Eingabedatei nicht lesbar: {input_path}")
|
||||||
if not 1 <= opts.chapter_level <= 6:
|
if opts.chapter_level != "auto" and not (isinstance(opts.chapter_level, int) and 1 <= opts.chapter_level <= 6):
|
||||||
raise InputError("--chapter-level muss zwischen 1 und 6 liegen.")
|
raise InputError("--chapter-level muss auto oder eine Zahl zwischen 1 und 6 sein.")
|
||||||
if opts.dry_run:
|
if opts.dry_run:
|
||||||
return
|
return
|
||||||
if output_dir.exists() and not output_dir.is_dir():
|
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,
|
def _build_structure(docs: list[Doc], package: Any, opts: Options,
|
||||||
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
|
diag: Diagnostics) -> tuple[Any, list[Chapter], LinkResolver]:
|
||||||
"""Notes, chapter units with file names, and the link resolver (incl. NAV/NCX targets)."""
|
"""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, md.title, md.authors).build(docs)
|
chapters = _build_chapters(docs, package, opts, diag)
|
||||||
_assign_filenames(chapters, opts.slug_language)
|
_assign_filenames(chapters, opts.slug_language)
|
||||||
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
|
resolver = LinkResolver(docs, chapters, notes, "README.md" if opts.toc else None)
|
||||||
nav_targets: list[tuple[str, str]] = []
|
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}")
|
diag.step(f"Rendere {ch.filename}")
|
||||||
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, _base_level(opts), diag)
|
||||||
_report_wrap_markers(renderer, diag)
|
_report_wrap_markers(renderer, diag)
|
||||||
if not opts.code_language:
|
if not opts.code_language:
|
||||||
_suggest_code_language(renderer, diag)
|
_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"
|
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:
|
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."""
|
"""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:
|
if ch.stats.word_count <= LARGE_CHAPTER_WORDS or chapter_level >= 6:
|
||||||
return
|
return
|
||||||
sections = len(re.findall(r"^## ", ch.markdown, re.M))
|
sections = len(re.findall(r"^## ", ch.markdown, re.M))
|
||||||
if sections >= 2:
|
if sections >= 2:
|
||||||
|
deeper = max(chapter_level, ch.title_level or 1) + 1
|
||||||
diag.warn(
|
diag.warn(
|
||||||
"chapter-large",
|
"chapter-large",
|
||||||
f"Kapitel „{ch.title}“ hat {ch.stats.word_count} Wörter und {sections} Unterabschnitte; "
|
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.docs[0] if ch.docs else "",
|
||||||
ch.filename,
|
ch.filename,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
|
||||||
words = " ".join(["Wort"] * 11000)
|
words = " ".join(["Wort"] * 11000)
|
||||||
body = "<h1>Teil 1</h1>" + "".join(f"<h2>Kapitel {i}</h2><p>{words}</p>" for i in range(3))
|
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"]
|
hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"]
|
||||||
assert len(hints) == 1
|
assert len(hints) == 1
|
||||||
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"]
|
||||||
|
|
@ -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"])
|
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):
|
def test_titles_derived_from_content_when_no_other_source(convert):
|
||||||
docs = [
|
docs = [
|
||||||
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
|
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue