Compare commits

..
4 changed files with 8 additions and 232 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) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
| `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr | | `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr |
| `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) | | `--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 | | `--version` | – | Programmversion |
### Rückgabecodes ### 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). - **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. - **SVG-Formeln** bleiben SVG (nie gerastert) und werden im Bericht als Formel markiert.
- **Layouttabellen / komplexe Tabellen** (`colspan`, `rowspan`, Blockinhalte in Zellen, verschachtelte - **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 Tabellen) bleiben als bereinigtes HTML im Markdown erhalten, mit Warnung. Einfache Tabellen werden
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. GFM-Pipe-Tabellen. Einzellige Layouttabellen werden als normaler Text ausgegeben.
- **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt. - **Syntax-Highlighting:** Farb-Spans, `<strong>`/`<em>` in Listings und Zeilennummern werden entfernt.
Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache

View file

@ -116,9 +116,6 @@ def escape_line_starts(text: str) -> str:
token = m.group(2) token = m.group(2)
pos = m.start(2) + (len(token) - 1 if token[0].isdigit() else 0) pos = m.start(2) + (len(token) - 1 if token[0].isdigit() else 0)
line = line[:pos] + "\\" + line[pos:] 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) out.append(line)
return "\n".join(out) return "\n".join(out)
@ -155,43 +152,6 @@ def indent_block(text: str, first: str, rest: str) -> str:
return "\n".join(out) 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]: def _ancestors(el: Any, depth: int = 5) -> list[Any]:
out: list[Any] = [] out: list[Any] = []
node = el.getparent() node = el.getparent()
@ -274,7 +234,7 @@ class Renderer:
blocks.extend(self.render_items(ch.items)) blocks.extend(self.render_items(ch.items))
blocks.extend(self._chapter_note_definitions()) blocks.extend(self._chapter_note_definitions())
text = "\n\n".join(b for b in blocks if b.strip()) 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]: def render_items(self, items: Sequence[tuple[str | None, Any]]) -> list[str]:
blocks: list[str] = [] blocks: list[str] = []
@ -734,19 +694,8 @@ class Renderer:
return f"Blockelemente ({local(e.tag)}) in Zellen" return f"Blockelemente ({local(e.tag)}) in Zellen"
if local(e.tag) == "math" and e.get("display") == "block": if local(e.tag) == "math" and e.get("display") == "block":
return "abgesetzte Formeln in Zellen" return "abgesetzte Formeln in Zellen"
if self._raw_pipe(e):
return "senkrechte Striche (|) in Code, Formel oder Link"
return "" 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 # ================================================================== inline
def inline_children(self, el: Any) -> list[Piece]: 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 "") ref_id = f"fnref-{num:03d}" + (f"-{len(refs) + 1}" if refs else "")
refs.append((self.file, ref_id)) refs.append((self.file, ref_id))
target = url_path(rel_link(self.file, self.endnotes_file)) + "#" + note.label 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>')] return [("r", f'<a id="{ref_id}"></a><sup>[{num}]({target})</sup>')]
if note not in self.chapter_notes: if note not in self.chapter_notes:
self.chapter_notes.append(note) self.chapter_notes.append(note)
@ -961,16 +908,13 @@ class Renderer:
blocks = self.render_note(note) or ["(leer)"] blocks = self.render_note(note) or ["(leer)"]
body = "\n\n".join(blocks) body = "\n\n".join(blocks)
if note.label in self.md_ref_labels: 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}]: ", " ")) out.append(indent_block(body, f"[^{note.label}]: ", " "))
else: else:
num = int(note.label.split("-")[1]) num = int(note.label.split("-")[1])
back = " ".join( back = " ".join(
f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", []) f"[↩](#{ref})" for _, ref in self.note_refs.get(f"{self.file}:{note.label}", [])
) )
anchor_style.append(_labeled_note(f'<a id="{note.label}"></a>**{num}:**', blocks, back)) anchor_style.append(indent_block(body, f'<a id="{note.label}"></a>**{num}:** ', "") + (f" {back}" if back else ""))
if anchor_style: if anchor_style:
title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes" title = "Anmerkungen" if self.opt.language.startswith("de") else "Notes"
out = [f"**{title}**"] + anchor_style + out out = [f"**{title}**"] + anchor_style + out
@ -991,7 +935,8 @@ class Renderer:
num = int(note.label.split("-")[1]) num = int(note.label.split("-")[1])
blocks = self.render_note(note) or ["(leer)"] 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, [])) 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'<a id="{note.label}"></a>**{num}.**', blocks, back)) body = "\n\n".join(blocks)
out.append(f'<a id="{note.label}"></a>**{num}.** ' + body + (f" {back}" if back else ""))
return "\n\n".join(out).strip() + "\n" return "\n\n".join(out).strip() + "\n"
# ------------------------------------------------------------------ images # ------------------------------------------------------------------ images
@ -1126,8 +1071,6 @@ class Renderer:
self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "svg", self.formulas.append({"source": self._source(el), "chapter": self.file, "origin": "svg",
"representation": "svg", "display": "false"}) "representation": "svg", "display": "false"})
self.diag.warn("formula-svg", "Formel liegt nur als SVG vor; als Bild referenziert.", self._source(el), self.file) 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))})")] return [("r", f"![{self._alt(alt)}]({url_path(rel_link(self.file, mf.output))})")]
def math(self, el: Any, display: bool) -> list[Piece]: def math(self, el: Any, display: bool) -> list[Piece]:

View file

@ -141,32 +141,14 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
if opts.dry_run: if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) 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) _write_output(reader, input_path, output_dir, opts, files, rendered.media)
if opts.report: if opts.report:
report_path = Path(opts.report) report_path = Path(opts.report)
try:
report_path.parent.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8") 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) 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 @dataclass
class _Rendered: class _Rendered:
renderer: Renderer renderer: Renderer

View file

@ -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 = ("<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