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 intoApfel
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", '
Erika Musterfrau
Testbuch
", "Titel"), + Doc("d.xhtml", '
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", '
Erika Musterfrau
Vorspann
Roman
"), + Doc("auflage.xhtml", "4. Auflage 2025
"), + Doc("impressum.xhtml", "© 2024 Testverlag
Das Werk ist urheberrechtlich geschützt.
"), + Doc("brief.xhtml", "Viel Spaß.
"), + Doc("k1.xhtml", "a
"), Doc("k2.xhtml", "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", "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):