diff --git a/README.md b/README.md index 7e8f76c..1beb872 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. 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 | +| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer | | `--version` | – | Programmversion | ### Rückgabecodes @@ -220,8 +220,7 @@ 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, 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 + Tabellen) 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 f10b89f..b007651 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -116,9 +116,6 @@ 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) @@ -155,43 +152,6 @@ 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 = ("```", "~~~", "|", " ", "#", "$$", "\\[", "* * *") - - -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() @@ -274,7 +234,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 _collapse_blank_lines(text).strip() + "\n" + return re.sub(r"\n{3,}", "\n\n", text).strip() + "\n" def render_items(self, items: Sequence[tuple[str | None, Any]]) -> list[str]: blocks: list[str] = [] @@ -734,19 +694,8 @@ 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]: @@ -913,8 +862,6 @@ 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) @@ -961,16 +908,13 @@ 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]) back = " ".join( f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", []) ) - anchor_style.append(_labeled_note(f'**{num}:**', blocks, back)) + anchor_style.append(indent_block(body, f'**{num}:** ', "") + (f" {back}" if back else "")) if anchor_style: title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes" out = [f"**{title}**"] + anchor_style + out @@ -991,7 +935,8 @@ 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, [])) - out.append(_labeled_note(f'**{num}.**', blocks, back)) + body = "\n\n".join(blocks) + out.append(f'**{num}.** ' + body + (f" {back}" if back else "")) return "\n\n".join(out).strip() + "\n" # ------------------------------------------------------------------ images @@ -1126,8 +1071,6 @@ 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/epub2md/pipeline.py b/epub2md/pipeline.py index 7c47213..60c01df 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -141,32 +141,14 @@ 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) - 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 + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(writer.to_json(report), encoding="utf-8") 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 deleted file mode 100644 index 439e22d..0000000 --- a/tests/test_qa_regressions.py +++ /dev/null @@ -1,148 +0,0 @@ -"""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"(?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 - - -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 - - -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 - - -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 - - -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 - - -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.

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