feat: Option --code-language für Codeblöcke ohne erkannte Sprache

Weist Blöcken ohne erkannte Sprache ein vom Nutzer gewähltes Label zu.
Aliase wie py werden normalisiert, unbekannte Werte ergeben Exit 1.
Erkannte Labels haben Vorrang; im Bericht detected_by "option"
(QA-Befund R1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 20:01:07 +02:00
commit cc36372329
5 changed files with 39 additions and 2 deletions

View file

@ -78,6 +78,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--math {auto,tex,mathml,image}` | `auto` | siehe [Mathematik](#math-modi) | | `--math {auto,tex,mathml,image}` | `auto` | siehe [Mathematik](#math-modi) |
| `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/<Pfad im EPUB>` | | `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/<Pfad im EPUB>` |
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | | `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) | | `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
| `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) |
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
@ -200,7 +201,9 @@ weil CommonMark keine `[^…]`-Syntax kennt.
- **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
kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen kommt aus Klassen/Attributen (`language-python`, `data-lang`, …) oder starken Inhaltssignalen
(`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen
Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft
auch Programmausgaben und Shell-Befehle im Buch.
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
- **DRM:** wird erkannt und abgelehnt. - **DRM:** wird erkannt und abgelehnt.
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne

View file

@ -9,6 +9,7 @@ from pathlib import Path
from typing import NoReturn from typing import NoReturn
from . import __version__ from . import __version__
from .codeblocks import LANG_ALIASES
from .errors import EXIT_INPUT, EXIT_INTERNAL, Epub2MdError from .errors import EXIT_INPUT, EXIT_INTERNAL, Epub2MdError
from .pipeline import Options, convert, print_summary from .pipeline import Options, convert, print_summary
@ -37,6 +38,14 @@ class _Parser(argparse.ArgumentParser):
sys.exit(EXIT_INPUT) sys.exit(EXIT_INPUT)
def code_language(value: str) -> str:
lang = LANG_ALIASES.get(value.strip().lower())
if lang is None:
known = ", ".join(sorted(set(LANG_ALIASES.values())))
raise argparse.ArgumentTypeError(f"unbekannte Sprache {value!r} (bekannt: {known})")
return lang
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = _Parser( p = _Parser(
prog="epub2md.py", prog="epub2md.py",
@ -70,6 +79,9 @@ def build_parser() -> argparse.ArgumentParser:
help="Dateinamen-Regeln: de (ä→ae), en (ä→a), none (Unicode behalten) (Standard: de)") help="Dateinamen-Regeln: de (ä→ae), en (ä→a), none (Unicode behalten) (Standard: de)")
p.add_argument("--keep-html", action="store_true", p.add_argument("--keep-html", action="store_true",
help="Nicht zuverlässig konvertierbare Elemente als bereinigtes HTML erhalten") help="Nicht zuverlässig konvertierbare Elemente als bereinigtes HTML erhalten")
p.add_argument("--code-language", type=code_language, default=None, metavar="SPRACHE",
help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c "
"(erkannte Labels haben Vorrang; Standard: kein Label)")
p.add_argument("--strict", action="store_true", p.add_argument("--strict", action="store_true",
help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen") help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen")
p.add_argument("--force", action="store_true", p.add_argument("--force", action="store_true",
@ -101,6 +113,7 @@ def main(argv: list[str] | None = None) -> int:
images=args.images, images=args.images,
slug_language=args.slug_language, slug_language=args.slug_language,
keep_html=args.keep_html, keep_html=args.keep_html,
code_language=args.code_language or "",
strict=args.strict, strict=args.strict,
verbose=args.verbose, verbose=args.verbose,
dry_run=args.dry_run, dry_run=args.dry_run,

View file

@ -89,6 +89,7 @@ class RenderOptions:
extract_media: bool = True extract_media: bool = True
keep_html: bool = False keep_html: bool = False
language: str = "de" language: str = "de"
code_language: str = "" # --code-language: label for blocks without a detected language
def md_escape(text: str, table: bool = False) -> str: def md_escape(text: str, table: bool = False) -> str:
@ -421,6 +422,8 @@ class Renderer:
if not lang: if not lang:
lang = detect_language_from_content(code) lang = detect_language_from_content(code)
how = "content" if lang else "" how = "content" if lang else ""
if not lang and self.opt.code_language:
lang, how = self.opt.code_language, "option"
if self.ch: if self.ch:
self.ch.stats.code_blocks += 1 self.ch.stats.code_blocks += 1
if not lang: if not lang:

View file

@ -45,6 +45,7 @@ class Options:
images: str = "relative" images: str = "relative"
slug_language: str = "de" slug_language: str = "de"
keep_html: bool = False keep_html: bool = False
code_language: str = ""
strict: bool = False strict: bool = False
verbose: bool = False verbose: bool = False
dry_run: bool = False dry_run: bool = False
@ -180,7 +181,9 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
cover_out = media.register(md.cover_path, role="cover", hint="cover").output cover_out = media.register(md.cover_path, role="cover", hint="cover").output
language = (md.language or "de").lower() language = (md.language or "de").lower()
renderer = Renderer( renderer = Renderer(
RenderOptions(opts.format, opts.footnotes, opts.math, opts.extract_media, opts.keep_html, language), RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
code_language=opts.code_language),
resolver, media, notes, diag, set(reader.names), resolver, media, notes, diag, set(reader.names),
) )
width = max(3, len(str(len(chapters) + 1))) width = max(3, len(str(len(chapters) + 1)))

View file

@ -86,3 +86,18 @@ def test_prose_is_not_forced_into_code(convert):
def test_unlabeled_code_is_reported(convert): def test_unlabeled_code_is_reported(convert):
run = convert(Book(docs=[Doc("c.xhtml", "<h1>U</h1><pre>foo bar</pre>")])) run = convert(Book(docs=[Doc("c.xhtml", "<h1>U</h1><pre>foo bar</pre>")]))
assert run.report["code_blocks"]["languages"] == {"(keine)": 1} assert run.report["code_blocks"]["languages"] == {"(keine)": 1}
def test_code_language_option_fills_only_unlabeled_blocks(convert):
body = "<h1>C</h1><pre>x = compute()</pre><pre>#include &lt;stdio.h&gt;\nint main(void) { return 0; }</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--code-language", "py")
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))]
assert langs == ["python", "c"]
by = {item["language"]: item["detected_by"] for item in run.report["code_blocks"]["items"]}
assert by == {"python": "option", "c": "content"}
def test_code_language_option_rejects_unknown_values(convert):
run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch")
assert run.rc == 1
assert "quatsch" in run.stderr