fix: Befunde aus der QA-Prüfung (Prompt_Kontrolle.md)

- F1: Nicht beschreibbares Ausgabeziel liefert eine verständliche
  Meldung und Exit 1 statt "Interner Fehler" (Exit 4).
- F2: Anker von <dt id> stehen vor dem fett gesetzten Glossarbegriff
  statt innerhalb der **-Marker.
- F3: Formeln als <img src="*.svg"> erscheinen im Bericht mit
  origin "svg" statt "image".

Regressionstests für alle drei Befunde ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 19:47:45 +02:00
commit 3b07fcf01a
7 changed files with 54 additions and 13 deletions

View file

@ -91,7 +91,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| Code | Bedeutung |
| --- | --- |
| 0 | Erfolgreich, ggf. mit nicht-kritischen Warnungen |
| 1 | Ungültige Parameter, Eingabedatei fehlt/nicht lesbar, Ausgabeverzeichnis belegt |
| 1 | Ungültige Parameter, Eingabedatei fehlt/nicht lesbar, Ausgabeverzeichnis belegt oder nicht beschreibbar (Schreibfehler) |
| 2 | ZIP-/Container-Fehler, DRM, unsichere ZIP-Pfade (mit `--strict`) |
| 3 | OPF-/Spine-/XHTML-Fehler (z. B. leere Spine; mit `--strict` auch einzelne defekte Dokumente) |
| 4 | Unerwarteter interner Fehler (Traceback mit `--verbose`) |

View file

@ -20,7 +20,7 @@ kapitelweiser Markdown-Dateien mit Medien, Metadaten und Konvertierungsbericht.
EPILOG = """\
Rückgabecodes:
0 Erfolgreich (ggf. mit nicht-kritischen Warnungen)
1 Ungültige Parameter, Eingabedatei nicht lesbar oder Ausgabeverzeichnis belegt
1 Ungültige Parameter, Eingabedatei nicht lesbar, Ausgabeverzeichnis belegt/nicht beschreibbar
2 EPUB-/ZIP-/Containerfehler (auch DRM, unsichere ZIP-Pfade mit --strict)
3 OPF-/Spine-/XHTML-Verarbeitungsfehler
4 Unerwarteter interner Fehler

View file

@ -510,31 +510,33 @@ class Renderer:
anchor = self.anchors_of(el)
out: list[str] = [anchor] if anchor else []
terms: list[str] = []
term_anchors = "" # anchors of <dt> elements go in front of the bold term, not inside it
for child in el:
if not is_element(child):
continue
name = local(child.tag)
if name == "dt":
t = join_inline(self.inline_children(child), hard_break=" ")
a = self.anchors_of(child)
terms.append(a + t)
terms.append(join_inline(self.inline_children(child), hard_break=" "))
term_anchors += self.anchors_of(child)
elif name == "dd":
blocks = self.flow(child)
a = self.anchors_of(child)
term = " / ".join(terms) if terms else ""
terms = []
ta, terms, term_anchors = term_anchors, [], ""
if term and len(blocks) == 1 and "\n" not in blocks[0] and not blocks[0].startswith(("```", "|", "<")):
out.append(f"**{term}:** {a}{blocks[0]}")
out.append(f"{ta}**{term}:** {a}{blocks[0]}")
else:
if term:
out.append(f"**{term}**")
out.append(f"{ta}**{term}**")
elif ta:
out.append(ta)
if a:
out.append(a)
out.extend(blocks)
elif name == "div": # HTML5 allows <div> grouping inside <dl>
out.extend(self.definition_list(child))
for t in terms:
out.append(f"**{t}**")
if terms:
out.append(term_anchors + " / ".join(f"**{t}**" for t in terms))
return out
def figure(self, el: Any) -> list[str]:
@ -1008,7 +1010,8 @@ class Renderer:
self.ch.stats.math_block += 1
else:
self.ch.stats.math_inline += 1
self.formulas.append({"source": source, "chapter": self.file, "origin": "image",
origin = "svg" if path.lower().endswith(".svg") else "image"
self.formulas.append({"source": source, "chapter": self.file, "origin": origin,
"representation": "image", "display": str(display).lower()})
if not tex:
self.diag.warn("formula-image", f"Formel liegt nur als Bild vor ({path.rsplit('/', 1)[-1]}); als Bild referenziert, keine semantische Textextraktion.", source, self.file)

View file

@ -400,8 +400,11 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O
def _write_output(reader: EpubReader, input_path: Path, output_dir: Path, opts: Options,
files: dict[str, str], media: MediaRegistry) -> None:
parent = output_dir.resolve().parent
parent.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix=".epub2md-", dir=parent))
try:
parent.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix=".epub2md-", dir=parent))
except OSError as exc:
raise InputError(f"Ausgabeverzeichnis nicht beschreibbar: {parent} ({exc.strerror or exc})") from exc
try:
for rel, content in files.items():
_write_file(tmp, rel, content.encode("utf-8"))
@ -419,6 +422,9 @@ def _write_output(reader: EpubReader, input_path: Path, output_dir: Path, opts:
else:
output_dir.rmdir()
os.replace(tmp, output_dir)
except OSError as exc:
shutil.rmtree(tmp, ignore_errors=True)
raise InputError(f"Schreibfehler im Ziel {output_dir}: {exc.strerror or exc}") from exc
except BaseException:
shutil.rmtree(tmp, ignore_errors=True)
raise

View file

@ -114,3 +114,11 @@ def test_layout_table_headings_are_recognized(convert):
assert "# 3 Grundlagen" in text
assert "## 3.1 Einführung" in text
assert "<table" not in text
def test_glossary_anchor_is_placed_before_bold_term(convert):
"""QA-F2: the anchor of a <dt id> must not end up inside the bold markers."""
body = ('<h1>K</h1><p><a href="#g">API</a></p><section epub:type="glossary"><h2>Glossar</h2>'
'<dl><dt id="g">API</dt><dd>Schnittstelle.</dd></dl></section>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("002")
assert '<a id="g"></a>**API:** Schnittstelle.' in text

View file

@ -124,3 +124,20 @@ def test_example_books_structural_invariants(convert, epub: Path):
for m in report["media"]["files"]:
assert (run.out / m["file"]).is_file()
assert sum(c["stats"]["word_count"] for c in report["chapters"]) > 1000
def test_unwritable_output_parent_gives_clear_error(convert, tmp_path):
"""QA-F1: a permission problem at the target must not be reported as internal error (exit 4)."""
import os
import stat
parent = tmp_path / "readonly"
parent.mkdir()
os.chmod(parent, stat.S_IRUSR | stat.S_IXUSR)
try:
run = convert(simple_book(), out=parent / "ziel")
finally:
os.chmod(parent, stat.S_IRWXU)
assert run.rc == 1
assert "Interner Fehler" not in run.stderr
assert "nicht beschreibbar" in run.stderr or "Schreibfehler" in run.stderr

View file

@ -97,3 +97,10 @@ def test_math_image_mode(convert):
assert text.count("![") == 3 # svg + both formula images
assert "<math" in text # pure MathML without image is not lost
assert any(w["code"] == "mathml-no-image" for w in run.report["warnings"])
def test_svg_file_formula_is_reported_as_svg(convert):
"""QA-F3: <img src="*.svg"> inside a formula container is an SVG formula in the report."""
report = convert(math_book()).report
origins = {f["origin"] for f in report["formulas"]["items"]}
assert "svg" in origins