diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e246b..b3480cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/). ### Behoben +- `epub2md_merge.py`: Kapitel-Startanker heißen jetzt `kap-003-name` statt `003-name`. Pandocs EPUB-Ausgabe + löste Links auf IDs mit führender Ziffer nicht auf (C-Buch 32, Mathebuch 20 tote Links im EPUB). + `pandoc/book.lua` erkennt beide Formen (QA3-05). +- Buch-README: Der EPUB-Befehl enthält bei Büchern mit Formeln `--mathml`. Ohne blieben im Mathebuch + 1072 Formeln als TeX-Text stehen (QA3-04). +- TeX aus `alt`-Texten wird für Pandoc portabel gemacht (`\"u` → `ü`, `\O` → `\emptyset`, `@{…}` und + `\arraycolsep` in `array`, leeres `\displaystyle` vor Zellenende). Im Mathebuch kann Pandoc damit 4985 statt + 4977 Formeln setzen; vorher blieben 9 als TeX-Text stehen (QA3-06). +- `pandoc/print.css` und `pandoc/book.lua`: keine Seitenzahl auf dem Cover. - CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `` (oder `@import`) einbindet. Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch machte das aus fetten Buchstaben Inline-Code (`` `st`an`d`ard ``) und setzte falsche ``; eine diff --git a/README.md b/README.md index 5eda27b..d2f6793 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,8 @@ verwendet und das Verzeichnis gibt den Namen vor. Beim Zusammenfügen passiert Folgendes: - Die Frontmatter der einzelnen Kapitel wird entfernt. -- Jedes Kapitel erhält einen Startanker (``). +- Jedes Kapitel erhält einen Startanker (``). Er beginnt mit einem Buchstaben, + weil Pandocs EPUB-Ausgabe Links auf IDs mit führender Ziffer nicht auflöst. - Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei. - Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf. @@ -390,9 +391,8 @@ Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bere **PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code. Dabei gehen HTML-Tabellen samt Inhalt verloren, und ``-Sprungziele fehlen, sodass interne Links im PDF ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele. -Getestet mit dem C-Buch (405 Seiten, 34 Tabellen, 728 von 755 internen Links funktionieren; die übrigen -stehen in Codeblöcken innerhalb von Tabellen). Die `README.md` jedes konvertierten Buches enthält den -passenden Befehl. +Getestet mit dem C-Buch (322 Seiten, 34 Tabellen; alle 831 internen Links haben ein Ziel, kein Zeichen +fehlt). Die `README.md` jedes konvertierten Buches enthält den passenden Befehl. Alternativ direkt aus den Kapiteldateien: @@ -407,8 +407,10 @@ pandoc output/programmierbuch/chapters/*.md \ - Ausgaben mit `--math tex` (`\(…\)`) brauchen `-f markdown+tex_math_single_backslash`. - Das YAML-Frontmatter jeder Kapiteldatei wird von Pandoc als Metadaten gelesen. Wer die Kapiteldateien direkt zusammen verarbeitet, sollte mit `--no-frontmatter` konvertieren oder die Gesamtdatei verwenden. -- Einzelne TeX-Befehle aus EPUBs (z. B. `\O` oder `@`-Konstrukte) kennt Pandocs Formelparser nicht. Er - belässt sie als TeX, mit der Warnung „Could not convert TeX math“. +- TeX aus dem E-Book wird für Pandocs Formelparser angepasst, ohne den Inhalt zu ändern: `\"u` → `ü`, + `\O` (leere Menge) → `\emptyset`, keine `@{…}`-Spaltentrenner und kein `\arraycolsep` in `array`, kein + leeres `\displaystyle` vor einem Zellenende. Seltene Handkonstruktionen (z. B. `\mathinner{…\raise…}`) + bleiben als TeX stehen, mit der Warnung „Could not convert TeX math“ (Mathebuch: 1 von 4986 Formeln). ## Bericht und Diagnose diff --git a/epub2md/mathml.py b/epub2md/mathml.py index 4687944..fdee4d8 100644 --- a/epub2md/mathml.py +++ b/epub2md/mathml.py @@ -365,7 +365,54 @@ def strip_tex_delimiters(text: str) -> str: if t.startswith(o) and t.endswith(c) and len(t) >= len(o) + len(c): t = t[len(o) : len(t) - len(c)] break - return t.strip() + return portable_tex(t.strip()) + + +UMLAUTS = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} + + +def portable_tex(tex: str) -> str: + """Publisher TeX → TeX that Pandoc's formula parser (texmath, MathJax, KaTeX) understands, still valid LaTeX: + text accents \\"u → ü, \\O (empty set in Springer books) → \\emptyset, no @{…} column separators in + array specifications, no \\arraycolsep assignments, no empty \\displaystyle before a cell end. Only changes + spacing, never content.""" + tex = re.sub(r'\\"(?:\{([aouAOU])\}|([aouAOU]))', lambda m: UMLAUTS[m.group(1) or m.group(2)], tex) + tex = re.sub(r"\\(?:text|mbox|mathrm)\s*\{\s*\\O\s*\}", r"\\emptyset", tex) + tex = re.sub(r"\\O(?![A-Za-z])", r"\\emptyset", tex) + tex = re.sub(r"\\arraycolsep\s*-?\d*\.?\d+\s*(?:pt|em|ex|mm|cm)", "", tex) + tex = re.sub(r"\\displaystyle\s*(?=\\\\|&|\\end\b)", "", tex) # no-op before a cell end; texmath fails + out: list[str] = [] + pos = 0 + for m in re.finditer(r"\\begin\{array\}\{", tex): + if m.start() < pos: + continue + end = _closing_brace(tex, m.end() - 1) + if end < 0: + break + spec = tex[m.end():end] + i = spec.find("@{") + while i >= 0: + close = _closing_brace(spec, i + 1) + if close < 0: + break + spec = spec[:i] + spec[close + 1:] + i = spec.find("@{") + out.append(tex[pos:m.end()] + spec) + pos = end + return "".join(out) + tex[pos:] + + +def _closing_brace(text: str, open_pos: int) -> int: + """Index of the '}' matching the '{' at ``open_pos`` (-1 if unbalanced).""" + depth = 0 + for i in range(open_pos, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return i + return -1 def is_display_tex(text: str) -> bool: diff --git a/epub2md/merge.py b/epub2md/merge.py index 5a052f9..3652e4d 100644 --- a/epub2md/merge.py +++ b/epub2md/merge.py @@ -188,7 +188,7 @@ def assign_anchors(chapters: list[ChapterFile]) -> int: for m in TOKEN.finditer(seg): if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids: ids.append(m.group("avalue")) - start = stems[ch.rel] + start = "kap-" + stems[ch.rel] # ids must start with a letter (Pandoc's EPUB writer breaks '#039-…') while start in used or start in ids: start = "kapitel-" + start ch.start_anchor = start diff --git a/epub2md/writer.py b/epub2md/writer.py index cf4333b..061326e 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -157,7 +157,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, f"python3 epub2md_merge.py {shlex.quote(output_dir)}", "# danach in diesem Ordner:", f"pandoc {md_} -s{math}{toc}{lua} -o {html_}", - f"pandoc {md_} --toc{lua} -o {shlex.quote(stem + '.epub')}", + f"pandoc {md_} --toc{math}{lua} -o {shlex.quote(stem + '.epub')}", ] + (latex + weasy if math else weasy + latex) out.append("## Weiterverarbeitung") out.append("```bash\n" + "\n".join(commands) + "\n```") @@ -166,7 +166,7 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus " "in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.") if stats.get("formulas"): - out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt " + out.append("Das Buch enthält Formeln: Für HTML und EPUB `--mathml` (bei HTML auch `--mathjax`) verwenden, sonst belässt " "Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.") out.append("## Reproduktion") out.append("```bash\n" + " ".join(shlex.quote(c) for c in command) + "\n```") diff --git a/pandoc/book.lua b/pandoc/book.lua index 67b76b3..f2f3225 100644 --- a/pandoc/book.lua +++ b/pandoc/book.lua @@ -11,7 +11,7 @@ This filter, for HTML output: * builds the table of contents at the placeholder (use it INSTEAD of --toc; --toc-depth is honoured), * drops Pandoc's title block when the book has its own title page, or moves it to the placeholder, * shows the cover (first chapter with nothing but one picture) without the caption "Cover", - * turns the chapter anchors into
so that print.css can + * turns the chapter anchors into
so that print.css can start every chapter – including cover, title page and imprint – on a new page. For LaTeX (PDF route md -> html -> pdf, filter in BOTH steps, no --toc) the table of contents becomes @@ -55,7 +55,7 @@ local function title_block(meta) return pandoc.RawBlock("html", table.concat(html, "\n")) end --- Paragraph that only holds chapter start anchors () → id of the first one. +-- Paragraph that only holds chapter start anchors () → id of the first one. local function chapter_anchor(block) if block.t ~= "Para" and block.t ~= "Plain" then return nil @@ -65,7 +65,8 @@ local function chapter_anchor(block) if inl.t ~= "RawInline" then return nil end - local found = inl.text:match('^$') or inl.text:match('^$') + local found = inl.text:match('^$') or inl.text:match('^$') + or inl.text:match('^$') or inl.text:match('^$') -- before 0.2.1 if found then id = id or found elseif inl.text ~= "" then @@ -143,6 +144,7 @@ local function latex_book(doc) blocks:insert(pandoc.RawBlock("latex", "\\clearpage")) blocks:insert(block) elseif block.t == "Div" and block.classes:includes("cover") then + blocks:insert(pandoc.RawBlock("latex", "\\thispagestyle{empty}")) -- no page number on the cover blocks:insert(block:walk({ Image = function(img) -- cover as large as the page allows img.attributes.width = nil img.attributes.height = "20cm" diff --git a/pandoc/print.css b/pandoc/print.css index e68f52d..2bea7b8 100644 --- a/pandoc/print.css +++ b/pandoc/print.css @@ -3,6 +3,7 @@ --pdf-engine=weasyprint -o buch.pdf book.lua places the table of contents after cover, title page and imprint and marks chapter starts. */ @page { size: A4; margin: 2cm 1.8cm; @bottom-center { content: counter(page); font-size: 9pt; } } +@page :first { @bottom-center { content: none; } } /* no page number on the cover / title page */ body { max-width: none; font-size: 10.5pt; line-height: 1.4; hyphens: auto; text-align: justify; font-family: "DejaVu Serif", "Liberation Serif", Georgia, serif; } #title-block-header, #title-block-header h1.title { text-align: center; margin-bottom: 2em; } diff --git a/tests/test_math.py b/tests/test_math.py index a89df9c..1266d5a 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -2,7 +2,7 @@ import pytest from lxml import etree from conftest import PNG, SVG, Book, Doc -from epub2md.mathml import Unsupported, mathml_to_tex +from epub2md.mathml import Unsupported, mathml_to_tex, portable_tex M = 'xmlns="http://www.w3.org/1998/Math/MathML"' @@ -111,6 +111,24 @@ def test_book_readme_suggests_mathml_for_pandoc_only_with_formulas(convert): readme = (run.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in readme and "epub2md_merge.py" in readme assert "--mathml" in readme + pandoc_lines = [ln for ln in readme.splitlines() if ln.startswith("pandoc ")] + assert pandoc_lines and all("--mathml" in ln for ln in pandoc_lines) # EPUB too, else TeX stays text (QA3-04) plain = convert(Book(docs=[Doc("a.xhtml", "

A

Text

")])) plain_readme = (plain.out / "README.md").read_text(encoding="utf-8") assert "## Weiterverarbeitung" in plain_readme and "--mathml" not in plain_readme + + +@pytest.mark.parametrize("tex,expected", [ + (r'\mbox{gr\"un}, \text{F\"{u}r}', r"\mbox{grün}, \text{Für}"), + (r'\text{ f{\"u}r } j', r"\text{ f{ü}r } j"), # braces stay balanced + (r"{\mathscr L}(A,b)={{\O }}", r"{\mathscr L}(A,b)={{\emptyset }}"), + (r"M\cap N=\text{\O }", r"M\cap N=\emptyset"), + (r"\Omega\neq\O", r"\Omega\neq\emptyset"), + (r"\begin{array}{@{}l@{\qquad \quad }l@{}} a & b\end{array}", r"\begin{array}{ll} a & b\end{array}"), + (r"\arraycolsep8pt\begin{array}{c@{\,}r} x\end{array}", r"\begin{array}{cr} x\end{array}"), + (r"a & =&\displaystyle b, \\ & \vdots &\displaystyle \\ c", r"a & =&\displaystyle b, \\ & \vdots &\\ c"), + (r"\frac{a}{b}+\{x\}", r"\frac{a}{b}+\{x\}"), +]) +def test_publisher_tex_is_made_portable(tex, expected): + """Pandoc's texmath rejects \\", \\O, @{…} and \\arraycolsep (Springer alt texts, QA3-06).""" + assert portable_tex(tex) == expected diff --git a/tests/test_merge.py b/tests/test_merge.py index 1a2d122..8277259 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -34,10 +34,10 @@ def test_merge_complete_book(convert, tmp_path): # links between chapters point into the merged file; colliding id "sec" renamed in chapter B assert '' in text and '' in text assert "[B](#c002-sec)" in text and "[A](#sec)" in text and "[hier](#sec)" in text - assert "[Kapitel B](#002-kapitel-b)" in text and '' in text + assert "[Kapitel B](#kap-002-kapitel-b)" in text and '' in text # media paths relative to the merged file, also inside image links assert "![Bild](media/images/0001-bild.png)" in text - assert "[![Bildlink](media/images/0001-bild.png)](#002-kapitel-b)" in text + assert "[![Bildlink](media/images/0001-bild.png)](#kap-002-kapitel-b)" in text # code untouched assert '[kein](link.md) id="sec" `code`' in text # footnote defined in both chapters -> only once in the merged file @@ -63,8 +63,8 @@ def test_merge_to_other_location_rewrites_media_paths(convert, tmp_path): text = target.read_text(encoding="utf-8") assert not text.startswith("---") assert f"![Bild](../{run.out.name}/media/images/0001-bild.png)" in text - assert "1. [Kapitel A](#001-kapitel-a)" in text - assert "001-kapitel-a" in anchors_in(text) + assert "1. [Kapitel A](#kap-001-kapitel-a)" in text + assert "kap-001-kapitel-a" in anchors_in(text) def test_merge_invalid_directory(tmp_path): diff --git a/tests/test_pandoc.py b/tests/test_pandoc.py new file mode 100644 index 0000000..4f4e00c --- /dev/null +++ b/tests/test_pandoc.py @@ -0,0 +1,60 @@ +"""Pandoc path: merged file → EPUB/HTML with the filters from pandoc/ (skipped without pandoc).""" + +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +from conftest import Book, Doc +from epub2md.merge import main as merge_main + +pytestmark = pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc nicht installiert") +FILTERS = Path(__file__).resolve().parent.parent / "pandoc" + + +def merged(convert, book: Book) -> Path: + run = convert(book) + assert merge_main([str(run.out)]) == 0 + return run.out / f"{run.report['input']['name'][:-5]}.md" + + +def pandoc(md: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["pandoc", md.name, *args], cwd=md.parent, capture_output=True, text=True, check=True) + + +def links_and_ids(epub: Path) -> tuple[list[tuple[str, str]], dict[str, set[str]]]: + with zipfile.ZipFile(epub) as z: + docs = {n: z.read(n).decode("utf-8") for n in z.namelist() if n.endswith(".xhtml")} + ids = {n: set(re.findall(r'\bid="([^"]+)"', t)) for n, t in docs.items()} + links = [(n, h) for n, t in docs.items() for h in re.findall(r'href="([^"]+)"', t) + if not re.match(r"^[a-z]+:", h) and not h.endswith(".css")] + return links, ids + + +def test_epub_links_to_chapter_starts_resolve(convert): + """Chapter start anchors must begin with a letter: Pandoc's EPUB writer breaks '#039-…' (QA3-05).""" + docs = [Doc("a.xhtml", '

Kapitel A

Siehe Kapitel B und Bild.

'), + Doc("b.xhtml", "

Kapitel B

Text B.

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

Nur eine Bildseite ohne Überschrift.

")] + md = merged(convert, Book(docs=docs)) + pandoc(md, "--toc", "-o", "buch.epub") + links, ids = links_and_ids(md.parent / "buch.epub") + broken = [] + for doc, href in links: + file, _, frag = href.partition("#") + target = str(Path(doc).parent / file).replace("\\", "/") if file else doc + target = re.sub(r"[^/]+/\.\./", "", target) + if target not in ids or (frag and frag not in ids[target]): + broken.append(f"{doc}: {href}") + assert not broken + + +def test_book_filter_turns_chapter_anchors_into_chapter_starts(convert): + docs = [Doc("a.xhtml", "

Kapitel A

a

"), Doc("b.xhtml", "

Kapitel B

b

")] + md = merged(convert, Book(docs=docs)) + html = pandoc(md, "-s", "--lua-filter", str(FILTERS / "book.lua"), "-t", "html").stdout + assert html.count('class="chapter-start"') == 2 + assert 'id="TOC"' in html and "Kapitel B" in html