Compare commits

...
Author SHA1 Message Date
daa99415b5 fix: Zeilen aus nur - oder = als Text maskieren (QA2-08)
Ein Absatz "---" wurde zur Trennlinie, "===" bzw. "---" nach einem
Zeilenumbruch machte die vorige Zeile zur Setext-Überschrift. Solche
Zeilen erhalten jetzt einen Backslash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:03:29 +02:00
ff1a0ae5e4 fix: --report darf keine erzeugte Datei ersetzen, Schreibfehler mit Exit 1 (QA2-07)
--report OUTPUT/manifest.json ersetzte das Manifest stillschweigend durch
den Bericht. Ein nicht beschreibbares --report-Ziel endete nach bereits
geschriebener Ausgabe mit Exit 4 ("Interner Fehler"). Jetzt: Abbruch mit
Exit 1 vor dem Schreiben bzw. verständliche Meldung mit Exit 1;
Überschreibverhalten in der README dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:02:50 +02:00
7de7efaaa2 fix: Leerzeilenfolgen innerhalb von Codeblöcken erhalten (QA2-06)
render_chapter reduzierte mehrfache Leerzeilen im gesamten Kapiteltext,
auch innerhalb von Codeblöcken (z. B. zwei Leerzeilen zwischen
Python-Funktionen nach PEP 8). Im Python-Beispielbuch waren 54 von 746
Listings nicht mehr zeichengetreu. Die Normalisierung gilt jetzt nur
außerhalb von Fences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:01:54 +02:00
e9120c84ef fix: Inline-SVG in HTML-Tabellen als <img> ausgeben (QA2-05)
Eingebettete SVGs in komplexen Tabellen wurden als Markdown-Bild
![…](…) in den HTML-Block geschrieben und daher nicht angezeigt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:00:58 +02:00
6be92645f4 fix: Endnotenverweise in HTML-Tabellen als HTML-Link ausgeben (QA2-04)
Mit --footnotes end-of-book wurde ein Verweis innerhalb einer als HTML
erhaltenen Tabelle als Markdown-Link [n](…) geschrieben. In einem
HTML-Block rendert Markdown nicht; der Verweis erschien als Rohtext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:00:28 +02:00
def1d8b5c0 fix: Sprungziel für Notenverweise aus HTML-Tabellen bei GFM-Fußnoten (QA2-03)
Wird dieselbe Anmerkung aus einer als HTML erhaltenen Tabelle und aus
dem Fließtext referenziert, entstand nur eine [^fn-…]-Definition; der
HTML-Verweis <a href="#fn-…"> lief ins Leere. Die Definition erhält in
diesem Fall zusätzlich den Anker <a id="fn-…">.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:59:46 +02:00
467b070326 fix: Notennummer und Rücklinks nicht an Codeblock-Zeilen anhängen (QA2-02)
Bei Anmerkungen im Anker-Stil (--format commonmark, --footnotes
end-of-book) wurde der Rücklink [↩] an die schließende Fence-Zeile
angehängt. Der Codeblock blieb dadurch offen und verschluckte den Rest
der Datei; epub2md_merge.py schrieb die Links darin nicht mehr um.
Beginnt oder endet eine Anmerkung mit einem Block (Code, Tabelle,
Liste, Formel), stehen Nummer bzw. Rücklinks jetzt in eigenem Absatz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:59:02 +02:00
8d7f3de3d7 fix: Tabellen mit | in Code, Formel oder Link als HTML ausgeben (QA2-01)
GFM trennt Tabellenzellen an jedem unmaskierten senkrechten Strich, auch
innerhalb von Code-Spans und Link-Zielen. Operator-Tabellen (|, ||, |=)
verloren dadurch Zellinhalte. Solche Tabellen werden jetzt wie andere
komplexe Tabellen als bereinigtes HTML mit Warnung ausgegeben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:42 +02:00
4 changed files with 232 additions and 8 deletions

View file

@ -102,7 +102,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
| `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr |
| `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) |
| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer |
| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer. Eine bestehende Datei `DATEI.json` wird ersetzt; Namen, die epub2md selbst im Ausgabeverzeichnis erzeugt (z. B. `manifest.json`), werden abgelehnt (Exit 1). Kann der Bericht nicht geschrieben werden: Exit 1, die Ausgabe ist trotzdem vollständig |
| `--version` | – | Programmversion |
### Rückgabecodes
@ -220,7 +220,8 @@ weil CommonMark keine `[^…]`-Syntax kennt.
- **Formelbasierte EPUBs:** Am besten sind EPUBs mit MathML oder TeX im `alt`-Text (z. B. Springer).
- **SVG-Formeln** bleiben SVG (nie gerastert) und werden im Bericht als Formel markiert.
- **Layouttabellen / komplexe Tabellen** (`colspan`, `rowspan`, Blockinhalte in Zellen, verschachtelte
Tabellen) bleiben als bereinigtes HTML im Markdown erhalten, mit Warnung. Einfache Tabellen werden
Tabellen, senkrechte Striche `|` in Code, Formeln oder Link-Zielen – GFM würde die Zeile dort in
zusätzliche Zellen zerlegen) bleiben als bereinigtes HTML im Markdown erhalten, mit Warnung. Einfache Tabellen werden
GFM-Pipe-Tabellen. Einzellige Layouttabellen werden als normaler Text ausgegeben.
- **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt.
Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache

View file

@ -116,6 +116,9 @@ def escape_line_starts(text: str) -> str:
token = m.group(2)
pos = m.start(2) + (len(token) - 1 if token[0].isdigit() else 0)
line = line[:pos] + "\\" + line[pos:]
elif re.fullmatch(r"\s*(-+|=+)\s*", line): # thematic break / setext underline
pos = len(line) - len(line.lstrip())
line = line[:pos] + "\\" + line[pos:]
out.append(line)
return "\n".join(out)
@ -152,6 +155,43 @@ def indent_block(text: str, first: str, rest: str) -> str:
return "\n".join(out)
def _collapse_blank_lines(text: str) -> str:
"""Reduce runs of blank lines to one, except inside fenced code blocks (also indented/quoted ones)."""
out: list[str] = []
fence = ""
for line in text.split("\n"):
bare = re.sub(r"^[ \t]*(?:>[ \t]?)*[ \t]*", "", line)
if fence:
if bare.rstrip() and set(bare.rstrip()) == {fence[0]} and len(bare.rstrip()) >= len(fence):
fence = ""
out.append(line)
continue
m = re.match(r"(`{3,}|~{3,})", bare)
if m:
fence = m.group(1)
elif not line.strip() and out and not out[-1].strip():
continue
out.append(line)
return "\n".join(out)
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
def _is_block_syntax(block: str) -> bool:
return block.startswith(_BLOCK_SYNTAX) or re.match(r"\d+\. ", block) is not None
def _labeled_note(label: str, blocks: list[str], back: str) -> str:
"""Anchor-style note: label before and backlinks after the body, without gluing them onto a code fence,
table or list line (which would break that block)."""
body = "\n\n".join(blocks)
text = f"{label}\n\n{body}" if _is_block_syntax(blocks[0]) else f"{label} {body}"
if back:
text += f"\n\n{back}" if _is_block_syntax(blocks[-1]) else f" {back}"
return text
def _ancestors(el: Any, depth: int = 5) -> list[Any]:
out: list[Any] = []
node = el.getparent()
@ -234,7 +274,7 @@ class Renderer:
blocks.extend(self.render_items(ch.items))
blocks.extend(self._chapter_note_definitions())
text = "\n\n".join(b for b in blocks if b.strip())
return re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return _collapse_blank_lines(text).strip() + "\n"
def render_items(self, items: Sequence[tuple[str | None, Any]]) -> list[str]:
blocks: list[str] = []
@ -694,8 +734,19 @@ class Renderer:
return f"Blockelemente ({local(e.tag)}) in Zellen"
if local(e.tag) == "math" and e.get("display") == "block":
return "abgesetzte Formeln in Zellen"
if self._raw_pipe(e):
return "senkrechte Striche (|) in Code, Formel oder Link"
return ""
def _raw_pipe(self, e: Any) -> bool:
"""'|' in content that is emitted unescaped (code span, math, link target, image) would split a GFM row."""
name = local(e.tag)
if is_inline_code(e) or name in ("kbd", "math"):
return "|" in text_content(e)
if name in ("a", "img", "image", "svg"):
return any("|" in v for v in e.attrib.values())
return False
# ================================================================== inline
def inline_children(self, el: Any) -> list[Piece]:
@ -862,6 +913,8 @@ class Renderer:
ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "")
refs.append((self.file, ref_id))
target = url_path(rel_link(self.file, self.endnotes_file)) + "#" + note.label
if self.in_table_html: # Markdown is not rendered inside an HTML block
return [("r", f'<a id="{ref_id}"></a><sup><a href="{target}">{num}</a></sup>')]
return [("r", f'<a id="{ref_id}"></a><sup>[{num}]({target})</sup>')]
if note not in self.chapter_notes:
self.chapter_notes.append(note)
@ -908,13 +961,16 @@ class Renderer:
blocks = self.render_note(note) or ["(leer)"]
body = "\n\n".join(blocks)
if note.label in self.md_ref_labels:
if f"{self.file}:{note.label}" in self.note_refs:
# also referenced from an HTML table (<a href="#fn-…">): give that link a target
body = f'<a id="{note.label}"></a>' + body
out.append(indent_block(body, f"[^{note.label}]: ", " "))
else:
num = int(note.label.split("-")[1])
back = " ".join(
f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", [])
)
anchor_style.append(indent_block(body, f'<a id="{note.label}"></a>**{num}:** ', "") + (f" {back}" if back else ""))
anchor_style.append(_labeled_note(f'<a id="{note.label}"></a>**{num}:**', blocks, back))
if anchor_style:
title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes"
out = [f"**{title}**"] + anchor_style + out
@ -935,8 +991,7 @@ class Renderer:
num = int(note.label.split("-")[1])
blocks = self.render_note(note) or ["(leer)"]
back = " ".join(f"[↩]({url_path(rel_link(self.file, f))}#{ref})" for f, ref in self.note_refs.get(note.label, []))
body = "\n\n".join(blocks)
out.append(f'<a id="{note.label}"></a>**{num}.** ' + body + (f" {back}" if back else ""))
out.append(_labeled_note(f'<a id="{note.label}"></a>**{num}.**', blocks, back))
return "\n\n".join(out).strip() + "\n"
# ------------------------------------------------------------------ images
@ -1071,6 +1126,8 @@ class Renderer:
self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "svg",
"representation": "svg", "display": "false"})
self.diag.warn("formula-svg", "Formel liegt nur als SVG vor; als Bild referenziert.", self._source(el), self.file)
if self.in_table_html: # Markdown is not rendered inside an HTML block
return [("r", f'<img src="{html.escape(url_path(rel_link(self.file, mf.output)))}" alt="{html.escape(alt)}"/>')]
return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))})")]
def math(self, el: Any, display: bool) -> list[Piece]:

View file

@ -141,14 +141,32 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
if opts.report:
_check_report_path(Path(opts.report), output_dir, files, rendered.media, opts)
_write_output(reader, input_path, output_dir, opts, files, rendered.media)
if opts.report:
report_path = Path(opts.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8")
try:
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8")
except OSError as exc:
raise InputError(f"Bericht (--report) konnte nicht geschrieben werden: {report_path} "
f"({exc.strerror or exc}); die Ausgabe in {output_dir} ist vollständig.") from exc
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
def _check_report_path(report_path: Path, output_dir: Path, files: dict[str, str], media: MediaRegistry,
opts: Options) -> None:
"""--report must not replace a file that epub2md itself writes into the output directory."""
if not is_inside(report_path, output_dir):
return
rel = report_path.resolve().relative_to(output_dir.resolve()).as_posix()
generated = set(files) | ({mf.output for mf in media.files.values()} if opts.extract_media else set())
if rel in generated:
raise InputError(f"--report {report_path} würde die erzeugte Datei {rel} überschreiben; "
"bitte einen anderen Dateinamen wählen.")
@dataclass
class _Rendered:
renderer: Renderer

View file

@ -0,0 +1,148 @@
"""Regression tests for defects found in the independent QA run (.qa/QA_REPORT.md, IDs QA2-…)."""
import re
from conftest import Book, Doc
def _pipe_rows(text: str) -> list[str]:
return [ln for ln in text.splitlines() if ln.startswith("| ")]
def test_pipe_in_inline_code_does_not_split_gfm_table(convert):
"""QA2-01: GFM splits table cells at every unescaped '|', also inside code spans and link targets."""
body = ("<h1>Operatoren</h1><table><thead><tr><th>Operator</th><th>Bedeutung</th></tr></thead><tbody>"
"<tr><td><code>||</code></td><td>logisches ODER</td></tr>"
"<tr><td><code>a | b</code></td><td><a href=\"https://example.org/a|b\">Link</a></td></tr>"
"</tbody></table>")
run = convert(Book(docs=[Doc("c.xhtml", body)]))
text = run.chapter("001")
for row in _pipe_rows(text):
cells = re.split(r"(?<!\\)\|", row.strip()[1:-1])
assert len(cells) == 2, f"Zeile zerfällt in GFM in {len(cells)} Zellen: {row}"
assert "||" in text and "a | b" in text and "logisches ODER" in text
NOTE_WITH_CODE = ('<h1>K</h1><p>Text<a epub:type="noteref" href="#n1" id="r1">1</a>.</p>'
'<aside epub:type="footnote" id="n1"><p>Note mit Code:</p><pre>code()\n weiter()</pre></aside>'
'<p>Zweite<a epub:type="noteref" href="#n2" id="r2">2</a>.</p>'
'<aside epub:type="footnote" id="n2"><pre>nur_code()</pre></aside>'
"<p>Danach kommt normaler Text.</p>")
def _fences_closed(text: str) -> bool:
"""Every opening ``` fence has a closing line that consists only of backticks."""
open_fence = None
for line in text.splitlines():
s = line.strip()
if open_fence is None:
m = re.match(r"(`{3,})", s)
if m:
open_fence = m.group(1)
elif re.fullmatch(r"`{3,}", s) and len(s) >= len(open_fence):
open_fence = None
return open_fence is None
def test_backlink_after_code_block_does_not_break_fence_commonmark(convert):
"""QA2-02: the ↩ backlink was appended to the closing ``` line, leaving the fence open."""
run = convert(Book(docs=[Doc("c.xhtml", NOTE_WITH_CODE)]), "--format", "commonmark")
text = run.chapter("001")
assert _fences_closed(text), text
assert not re.search(r"\*\*\d+[:.]\*\* ```", text), "Fence hinter Notennummer ist kein Codeblock"
assert "[↩](#fnref-001)" in text
def test_backlink_after_code_block_does_not_break_fence_end_of_book(convert):
run = convert(Book(docs=[Doc("c.xhtml", NOTE_WITH_CODE)]), "--footnotes", "end-of-book")
notes = run.chapter("999")
assert _fences_closed(notes), notes
assert not re.search(r"\*\*\d+[:.]\*\* ```", notes), "Fence hinter Notennummer ist kein Codeblock"
assert "[↩](001-k.md#fnref-001)" in notes
NOTE_IN_HTML_TABLE = ('<h1>K</h1><table><tr><td colspan="2">Zelle<a epub:type="noteref" href="#n1" id="r1">1</a></td></tr>'
"<tr><td>a</td><td>b</td></tr></table>"
'<p>Text<a epub:type="noteref" href="#n1" id="r1b">1</a>.</p>'
'<aside epub:type="footnote" id="n1"><p>Die Note.</p></aside>')
def test_note_referenced_from_html_table_and_text_has_target(convert):
"""QA2-03: GFM: the HTML-table reference pointed to #fn-001, but only a [^fn-001] definition existed."""
run = convert(Book(docs=[Doc("c.xhtml", NOTE_IN_HTML_TABLE)]))
text = run.chapter("001")
assert 'href="#fn-001"' in text and "Text[^fn-001]." in text
assert re.search(r'id="fn-001"', text), text
assert run.report["output_link_check"]["broken_count"] == 0
def _html_table(text: str) -> str:
return text[text.index("<table"): text.index("</table>")]
def test_end_of_book_reference_inside_html_table_is_html(convert):
"""QA2-04: a Markdown link inside an HTML block is not rendered (shows up as literal text)."""
run = convert(Book(docs=[Doc("c.xhtml", NOTE_IN_HTML_TABLE)]), "--footnotes", "end-of-book")
table = _html_table(run.chapter("001"))
assert "](" not in table, table
assert '<a href="999-anmerkungen.md#fn-001">1</a>' in table
def test_inline_svg_inside_html_table_is_html_img(convert):
"""QA2-05: inline SVG in a complex table was written as Markdown image inside the HTML block."""
body = ('<h1>T</h1><table><tr><th colspan="2">Kopf</th></tr><tr><td>A</td><td>'
'<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" aria-label="Kreis"><circle r="2"/></svg>'
"</td></tr></table>")
run = convert(Book(docs=[Doc("c.xhtml", body)]))
table = _html_table(run.chapter("001"))
assert "![" not in table, table
assert re.search(r'<img src="\.\./media/images/\d{4}-c-svg\.svg" alt="Kreis"/>', table), table
assert run.report["output_link_check"]["broken_count"] == 0
def test_multiple_blank_lines_inside_code_are_kept(convert):
"""QA2-06: runs of blank lines inside code blocks were collapsed to one (PEP 8: two between functions)."""
code = "def f():\n pass\n\n\nclass K:\n pass\n\n\n\nx = 1"
body = f"<h1>K</h1><p>Vorher</p><pre>{code}</pre><p>Nachher</p>"
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert f"```python\n{code}\n```" in text, text
assert "\n\n\n" not in text.replace(code, "") # outside code blank lines are still normalized
def test_dash_and_equals_lines_stay_text(convert):
"""QA2-08: a paragraph '---' became a thematic break, '===' after a line break a setext heading."""
body = "<h1>K</h1><p>---</p><p>Zeile<br/>===</p><p>Text<br/>-----</p>"
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "\n\\---\n" in text, text
assert "Zeile\\\n\\===" in text, text
assert "Text\\\n\\-----" in text, text
def _simple() -> Book:
return Book(docs=[Doc("c.xhtml", "<h1>K</h1><p>Text.</p>")])
def test_report_must_not_overwrite_generated_output_file(convert, tmp_path):
"""QA2-07: --report OUTPUT/manifest.json silently replaced the manifest with the report."""
out = tmp_path / "ziel"
run = convert(_simple(), "--report", str(out / "manifest.json"), out=out)
assert run.rc == 1
assert "--report" in run.stderr
assert not out.exists()
def test_report_write_error_is_input_error_not_internal(convert, tmp_path):
"""QA2-07: an unwritable --report target ended with exit 4 ("Interner Fehler")."""
import os
import stat
ro = tmp_path / "ro"
ro.mkdir()
os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR)
try:
run = convert(_simple(), "--report", str(ro / "bericht.json"), out=tmp_path / "ziel")
finally:
os.chmod(ro, stat.S_IRWXU)
assert run.rc == 1, run.stderr
assert "Interner Fehler" not in run.stderr
assert "Bericht" in run.stderr