Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daa99415b5 | |||
| ff1a0ae5e4 | |||
| 7de7efaaa2 | |||
| e9120c84ef | |||
| 6be92645f4 | |||
| def1d8b5c0 | |||
| 467b070326 | |||
| 8d7f3de3d7 |
4 changed files with 232 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")})")]
|
||||
|
||||
def math(self, el: Any, display: bool) -> list[Piece]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
148
tests/test_qa_regressions.py
Normal file
148
tests/test_qa_regressions.py
Normal 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
|
||||
Loading…
Reference in a new issue