feat: Druck-Umbruchzeichen (↪/➥) in Codeblöcken auflösen (QA R6)
Fortsetzungszeilen werden wieder mit genau einem Leerzeichen zur ursprünglichen Codezeile zusammengefügt. Im Python-Beispielbuch sind das 195 Stellen; danach lassen sich 247 statt 203 von 255 Python-Blöcken parsen. Abschaltbar mit --keep-wrap-markers; der Bericht enthält wrap_lines_joined pro Block und einen Hinweis pro Kapitel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f14e657e29
commit
6db834addd
7 changed files with 68 additions and 7 deletions
|
|
@ -164,6 +164,10 @@ python3 epub2md.py python-buch.epub output/python-buch --code-language python
|
||||||
Das hilft Editoren beim Einfärben des Codes. Nachteil: Auch Programmausgaben und Shell-Befehle im Buch
|
Das hilft Editoren beim Einfärben des Codes. Nachteil: Auch Programmausgaben und Shell-Befehle im Buch
|
||||||
werden dann als Python markiert.
|
werden dann als Python markiert.
|
||||||
|
|
||||||
|
Zu lange Codezeilen sind im gedruckten Buch oft umbrochen und die Fortsetzung mit `↪` markiert. Das
|
||||||
|
Programm fügt solche Zeilen automatisch wieder zusammen, damit der Code lauffähig ist. Wer die
|
||||||
|
Druckfassung unverändert haben möchte, gibt `--keep-wrap-markers` an.
|
||||||
|
|
||||||
### Mathematikbuch
|
### Mathematikbuch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -313,9 +317,9 @@ Nein, es wird nur gelesen. Mit `--copy-original` wird zusätzlich eine Kopie in
|
||||||
Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der
|
Manche E-Book-Seiten (Titelseite, Impressum) haben weder Überschrift noch Titel. Dann wird der
|
||||||
interne Dateiname verwendet.
|
interne Dateiname verwendet.
|
||||||
|
|
||||||
**Warum steht in einem Codebeispiel ein `↪` oder ist es falsch eingerückt?**
|
**Warum ist ein Codebeispiel falsch eingerückt?**
|
||||||
So steht es im E-Book selbst (Umbruchzeichen bzw. Satzfehler des Verlags). Der Code wird bewusst
|
So steht es im E-Book selbst (Satzfehler des Verlags). Der Code wird bewusst zeichengetreu
|
||||||
zeichengetreu übernommen.
|
übernommen. Nur die Druck-Umbruchzeichen `↪` werden aufgelöst (abschaltbar mit `--keep-wrap-markers`).
|
||||||
|
|
||||||
**Können Dateinamen Umlaute enthalten?**
|
**Können Dateinamen Umlaute enthalten?**
|
||||||
Standardmäßig werden Umlaute umschrieben (`ä` → `ae`). Mit `--slug-language none` bleiben sie erhalten,
|
Standardmäßig werden Umlaute umschrieben (`ä` → `ae`). Mit `--slug-language none` bleiben sie erhalten,
|
||||||
|
|
@ -345,6 +349,7 @@ python3 epub2md_merge.py --help
|
||||||
| `--force` | frühere Ausgabe ersetzen |
|
| `--force` | frühere Ausgabe ersetzen |
|
||||||
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
|
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
|
||||||
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
||||||
|
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
||||||
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
||||||
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
||||||
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
|
||||||
| `--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"` |
|
| `--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-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
|
||||||
| `--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) |
|
||||||
|
|
@ -339,8 +340,8 @@ Verlustfreiheit behauptet (`lossless: false`).
|
||||||
(z. B. `cinnertitle`).
|
(z. B. `cinnertitle`).
|
||||||
- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen
|
- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen
|
||||||
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen).
|
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen).
|
||||||
- Druck-Fortsetzungszeichen (`↪`) in Listings und fehlerhafte Einrückungen der Vorlage werden
|
- Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen
|
||||||
zeichengetreu übernommen.
|
(`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`).
|
||||||
- Formelbilder ohne TeX-Alternativtext bleiben Bilder.
|
- Formelbilder ohne TeX-Alternativtext bleiben Bilder.
|
||||||
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Für Bildbände gibt es
|
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Für Bildbände gibt es
|
||||||
bislang keine Testbücher.
|
bislang keine Testbücher.
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
p.add_argument("--code-language", type=code_language, default=None, metavar="SPRACHE",
|
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 "
|
help="Sprachlabel für Codeblöcke ohne erkannte Sprache, z. B. python oder c "
|
||||||
"(erkannte Labels haben Vorrang; Standard: kein Label)")
|
"(erkannte Labels haben Vorrang; Standard: kein Label)")
|
||||||
|
p.add_argument("--keep-wrap-markers", action="store_true",
|
||||||
|
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
||||||
|
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
|
||||||
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",
|
||||||
|
|
@ -114,6 +117,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
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 "",
|
code_language=args.code_language or "",
|
||||||
|
keep_wrap_markers=args.keep_wrap_markers,
|
||||||
strict=args.strict,
|
strict=args.strict,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,27 @@ def detect_language_from_content(code: str) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
WRAP_MARKER = re.compile(r"^[ \t]*[↪➥][ \t]?")
|
||||||
|
|
||||||
|
|
||||||
|
def join_wrap_markers(code: str) -> tuple[str, int]:
|
||||||
|
"""Undo print line wrapping: a line starting with ↪/➥ continues the previous line.
|
||||||
|
|
||||||
|
Publishers wrap over-long code lines at a space and mark the continuation; the logical line is
|
||||||
|
restored with exactly one space. Returns (code, number of joined lines).
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
joined = 0
|
||||||
|
for line in code.split("\n"):
|
||||||
|
m = WRAP_MARKER.match(line)
|
||||||
|
if m and out:
|
||||||
|
out[-1] = out[-1].rstrip(" \t") + " " + line[m.end():].lstrip(" \t")
|
||||||
|
joined += 1
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out), joined
|
||||||
|
|
||||||
|
|
||||||
def fence_for(code: str) -> str:
|
def fence_for(code: str) -> str:
|
||||||
longest = max((len(m) for m in re.findall(r"`+", code)), default=0)
|
longest = max((len(m) for m in re.findall(r"`+", code)), default=0)
|
||||||
return "`" * max(3, longest + 1)
|
return "`" * max(3, longest + 1)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from .codeblocks import (
|
||||||
fence_for,
|
fence_for,
|
||||||
inline_code,
|
inline_code,
|
||||||
is_block_code_container,
|
is_block_code_container,
|
||||||
|
join_wrap_markers,
|
||||||
is_inline_code,
|
is_inline_code,
|
||||||
)
|
)
|
||||||
from .diagnostics import Diagnostics
|
from .diagnostics import Diagnostics
|
||||||
|
|
@ -90,6 +91,7 @@ class RenderOptions:
|
||||||
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
|
code_language: str = "" # --code-language: label for blocks without a detected language
|
||||||
|
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
|
||||||
|
|
||||||
|
|
||||||
def md_escape(text: str, table: bool = False) -> str:
|
def md_escape(text: str, table: bool = False) -> str:
|
||||||
|
|
@ -418,6 +420,9 @@ class Renderer:
|
||||||
out = [anchors] if anchors else []
|
out = [anchors] if anchors else []
|
||||||
if not code.strip():
|
if not code.strip():
|
||||||
return out
|
return out
|
||||||
|
joined = 0
|
||||||
|
if not self.opt.keep_wrap_markers:
|
||||||
|
code, joined = join_wrap_markers(code)
|
||||||
lang, how = detect_language(el)
|
lang, how = detect_language(el)
|
||||||
if not lang:
|
if not lang:
|
||||||
lang = detect_language_from_content(code)
|
lang = detect_language_from_content(code)
|
||||||
|
|
@ -429,7 +434,7 @@ class Renderer:
|
||||||
if not lang:
|
if not lang:
|
||||||
self.ch.stats.code_blocks_unlabeled += 1
|
self.ch.stats.code_blocks_unlabeled += 1
|
||||||
self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang,
|
self.code_blocks.append({"source": self._source(el), "chapter": self.file, "language": lang,
|
||||||
"detected_by": how, "lines": code.count("\n") + 1})
|
"detected_by": how, "lines": code.count("\n") + 1, "wrap_lines_joined": joined})
|
||||||
fence = fence_for(code)
|
fence = fence_for(code)
|
||||||
out.append(f"{fence}{lang}\n{code}\n{fence}")
|
out.append(f"{fence}{lang}\n{code}\n{fence}")
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class Options:
|
||||||
slug_language: str = "de"
|
slug_language: str = "de"
|
||||||
keep_html: bool = False
|
keep_html: bool = False
|
||||||
code_language: str = ""
|
code_language: str = ""
|
||||||
|
keep_wrap_markers: bool = False
|
||||||
strict: bool = False
|
strict: bool = False
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
@ -183,7 +184,7 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
|
||||||
renderer = Renderer(
|
renderer = Renderer(
|
||||||
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
||||||
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
||||||
code_language=opts.code_language),
|
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers),
|
||||||
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)))
|
||||||
|
|
@ -195,6 +196,13 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
|
||||||
ch.markdown = renderer.render_chapter(ch)
|
ch.markdown = renderer.render_chapter(ch)
|
||||||
ch.stats.word_count = _word_count(ch.markdown)
|
ch.stats.word_count = _word_count(ch.markdown)
|
||||||
_check_chapter_size(ch, opts.chapter_level, diag)
|
_check_chapter_size(ch, opts.chapter_level, diag)
|
||||||
|
wraps: dict[str, int] = {}
|
||||||
|
for block in renderer.code_blocks:
|
||||||
|
if block["wrap_lines_joined"]:
|
||||||
|
wraps[block["chapter"]] = wraps.get(block["chapter"], 0) + block["wrap_lines_joined"]
|
||||||
|
for chapter_file, joined in wraps.items():
|
||||||
|
diag.info("wrap-markers-joined", f"{joined} Druck-Zeilenumbrüche (↪) in Codeblöcken zusammengeführt.",
|
||||||
|
chapter_file, chapter_file)
|
||||||
endnotes: Chapter | None = None
|
endnotes: Chapter | None = None
|
||||||
endnotes_md = ""
|
endnotes_md = ""
|
||||||
if opts.footnotes == "end-of-book" and renderer.book_notes:
|
if opts.footnotes == "end-of-book" and renderer.book_notes:
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,20 @@ 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")
|
run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch")
|
||||||
assert run.rc == 1
|
assert run.rc == 1
|
||||||
assert "quatsch" in run.stderr
|
assert "quatsch" in run.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_wrap_markers_are_joined(convert):
|
||||||
|
body = ("<h1>W</h1><pre> return (s * (s -\n ↪ distance(a, b)) *\n s)\n"
|
||||||
|
"x = ['a', 'b',\n↪ 'c']</pre>")
|
||||||
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
||||||
|
(_, _, code), = fenced_blocks(run.chapter("001"))
|
||||||
|
assert code == " return (s * (s - distance(a, b)) *\n s)\nx = ['a', 'b', 'c']"
|
||||||
|
item = run.report["code_blocks"]["items"][0]
|
||||||
|
assert item["wrap_lines_joined"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_wrap_markers_can_be_kept(convert):
|
||||||
|
body = "<h1>W</h1><pre>a = (1,\n↪ 2)</pre>"
|
||||||
|
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers")
|
||||||
|
(_, _, code), = fenced_blocks(run.chapter("001"))
|
||||||
|
assert code == "a = (1,\n↪ 2)"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue