From 86aa93c12cb61d6809fa851a784e07ce044234d6 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 06:48:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Buchanfang=20Cover=20=E2=86=92=20Titel?= =?UTF-8?q?=20=E2=86=92=20Impressum=20=E2=86=92=20Inhaltsverzeichnis=20f?= =?UTF-8?q?=C3=BCr=20alle=20B=C3=BCcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BEDIENUNGSANLEITUNG.md | 21 +++--- CHANGELOG.md | 7 +- README.md | 20 ++++-- epub2md/chapters.py | 13 +++- epub2md/merge.py | 32 +++++++-- epub2md/writer.py | 29 +++++---- pandoc/book.lua | 144 +++++++++++++++++++++++++++++++++++++++-- tests/test_chapters.py | 15 +++++ tests/test_merge.py | 18 ++++++ tests/test_qa2_open.py | 4 +- 10 files changed, 257 insertions(+), 46 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 72e83bf..0ef72d1 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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` | | 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` | -| 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` | Die drei Hilfsdateien: @@ -331,9 +331,11 @@ Die drei Hilfsdateien: - **`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 (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 - wirkt nur bei HTML und beim PDF über weasyprint; EPUB, Word und LibreOffice bauen ihr Verzeichnis mit - `--toc` selbst. + angeben. Hat das Buch keine eigene Titelseite, setzt der Filter Pandocs Titelkopf hinter das Cover. Das + gilt für HTML und für beide PDF-Wege (beim LaTeX-Weg den Filter in beiden Schritten angeben); EPUB, Word + 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 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 @@ -346,12 +348,13 @@ Du musst die Befehle nicht abtippen: Die `README.md` im Ausgabeordner enthält s **Zum PDF:** -- **Mit weasyprint** (empfohlen) genügt ein Schritt. Tabellen, Farben, Infokästen und die Gestaltung aus - `--styles html` bleiben erhalten, der Buchanfang ist richtig geordnet. Für Bücher mit vielen Formeln ist - der LaTeX-Weg sicherer. +- **Mit weasyprint** (empfohlen für Bücher ohne Formeln) genügt ein Schritt. Tabellen, Farben, + Infokästen und die Gestaltung aus `--styles html` bleiben erhalten. - **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 - Inhaltsverzeichnis steht hier am Anfang, vor dem Cover. + Markdown nach LaTeX lässt Pandoc komplexe Tabellen weg, und Querverweise im PDF funktionieren nicht. Für + 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e40f1..63f0e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 - 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, Silbentrennung außer in Code, umbrechende Codezeilen). Die Buch-README enthält den passenden Befehl. diff --git a/README.md b/README.md index c91bfab..14af3d8 100644 --- a/README.md +++ b/README.md @@ -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 --toc --lua-filter $P/callouts.lua -o programmierbuch.epub -cd output/mathematikbuch -pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html -# PDF über den Zwischenschritt HTML (siehe unten) -pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf +cd output/mathematikbuch # Formeln: PDF über HTML und LaTeX (weasyprint setzt kein MathML) +pandoc mathematikbuch.md -s --mathml --lua-filter $P/book.lua --lua-filter $P/callouts.lua -o mathematikbuch.html +pandoc mathematikbuch.html --lua-filter $P/book.lua --lua-filter $P/callouts.lua --pdf-engine=xelatex \ + -o mathematikbuch.pdf ``` Hilfsdateien in `pandoc/`: @@ -351,8 +351,16 @@ Hilfsdateien in `pandoc/`: keine eigene Titelseite hat, die Stelle hinter dem Cover (`
`). Der 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 - `div.chapter-start` für Seitenumbrüche. Bei anderen Formaten entfernt er nur die Platzhalter. Wer selbst - `--toc` angibt, bekommt Pandocs Verzeichnis wie gewohnt. + `div.chapter-start` für Seitenumbrüche. Beim LaTeX-Weg (Filter in beiden Schritten) wird daraus + `\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 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 diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 0b16de3..2384eb7 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -45,8 +45,8 @@ KIND_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("appendix", re.compile(r"^(anhang|appendix)", re.I)), ("colophon", re.compile(r"^(impressum|colophon|kolophon)", re.I)), ("dedication", re.compile(r"^(widmung|dedication)", re.I)), - ("cover", re.compile(r"^(cover|umschlag|titelbild)\b", re.I)), - ("titlepage", re.compile(r"^(titelei|titelseite|title ?page)", re.I)), + ("cover", re.compile(r"^(cover|umschlag|titelbild|deckblatt)\b", 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)), ("epilogue", re.compile(r"^(epilog|nachwort|epilogue|afterword)", re.I)), ("prologue", re.compile(r"^(prolog|prologue)", re.I)), @@ -307,12 +307,21 @@ class ChapterBuilder: kind = classify_title(ch.title) if kind and kind not in ch.types: 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": ch.types.remove("index") if not ch.types: 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 class _TypeCarrier: types: list[str] diff --git a/epub2md/merge.py b/epub2md/merge.py index 0eb2532..5a052f9 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -47,6 +47,7 @@ class ChapterFile: start_anchor: str = "" anchor_map: dict[str, str] = field(default_factory=dict) types: list[str] = field(default_factory=list) + words: int = 0 @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 {} if manifest_path.is_file(): 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: files = sorted((out_dir / "chapters").glob("*.md")) - entries = [(i + 1, p.stem, f"chapters/{p.name}", []) 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()] - missing = [rel for _, _, rel, _ in entries if not (out_dir / rel).is_file()] + entries = [(i + 1, p.stem, f"chapters/{p.name}", [], 0) for i, p in enumerate(files)] + 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()] if missing: raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}") if not chapters: @@ -97,9 +99,25 @@ TOC_PLACEHOLDER = '
' # pandoc/book.lua puts the tab TITLE_PLACEHOLDER = '
' # … 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: - """Number of leading chapters that are front matter (cover, title page, imprint …).""" - n = 0 + """Number of leading chapters before the table of contents: cover, title page, imprint and the short + 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): n += 1 return n @@ -257,7 +275,7 @@ def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True if fm: parts.append("---\n" + to_yaml(fm) + "---") 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 if toc: lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""] diff --git a/epub2md/writer.py b/epub2md/writer.py index f00c8de..cf4333b 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -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 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 - 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 = [ "# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)", f"python3 epub2md_merge.py {shlex.quote(output_dir)}", "# danach in diesem Ordner:", - f"pandoc {shlex.quote(stem + '.md')} -s{math}{toc}{lua} -o {shlex.quote(stem + '.html')}", - f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}", - "# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele", - 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')}", - ] + f"pandoc {md_} -s{math}{toc}{lua} -o {html_}", + f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}", + ] + (latex + weasy if math else weasy + latex) out.append("## Weiterverarbeitung") out.append("```bash\n" + "\n".join(commands) + "\n```") if callouts: diff --git a/pandoc/book.lua b/pandoc/book.lua index ef78a17..67b76b3 100644 --- a/pandoc/book.lua +++ b/pandoc/book.lua @@ -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: * 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 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 into
so that print.css can 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: pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/book.lua \ @@ -73,7 +75,123 @@ local function chapter_anchor(block) return id 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) + if FORMAT:match("latex") then + return latex_book(doc) + end local html = FORMAT:match("html") and not FORMAT:match("epub") local toc_at, title_at = nil, nil for i, block in ipairs(doc.blocks) do @@ -98,6 +216,7 @@ function Pandoc(doc) end local blocks = pandoc.Blocks({}) + local seen_chapter = false for i, block in ipairs(doc.blocks) do local anchor = chapter_anchor(block) if i == toc_at then @@ -117,15 +236,26 @@ function Pandoc(doc) end elseif anchor then blocks:insert(pandoc.Div({}, pandoc.Attr(anchor, { "chapter-start" }))) - local nxt = doc.blocks[i + 1] - if anchor:match("^%d%d%d%-cover") and nxt and nxt.t == "Figure" then - -- cover chapter: the picture without the caption "Cover", centred on its own page - local imgs = {} + -- cover: the first chapter, if it holds nothing but one picture ('Cover', '[Titelinfo]', 'Deckblatt' …); + -- shown without the caption "Cover", centred on its own page + local nxt, after = doc.blocks[i + 1], doc.blocks[i + 2] + 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 }) - 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" })) end end + seen_chapter = true else blocks:insert(block) end diff --git a/tests/test_chapters.py b/tests/test_chapters.py index c98dd87..75d71fd 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -185,3 +185,18 @@ def test_special_chapter_is_not_split_further(convert): "

Register

A

Apfel

Index

x

") run = convert(Book(docs=[Doc("a.xhtml", body)])) 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", '
', "[Titelinfo]"), + Doc("t.xhtml", "

Erika Musterfrau

Testbuch

", "Titel"), + Doc("d.xhtml", '
Deckblatt
', "Deckblatt"), + Doc("c1.xhtml", "

Eins

Text

")] + 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]] diff --git a/tests/test_merge.py b/tests/test_merge.py index 2aa702a..1a2d122 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -118,3 +118,21 @@ def test_merge_own_toc_replaces_placeholder(convert): assert merge_main([str(run.out), "--toc"]) == 0 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") + + +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", '
Cover
'), + Doc("titel.xhtml", "

Erika Musterfrau

Vorspann

Roman

"), + Doc("auflage.xhtml", "

Vorspann

4. Auflage 2025

"), + Doc("impressum.xhtml", "

© 2024 Testverlag

Das Werk ist urheberrechtlich geschützt.

"), + Doc("brief.xhtml", "

Liebe Leserin, lieber Leser,

Viel Spaß.

"), + Doc("k1.xhtml", "

1 Der Einstieg

a

"), Doc("k2.xhtml", "

Zwischenspiel

b

")] + 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('
') + 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 diff --git a/tests/test_qa2_open.py b/tests/test_qa2_open.py index 35f3e7b..e9b1e41 100644 --- a/tests/test_qa2_open.py +++ b/tests/test_qa2_open.py @@ -32,7 +32,9 @@ def test_qa2_10_book_readme_recommends_pdf_via_html(convert): """Pandoc drops raw HTML (tables, ) when going Markdown -> LaTeX; the HTML route keeps both.""" run = convert(Book(docs=[Doc("a.xhtml", "

A

x

")])) 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):