From 8d7f3de3d7d4827d220504e91a7c278b4b696ccd Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 5 Oct 2026 23:57:42 +0200 Subject: [PATCH 1/8] 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 --- README.md | 3 ++- epub2md/html_converter.py | 11 +++++++++++ tests/test_qa_regressions.py | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/test_qa_regressions.py diff --git a/README.md b/README.md index 1beb872..df0bea5 100644 --- a/README.md +++ b/README.md @@ -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, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index b007651..ae76927 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -694,8 +694,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]: diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py new file mode 100644 index 0000000..11793e0 --- /dev/null +++ b/tests/test_qa_regressions.py @@ -0,0 +1,23 @@ +"""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 = ("

Operatoren

" + "" + "" + "
OperatorBedeutung
||logisches ODER
a | bLink
") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + text = run.chapter("001") + for row in _pipe_rows(text): + cells = re.split(r"(? Date: Mon, 5 Oct 2026 23:59:02 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20Notennummer=20und=20R=C3=BCcklinks?= =?UTF-8?q?=20nicht=20an=20Codeblock-Zeilen=20anh=C3=A4ngen=20(QA2-02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- epub2md/html_converter.py | 22 ++++++++++++++++++--- tests/test_qa_regressions.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index ae76927..f77f94c 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -152,6 +152,23 @@ def indent_block(text: str, first: str, rest: str) -> str: return "\n".join(out) +_BLOCK_SYNTAX = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") + + +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() @@ -925,7 +942,7 @@ class Renderer: back = " ".join( f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", []) ) - anchor_style.append(indent_block(body, f'**{num}:** ', "") + (f" {back}" if back else "")) + anchor_style.append(_labeled_note(f'**{num}:**', blocks, back)) if anchor_style: title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes" out = [f"**{title}**"] + anchor_style + out @@ -946,8 +963,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'**{num}.** ' + body + (f" {back}" if back else "")) + out.append(_labeled_note(f'**{num}.**', blocks, back)) return "\n\n".join(out).strip() + "\n" # ------------------------------------------------------------------ images diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index 11793e0..fc52114 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -21,3 +21,41 @@ def test_pipe_in_inline_code_does_not_split_gfm_table(convert): cells = re.split(r"(?K

Text1.

' + '' + '

Zweite2.

' + '' + "

Danach kommt normaler Text.

") + + +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 From def1d8b5c02310178675a9031d87059b7fafa60e Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 5 Oct 2026 23:59:46 +0200 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20Sprungziel=20f=C3=BCr=20Notenverweis?= =?UTF-8?q?e=20aus=20HTML-Tabellen=20bei=20GFM-Fu=C3=9Fnoten=20(QA2-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wird dieselbe Anmerkung aus einer als HTML erhaltenen Tabelle und aus dem Fließtext referenziert, entstand nur eine [^fn-…]-Definition; der HTML-Verweis lief ins Leere. Die Definition erhält in diesem Fall zusätzlich den Anker . Co-Authored-By: Claude Opus 5.5 --- epub2md/html_converter.py | 3 +++ tests/test_qa_regressions.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index f77f94c..910402a 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -936,6 +936,9 @@ 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 (): give that link a target + body = f'' + body out.append(indent_block(body, f"[^{note.label}]: ", " ")) else: num = int(note.label.split("-")[1]) diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index fc52114..dfcbac0 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -59,3 +59,18 @@ def test_backlink_after_code_block_does_not_break_fence_end_of_book(convert): 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 = ('

K

' + "
Zelle1
ab
" + '

Text1.

' + '') + + +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 From 6be92645f4d2c737a3572dfb26ad3121f19177cb Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:00:28 +0200 Subject: [PATCH 4/8] fix: Endnotenverweise in HTML-Tabellen als HTML-Link ausgeben (QA2-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- epub2md/html_converter.py | 2 ++ tests/test_qa_regressions.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 910402a..d7210e5 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -890,6 +890,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'{num}')] return [("r", f'[{num}]({target})')] if note not in self.chapter_notes: self.chapter_notes.append(note) diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index dfcbac0..12d82aa 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -74,3 +74,15 @@ def test_note_referenced_from_html_table_and_text_has_target(convert): 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("")] + + +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 '1' in table From e9120c84ef4a2e772ebb9ffc27318cf984cb87e8 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:00:58 +0200 Subject: [PATCH 5/8] fix: Inline-SVG in HTML-Tabellen als ausgeben (QA2-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- epub2md/html_converter.py | 2 ++ tests/test_qa_regressions.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index d7210e5..2a2717b 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -1103,6 +1103,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'{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]: diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index 12d82aa..efe605a 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -86,3 +86,15 @@ def test_end_of_book_reference_inside_html_table_is_html(convert): table = _html_table(run.chapter("001")) assert "](" not in table, table assert '1' 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 = ('

T

Kopf
A' + '' + "
") + run = convert(Book(docs=[Doc("c.xhtml", body)])) + table = _html_table(run.chapter("001")) + assert "![" not in table, table + assert re.search(r'Kreis', table), table + assert run.report["output_link_check"]["broken_count"] == 0 From 7de7efaaa239aeecfd260dd0f0b4af7ec04dd86f Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:01:54 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Leerzeilenfolgen=20innerhalb=20von?= =?UTF-8?q?=20Codebl=C3=B6cken=20erhalten=20(QA2-06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- epub2md/html_converter.py | 22 +++++++++++++++++++++- tests/test_qa_regressions.py | 9 +++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 2a2717b..ce84500 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -152,6 +152,26 @@ 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 = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") @@ -251,7 +271,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] = [] diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index efe605a..45ecd40 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -98,3 +98,12 @@ def test_inline_svg_inside_html_table_is_html_img(convert): assert "![" not in table, table assert re.search(r'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"

K

Vorher

{code}

Nachher

" + 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 From ff1a0ae5e4447e39bbb1eee94122248961d85d55 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:02:50 +0200 Subject: [PATCH 7/8] fix: --report darf keine erzeugte Datei ersetzen, Schreibfehler mit Exit 1 (QA2-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --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 --- README.md | 2 +- epub2md/pipeline.py | 22 ++++++++++++++++++++-- tests/test_qa_regressions.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df0bea5..7e8f76c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 60c01df..7c47213 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -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 diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index 45ecd40..b79ed17 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -107,3 +107,33 @@ def test_multiple_blank_lines_inside_code_are_kept(convert): 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 _simple() -> Book: + return Book(docs=[Doc("c.xhtml", "

K

Text.

")]) + + +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 From daa99415b57734c264de4c7303bf160804335bb5 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 6 Oct 2026 00:03:29 +0200 Subject: [PATCH 8/8] fix: Zeilen aus nur - oder = als Text maskieren (QA2-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- epub2md/html_converter.py | 3 +++ tests/test_qa_regressions.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index ce84500..f10b89f 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -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) diff --git a/tests/test_qa_regressions.py b/tests/test_qa_regressions.py index b79ed17..439e22d 100644 --- a/tests/test_qa_regressions.py +++ b/tests/test_qa_regressions.py @@ -109,6 +109,15 @@ def test_multiple_blank_lines_inside_code_are_kept(convert): 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 = "

K

---

Zeile
===

Text
-----

" + 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", "

K

Text.

")])