From cc36372329883e47cae3b89c499b18dadc4eb8d5 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 5 Oct 2026 20:01:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Option=20--code-language=20f=C3=BCr=20C?= =?UTF-8?q?odebl=C3=B6cke=20ohne=20erkannte=20Sprache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 ++++- epub2md/cli.py | 13 +++++++++++++ epub2md/html_converter.py | 3 +++ epub2md/pipeline.py | 5 ++++- tests/test_codeblocks.py | 15 +++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 109032d..dc68fb8 100644 --- a/README.md +++ b/README.md @@ -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) | | `--images {relative,flat,original-path}` | `relative` | `relative`: `media/images/0001-name.ext`; `flat`: `media/name.ext`; `original-path`: `media/` | | `--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`) | | `--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) | @@ -200,7 +201,9 @@ weil CommonMark keine `[^…]`-Syntax kennt. - **Syntax-Highlighting:** Farb-Spans, ``/`` in Listings und Zeilennummern werden entfernt. Einrückung, Tabs, `<`, `>`, `&`, `$`, `|`, Backslashes und Backticks bleiben exakt erhalten. Die Sprache 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). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne diff --git a/epub2md/cli.py b/epub2md/cli.py index 277c14d..e6defcb 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import NoReturn from . import __version__ +from .codeblocks import LANG_ALIASES from .errors import EXIT_INPUT, EXIT_INTERNAL, Epub2MdError from .pipeline import Options, convert, print_summary @@ -37,6 +38,14 @@ class _Parser(argparse.ArgumentParser): 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: p = _Parser( prog="epub2md.py", @@ -70,6 +79,9 @@ def build_parser() -> argparse.ArgumentParser: help="Dateinamen-Regeln: de (ä→ae), en (ä→a), none (Unicode behalten) (Standard: de)") p.add_argument("--keep-html", action="store_true", 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", help="Bei kritischen Fehlern (fehlende Spine-Dateien, unsichere Pfade, kaputtes XHTML) abbrechen") p.add_argument("--force", action="store_true", @@ -101,6 +113,7 @@ def main(argv: list[str] | None = None) -> int: images=args.images, slug_language=args.slug_language, keep_html=args.keep_html, + code_language=args.code_language or "", strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index b3b741f..f3aeeb8 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -89,6 +89,7 @@ class RenderOptions: extract_media: bool = True keep_html: bool = False language: str = "de" + code_language: str = "" # --code-language: label for blocks without a detected language def md_escape(text: str, table: bool = False) -> str: @@ -421,6 +422,8 @@ class Renderer: if not lang: lang = detect_language_from_content(code) how = "content" if lang else "" + if not lang and self.opt.code_language: + lang, how = self.opt.code_language, "option" if self.ch: self.ch.stats.code_blocks += 1 if not lang: diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 9c1fc73..1bc1454 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -45,6 +45,7 @@ class Options: images: str = "relative" slug_language: str = "de" keep_html: bool = False + code_language: str = "" strict: bool = False verbose: 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 language = (md.language or "de").lower() 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), ) width = max(3, len(str(len(chapters) + 1))) diff --git a/tests/test_codeblocks.py b/tests/test_codeblocks.py index 1de2b82..704cdd6 100644 --- a/tests/test_codeblocks.py +++ b/tests/test_codeblocks.py @@ -86,3 +86,18 @@ def test_prose_is_not_forced_into_code(convert): def test_unlabeled_code_is_reported(convert): run = convert(Book(docs=[Doc("c.xhtml", "

U

foo bar
")])) assert run.report["code_blocks"]["languages"] == {"(keine)": 1} + + +def test_code_language_option_fills_only_unlabeled_blocks(convert): + body = "

C

x = compute()
#include <stdio.h>\nint main(void) { return 0; }
" + 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", "

C

x
")]), "--code-language", "quatsch") + assert run.rc == 1 + assert "quatsch" in run.stderr