feat: Buchanfang Cover → Titel → Impressum → Inhaltsverzeichnis für alle Bücher

- Kapitelerkennung: erstes Kapitel nur aus einem Bild = Cover ("[Titelinfo]",
  "Deckblatt"), Titel "Titel" = Titelseite
- epub2md_merge: Vorspann reicht bis vor den Hauptteil (Teil, Vorwort,
  Einleitung, nummerierter Titel, > 1000 Wörter), kurze Zwischenseiten
  (Auflage, Verlagsbrief, "Über dieses Buch") bleiben davor
- book.lua: Cover am Inhalt erkannt; LaTeX-Weg (Filter in beiden Schritten):
  \tableofcontents hinter dem Vorspann, \clearpage je Kapitel, kein \maketitle
  vor dem Cover, A4, seitengroßes Cover, DejaVu-Schriften und Mathe-Symbole
  für fehlende Glyphen (Mathebuch: >10.000 fehlende Zeichen → 0)
- Buch-README: bei Formeln LaTeX-Weg empfohlen, sonst weasyprint
- Alle sechs Beispielbücher geprüft: Seite 1 Cover, Verzeichnis nach dem Vorspann

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 06:48:18 +02:00
commit 86aa93c12c
10 changed files with 257 additions and 46 deletions

View file

@ -323,7 +323,7 @@ P=~/Python_Programs/epub2md/pandoc # Pfad zu deinem epub2md-Ordner anpassen
| E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` | | E-Book (EPUB) | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.epub` |
| Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` | | Word | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.docx` |
| LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` | | LibreOffice | `pandoc meinbuch.md --toc --lua-filter $P/callouts.lua -o meinbuch.odt` |
| PDF mit LaTeX | `pandoc meinbuch.md -s --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` | | PDF mit LaTeX (Bücher mit Formeln) | `pandoc meinbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o meinbuch.html && pandoc meinbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex -o meinbuch.pdf` |
| Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` | | Kindle (AZW3) | erst EPUB wie oben, dann `ebook-convert meinbuch.epub meinbuch.azw3` |
Die drei Hilfsdateien: Die drei Hilfsdateien:
@ -331,9 +331,11 @@ Die drei Hilfsdateien:
- **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das - **`book.lua`** ordnet den Buchanfang wie im gedruckten Buch: Cover, Titelseite, Impressum, dann das
Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das Inhaltsverzeichnis, jeweils auf einer neuen Seite. Ohne den Filter setzt Pandoc mit `--toc` das
Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc` Inhaltsverzeichnis (und seinen eigenen Titelkopf) vor das Cover. Mit `book.lua` deshalb **kein** `--toc`
angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Er angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das
wirkt nur bei HTML und beim PDF über weasyprint; EPUB, Word und LibreOffice bauen ihr Verzeichnis mit gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word
`--toc` selbst. und LibreOffice bauen ihr Verzeichnis mit `--toc` selbst. Kurze Seiten zwischen Titelseite und Impressum
(Auflagenvermerk, Verlagsbrief, „Über dieses Buch“) bleiben im Vorspann; das Inhaltsverzeichnis folgt vor
dem ersten Teil, Vorwort oder nummerierten Kapitel.
- **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese - **`callouts.lua`** zeichnet die Infokästen (`> [!TIP]`). Pandoc in der Ubuntu-Version kennt diese
Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden Schreibweise nicht und druckt „[!TIP]“ sonst einfach mit. In HTML, EPUB und im PDF über weasyprint werden
daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein daraus gerahmte, farbige Kästen mit Titel („Tipp: …“), im PDF über LaTeX und in Word/LibreOffice ein
@ -346,12 +348,13 @@ Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält s
**Zum PDF:** **Zum PDF:**
- **Mit weasyprint** (empfohlen) genügt ein Schritt. Tabellen, Farben, Infokästen und die Gestaltung aus - **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben,
`--styles html` bleiben erhalten, der Buchanfang ist richtig geordnet. Für Bücher mit vielen Formeln ist Infokästen und die Gestaltung aus `--styles html` bleiben erhalten.
der LaTeX-Weg sicherer.
- **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von - **Mit LaTeX** immer den Zwischenschritt über HTML nehmen, wie in der Tabelle. Beim direkten Weg von
Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Das Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für
Inhaltsverzeichnis steht hier am Anfang, vor dem Cover. Bücher mit Formeln ist das der richtige Weg, weil weasyprint keine Formeln setzen kann. Farben und
Kastenrahmen gehen dabei verloren; Kästen erscheinen als eingerückter Block mit fettem Titel. `book.lua`
stellt dabei A4 ein und wählt die Schrift DejaVu, die auch Zeichen wie ∈, λ oder ≠ im Fließtext enthält.
Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen. Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen.

View file

@ -11,7 +11,12 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des davor (Pandocs `--toc` setzt es immer an den Anfang). `epub2md_merge.py` markiert dafür das Ende des
Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover, Vorspanns; ohne eigene Titelseite kommt Pandocs Titelkopf hinter das Cover. Jedes Kapitel, auch Cover,
Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat Titelseite und Impressum, beginnt mit `print.css` auf einer neuen Seite; das Inhaltsverzeichnis hat
Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. Seitenzahlen. Mit `epub2md_merge.py --toc` steht dessen Verzeichnis an derselben Stelle. Gilt auch für den
PDF-Weg über LaTeX (`\tableofcontents` hinter dem Vorspann); bei Büchern mit Formeln empfiehlt die
Buch-README diesen Weg. Dort setzt der Filter außerdem A4, ein seitengroßes Cover und DejaVu-Schriften,
sodass keine Zeichen mehr fehlen (Mathebuch: vorher über 10.000 fehlende Glyphen wie ∈, λ, ≠).
- Kapitelerkennung: Ein erstes Kapitel nur aus einem Bild ist das Cover (auch „[Titelinfo]“, „Deckblatt“),
ein Kapitel „Titel“ die Titelseite. Der Vorspann darf kurze Zwischenseiten enthalten.
- `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, - `pandoc/print.css`: Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite,
Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl.

View file

@ -337,10 +337,10 @@ pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.l
pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html pandoc programmierbuch.md -s --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o programmierbuch.html
pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub pandoc programmierbuch.md --toc --lua-filter $P/callouts.lua -o programmierbuch.epub
cd output/mathematikbuch cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML)
pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html
# PDF über den Zwischenschritt HTML (siehe unten) pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \
pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf -o mathematikbuch.pdf
``` ```
Hilfsdateien in `pandoc/`: Hilfsdateien in `pandoc/`:
@ -351,8 +351,16 @@ Hilfsdateien in `pandoc/`:
keine eigene Titelseite hat, die Stelle hinter dem Cover (`<div id="epub2md-titlepage"></div>`). Der keine eigene Titelseite hat, die Stelle hinter dem Cover (`<div id="epub2md-titlepage"></div>`). Der
Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener Filter baut dort das Inhaltsverzeichnis (`--toc-depth` gilt), lässt Pandocs Titelkopf bei vorhandener
Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern Titelseite weg, zeigt das Cover ohne Bildunterschrift und macht aus den Kapitelankern
`div.chapter-start` für Seitenumbrüche. Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus
`--toc` angibt, bekommt Pandocs Verzeichnis wie gewohnt. `\tableofcontents` an derselben Stelle, `\clearpage` vor jedem Kapitel und kein `\maketitle` vor dem Cover.
Außerdem A4 mit 2 cm Rand, das Cover in Seitengröße und – falls installiert und nicht selbst gewählt –
DejaVu-Schriften (`mainfont`, `monofont`, `mathfont`): Latin Modern fehlen Zeichen wie ∈ λ ≠ ≥ □ und
schmale Leerzeichen (im Mathebuch über 10.000 Stellen). ℓ, 〈 〉, ∖, ⋯ und „↩︎“ werden als Mathe-Symbole gesetzt.
Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst `--toc` angibt, bekommt Pandocs
Verzeichnis wie gewohnt.
- Der Vorspann reicht bis vor das erste Kapitel des Hauptteils (Teil, Vorwort, Einleitung, Prolog,
nummerierter Titel oder mehr als 1000 Wörter), sofern er Cover, Titelseite oder Impressum enthält. Ein
erstes Kapitel nur aus einem Bild gilt als Cover, ein Kapitel „Titel“ als Titelseite.
- **`print.css`** (weasyprint): A4, Seitenzahlen, jedes Kapitel (auch Cover, Titelseite, Impressum) und das - **`print.css`** (weasyprint): A4, Seitenzahlen, jedes Kapitel (auch Cover, Titelseite, Impressum) und das
Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und Seitenzahl, Silbentrennung Inhaltsverzeichnis auf neuer Seite, Inhaltsverzeichnis mit Punktlinie und Seitenzahl, Silbentrennung
(nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb (nicht in Code), umbrechende Codezeilen. Mit `--css` lässt Pandoc sein eigenes Stylesheet weg, deshalb

View file

@ -45,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("appendix", re.compile(r"^(anhang|appendix)", re.I)),
("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)),
("dedication", re.compile(r"^(widmung|dedication)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)),
("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", re.I)),
("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), ("titlepage", re.compile(r"^(titelei|titelseite|title ?page|titel\s*$|title\s*$)", re.I)),
("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)), ("part", re.compile(r"^(teil|part)\s+([IVXLC]+|\d+|[a-zäöü]+)\b", re.I)),
("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)),
("prologue", re.compile(r"^(prolog|prologue)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)),
@ -307,12 +307,21 @@ class ChapterBuilder:
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:
ch.types.append(kind) ch.types.append(kind)
if ch.number == 1 and "cover" not in ch.types and _image_only(ch.items):
ch.types.append("cover") # first chapter with nothing but a picture, whatever its label ('[Titelinfo]')
if "index" in ch.types and classify_title(ch.title) == "index-persons": if "index" in ch.types and classify_title(ch.title) == "index-persons":
ch.types.remove("index") ch.types.remove("index")
if not ch.types: if not ch.types:
ch.types.append("chapter") ch.types.append("chapter")
def _image_only(items: list[tuple[str, Any]]) -> bool:
"""Content is one or more pictures without any text."""
text = "".join(it if isinstance(it, str) else text_content(it) for _, it in items if isinstance(it, str) or is_element(it))
return not text.strip() and any(is_element(it) and any(local(e.tag) in ("img", "svg", "image") for e in iter_elements(it))
for _, it in items)
@dataclass @dataclass
class _TypeCarrier: class _TypeCarrier:
types: list[str] types: list[str]

View file

@ -47,6 +47,7 @@ class ChapterFile:
start_anchor: str = "" start_anchor: str = ""
anchor_map: dict[str, str] = field(default_factory=dict) anchor_map: dict[str, str] = field(default_factory=dict)
types: list[str] = field(default_factory=list) types: list[str] = field(default_factory=list)
words: int = 0
@dataclass @dataclass
@ -75,12 +76,13 @@ def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {} report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
if manifest_path.is_file(): if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
entries = [(c["number"], c["title"], c["file"], c.get("types") or []) for c in manifest.get("chapters", [])] entries = [(c["number"], c["title"], c["file"], c.get("types") or [], c.get("word_count", 0))
for c in manifest.get("chapters", [])]
else: else:
files = sorted((out_dir / "chapters").glob("*.md")) files = sorted((out_dir / "chapters").glob("*.md"))
entries = [(i + 1, p.stem, f"chapters/{p.name}", []) for i, p in enumerate(files)] entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)]
chapters = [ChapterFile(n, t, rel, types=list(ty)) for n, t, rel, ty in entries if (out_dir / rel).is_file()] chapters = [ChapterFile(n, t, rel, types=list(ty), words=w) for n, t, rel, ty, w in entries if (out_dir / rel).is_file()]
missing = [rel for _, _, rel, _ in entries if not (out_dir / rel).is_file()] missing = [rel for _, _, rel, _, _ in entries if not (out_dir / rel).is_file()]
if missing: if missing:
raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}")
if not chapters: if not chapters:
@ -97,9 +99,25 @@ TOC_PLACEHOLDER = '<div id="epub2md-toc"></div>' # pandoc/book.lua puts the tab
TITLE_PLACEHOLDER = '<div id="epub2md-titlepage"></div>' # … and Pandoc's title block here (no own title page) TITLE_PLACEHOLDER = '<div id="epub2md-titlepage"></div>' # … and Pandoc's title block here (no own title page)
# Where the main matter starts: a part, preface, introduction …, a numbered title or a longer text.
MAIN_TYPES = {"part", "preface", "foreword", "introduction", "prologue", "preamble", "appendix", "bodymatter", "toc"}
NUMBERED_TITLE = re.compile(r"^\s*(\d+|[IVXLC]+\.\s|teil\b|kapitel\b|part\b|chapter\b|buch\b|book\b)", re.I)
FRONT_MAX_WORDS = 1000
FRONT_MAX_CHAPTERS = 12
def front_matter_count(chapters: list[ChapterFile]) -> int: def front_matter_count(chapters: list[ChapterFile]) -> int:
"""Number of leading chapters that are front matter (cover, title page, imprint …).""" """Number of leading chapters before the table of contents: cover, title page, imprint and the short
n = 0 pages between them (series page, edition note, publisher's letter …) up to the start of the main matter."""
k = 0
while k < min(len(chapters), FRONT_MAX_CHAPTERS):
ch = chapters[k]
if MAIN_TYPES & set(ch.types) or NUMBERED_TITLE.match(ch.title) or ch.words > FRONT_MAX_WORDS:
break
k += 1
if k < len(chapters) and any(FRONT_TYPES & set(c.types) for c in chapters[:k]):
return k
n = 0 # no clear start of the main matter: only the unbroken run of typed front matter
while n < len(chapters) and FRONT_TYPES & set(chapters[n].types): while n < len(chapters) and FRONT_TYPES & set(chapters[n].types):
n += 1 n += 1
return n return n
@ -257,7 +275,7 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True
if fm: if fm:
parts.append("---\n" + to_yaml(fm) + "---") parts.append("---\n" + to_yaml(fm) + "---")
front = front_matter_count(chapters) front = front_matter_count(chapters)
has_title_page = any("titlepage" in ch.types for ch in chapters[:front]) has_title_page = any({"titlepage", "halftitlepage"} & set(ch.types) for ch in chapters[:front])
cover_first = bool(chapters) and "cover" in chapters[0].types cover_first = bool(chapters) and "cover" in chapters[0].types
if toc: if toc:
lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""]

View file

@ -139,23 +139,26 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes
lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}" lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}"
# book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top # book.lua: table of contents after cover/title page/imprint instead of Pandoc's --toc at the very top
toc = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else " --toc" book = f" --lua-filter {shlex.quote(str(BOOK_FILTER))}" if BOOK_FILTER.is_file() else ""
toc = book or " --toc"
md_, html_, pdf_ = (shlex.quote(stem + ext) for ext in (".md", ".html", ".pdf"))
latex = [
"# PDF mit LaTeX über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {md_} -s{math}{book}{lua} -o {html_} && pandoc {html_}{toc}{lua} --pdf-engine=xelatex -o {pdf_}",
]
weasy = [
"# PDF mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten" + ("" if math else " (empfohlen)"),
f"pandoc {md_} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} --pdf-engine=weasyprint -o {pdf_}",
] if PRINT_CSS.is_file() else []
if math:
latex[0] += " (empfohlen: weasyprint kann keine Formeln setzen)"
commands = [ commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)", "# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}", f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:", "# danach in diesem Ordner:",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} -o {shlex.quote(stem + '.html')}", f"pandoc {md_} -s{math}{toc}{lua} -o {html_}",
f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}", f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}",
"# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele", ] + (latex + weasy if math else weasy + latex)
f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
]
if PRINT_CSS.is_file():
commands += [
"# PDF ohne LaTeX mit weasyprint: Farben, Kästen und HTML-Stile bleiben erhalten",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} --css {shlex.quote(str(PRINT_CSS))} "
f"--pdf-engine=weasyprint -o {shlex.quote(stem + '.pdf')}",
]
out.append("## Weiterverarbeitung") out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```") out.append("```bash\n" + "\n".join(commands) + "\n```")
if callouts: if callouts:

View file

@ -10,11 +10,13 @@ i.e. before the book's cover, title page and imprint. epub2md_merge.py marks the
This filter, for HTML output: This filter, for HTML output:
* builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured), * 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, * drops Pandoc's title block when the book has its own title page, or moves it to the placeholder,
* shows the cover picture (chapter "NNN-cover") without the caption "Cover", * 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="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. start every chapter – including cover, title page and imprint – on a new page.
For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there). For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes
\tableofcontents at the same place, every chapter starts with \clearpage and there is no \maketitle
before the cover. For other formats it only removes the placeholders (EPUB, DOCX, ODT: use --toc there).
Usage: Usage:
pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \ pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \
@ -73,7 +75,123 @@ local function chapter_anchor(block)
return id return id
end end
-- Characters in running text that even DejaVu lacks: typeset as math symbols ("↩︎": footnote back-links).
local LATEX_SYMBOLS = { ["↩"] = "$\\hookleftarrow$", ["ℓ"] = "$\\ell$", ["\u{2329}"] = "$\\langle$",
["\u{232A}"] = "$\\rangle$", ["\u{3008}"] = "$\\langle$", ["\u{3009}"] = "$\\rangle$",
["∖"] = "$\\setminus$", ["⋯"] = "$\\cdots$", ["\u{FE0E}"] = "" }
local LATEX_FONTS = { mainfont = "DejaVu Serif", monofont = "DejaVu Sans Mono", mathfont = "DejaVu Math TeX Gyre" }
local function font_installed(name)
local pipe = io.popen("fc-list -q " .. string.format("%q", name) .. " && echo yes")
local out = pipe and pipe:read("a") or ""
if pipe then
pipe:close()
end
return out:match("yes") ~= nil
end
local function latex_symbols(str)
local found = false
for _, c in utf8.codes(str.text) do
if LATEX_SYMBOLS[utf8.char(c)] then
found = true
break
end
end
if not found then
return nil
end
local out, buf = pandoc.Inlines({}), {}
for _, c in utf8.codes(str.text) do
local ch = utf8.char(c)
local tex = LATEX_SYMBOLS[ch]
if tex then
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
buf = {}
end
if tex ~= "" then
out:insert(pandoc.RawInline("latex", tex))
end
else
buf[#buf + 1] = ch
end
end
if #buf > 0 then
out:insert(pandoc.Str(table.concat(buf)))
end
return out
end
-- LaTeX (2nd step of the PDF route md -> html -> pdf): the HTML from step 1 already holds the table of
-- contents (div#TOC.toc), the chapter starts (div.chapter-start) and, if moved, Pandoc's title block.
local function latex_book(doc)
local found = false
doc.blocks:walk({ Div = function(d)
if d.identifier == "TOC" and d.classes:includes("toc") or d.classes:includes("chapter-start") then
found = true
end
end })
if not found then
return nil
end
local blocks = pandoc.Blocks({})
for _, block in ipairs(doc.blocks) do
if block.t == "Div" and block.identifier == "TOC" and block.classes:includes("toc") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\tableofcontents\n\\clearpage"))
elseif block.t == "Div" and block.classes:includes("chapter-start") then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage"))
blocks:insert(block)
elseif block.t == "Div" and block.classes:includes("cover") then
blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows
img.attributes.width = nil
img.attributes.height = "20cm"
return img
end }))
elseif block.t == "Div" and block.identifier == "title-block-header" then
blocks:insert(pandoc.RawBlock("latex", "\\clearpage\n\\begin{center}\n\\vspace*{3cm}"))
for _, b in ipairs(block.content) do
if b.t == "Header" then
local title = pandoc.Inlines({ pandoc.RawInline("latex", "{\\Huge\\bfseries ") })
title:extend(b.content)
title:insert(pandoc.RawInline("latex", "}"))
blocks:insert(pandoc.Para(title))
else
blocks:insert(b)
end
end
blocks:insert(pandoc.RawBlock("latex", "\\end{center}"))
else
blocks:insert(block)
end
end
doc.blocks = blocks:walk({ Str = latex_symbols })
-- fonts with a wide Unicode coverage (∈ λ ≠ ≥ □, thin spaces …), unless the user chose fonts himself
-- A4 with moderate margins, like print.css (Pandoc's LaTeX default: US letter, wide margins)
doc.meta.papersize = doc.meta.papersize or "a4"
doc.meta.geometry = doc.meta.geometry or "margin=2cm"
for var, font in pairs(LATEX_FONTS) do
if doc.meta[var] == nil and font_installed(font) then
doc.meta[var] = font
end
end
-- no \maketitle at the very top (before the cover); keep the PDF metadata
if doc.meta.title then
doc.meta["title-meta"] = pandoc.utils.stringify(doc.meta.title)
doc.meta.title = nil
end
if doc.meta.author then
doc.meta["author-meta"] = pandoc.utils.stringify(doc.meta.author)
doc.meta.author = nil
end
doc.meta.date = nil
return doc
end
function Pandoc(doc) function Pandoc(doc)
if FORMAT:match("latex") then
return latex_book(doc)
end
local html = FORMAT:match("html") and not FORMAT:match("epub") local html = FORMAT:match("html") and not FORMAT:match("epub")
local toc_at, title_at = nil, nil local toc_at, title_at = nil, nil
for i, block in ipairs(doc.blocks) do for i, block in ipairs(doc.blocks) do
@ -98,6 +216,7 @@ function Pandoc(doc)
end end
local blocks = pandoc.Blocks({}) local blocks = pandoc.Blocks({})
local seen_chapter = false
for i, block in ipairs(doc.blocks) do for i, block in ipairs(doc.blocks) do
local anchor = chapter_anchor(block) local anchor = chapter_anchor(block)
if i == toc_at then if i == toc_at then
@ -117,15 +236,26 @@ function Pandoc(doc)
end end
elseif anchor then elseif anchor then
blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" }))) blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" })))
local nxt = doc.blocks[i + 1] -- cover: the first chapter, if it holds nothing but one picture ('Cover', '[Titelinfo]', 'Deckblatt' …);
if anchor:match("^%d%d%d%-cover") and nxt and nxt.t == "Figure" then -- shown without the caption "Cover", centred on its own page
-- cover chapter: the picture without the caption "Cover", centred on its own page local nxt, after = doc.blocks[i + 1], doc.blocks[i + 2]
local imgs = {} local ends = after == nil or chapter_anchor(after) ~= nil or is_placeholder(after, TOC_ID)
or is_placeholder(after, TITLE_ID)
if not seen_chapter and nxt and (nxt.t == "Figure" or nxt.t == "Para") and ends then
local imgs, text = {}, false
nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end }) nxt:walk({ Image = function(img) imgs[#imgs + 1] = img end })
if #imgs == 1 then if nxt.t == "Para" then
for _, inl in ipairs(nxt.content) do
if inl.t ~= "Image" and inl.t ~= "Space" and inl.t ~= "SoftBreak" then
text = true
end
end
end
if #imgs == 1 and not text then
doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" })) doc.blocks[i + 1] = pandoc.Div({ pandoc.Plain({ imgs[1] }) }, pandoc.Attr("", { "cover" }))
end end
end end
seen_chapter = true
else else
blocks:insert(block) blocks:insert(block)
end end

View file

@ -185,3 +185,18 @@ def test_special_chapter_is_not_split_further(convert):
"<h1>Register</h1><h2>A</h2><p>Apfel</p><h2>Index</h2><p>x</p>") "<h1>Register</h1><h2>A</h2><p>Apfel</p><h2>Index</h2><p>x</p>")
run = convert(Book(docs=[Doc("a.xhtml", body)])) run = convert(Book(docs=[Doc("a.xhtml", body)]))
assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"] assert [p.name for p in run.chapters()] == ["001-literatur.md", "002-register.md"]
def test_first_image_only_chapter_is_cover_and_titel_is_title_page(convert):
from conftest import PNG
docs = [Doc("info.xhtml", '<div><img src="c.png" alt=""/></div>', "[Titelinfo]"),
Doc("t.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p>", "Titel"),
Doc("d.xhtml", '<div><img src="c.png" alt="Deckblatt"/></div>', "Deckblatt"),
Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>")]
run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG}))
types = {c["file"]: c["types"] for c in run.report["chapters"]}
first, second, third = sorted(types)[:3]
assert "cover" in types[first]
assert "titlepage" in types[second]
assert "cover" in types[third] # 'Deckblatt' by title
assert "cover" not in types[sorted(types)[3]]

View file

@ -118,3 +118,21 @@ def test_merge_own_toc_replaces_placeholder(convert):
assert merge_main([str(run.out), "--toc"]) == 0 assert merge_main([str(run.out), "--toc"]) == 0
text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8") text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8")
assert "epub2md-toc" not in text and text.index("© 2024") < text.index("## Inhalt") < text.index("# Kapitel A") assert "epub2md-toc" not in text and text.index("© 2024") < text.index("## Inhalt") < text.index("# Kapitel A")
def test_merge_front_matter_region_with_gaps(convert):
"""Krooss/Blake-like: short untyped pages between title page and imprint; main matter starts at '1 …'."""
docs = [Doc("cover.xhtml", '<div><img src="img/a.png" alt="Cover"/></div>'),
Doc("titel.xhtml", "<p>Erika Musterfrau</p><p>Vorspann</p><p>Roman</p>"),
Doc("auflage.xhtml", "<h1>Vorspann</h1><p>4. Auflage 2025</p>"),
Doc("impressum.xhtml", "<p>© 2024 Testverlag</p><p>Das Werk ist urheberrechtlich geschützt.</p>"),
Doc("brief.xhtml", "<h1>Liebe Leserin, lieber Leser,</h1><p>Viel Spaß.</p>"),
Doc("k1.xhtml", "<h1>1 Der Einstieg</h1><p>a</p>"), Doc("k2.xhtml", "<h1>Zwischenspiel</h1><p>b</p>")]
book = Book(title="Vorspann", docs=docs, nav=[("1 Der Einstieg", "k1.xhtml"), ("Zwischenspiel", "k2.xhtml")],
media={"img/a.png": PNG})
run = convert(book)
assert merge_main([str(run.out)]) == 0
text = (run.out / f"{run.report['input']['name'][:-5]}.md").read_text(encoding="utf-8")
toc = text.index('<div id="epub2md-toc"></div>')
assert text.index("© 2024") < text.index("Viel Spaß.") < toc < text.index("# 1 Der Einstieg")
assert text.count("epub2md-toc") == 1 and "epub2md-titlepage" not in text

View file

@ -32,7 +32,9 @@ def test_qa2_10_book_readme_recommends_pdf_via_html(convert):
"""Pandoc drops raw HTML (tables, <a id>) when going Markdown -> LaTeX; the HTML route keeps both.""" """Pandoc drops raw HTML (tables, <a id>) when going Markdown -> LaTeX; the HTML route keeps both."""
run = convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")])) run = convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")]))
readme = (run.out / "README.md").read_text(encoding="utf-8") readme = (run.out / "README.md").read_text(encoding="utf-8")
assert re.search(r"pandoc \S+\.md -s -o \S+\.html && pandoc \S+\.html --toc --pdf-engine=xelatex -o \S+\.pdf", readme) book = r"--lua-filter \S+/pandoc/book\.lua" # table of contents after the front matter (both steps)
assert re.search(rf"pandoc \S+\.md -s {book} -o \S+\.html && pandoc \S+\.html {book} --pdf-engine=xelatex -o \S+\.pdf",
readme)
def test_qa2_11_code_language_hint(convert): def test_qa2_11_code_language_hint(convert):