feat: Leerzeichen zwischen aneinanderstoßenden Feld-Spans (QA R4)

Zwei <span>-Geschwister mit unterschiedlichen Klassen, ohne Text
dazwischen und mit Großbuchstabe oder Ziffer rechts bekommen ein
trennendes Leerzeichen. Initialen (D|as) und Wortteile (Haupt|bahnhof)
bleiben unberührt. Im Mathebuch werden so 49 Stellen korrigiert, die
anderen drei Beispielbücher bleiben unverändert.

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

View file

@ -343,8 +343,10 @@ Verlustfreiheit behauptet (`lossless: false`).
- Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste - Titel überschriftsloser Seiten werden aus dem Inhalt abgeleitet (Cover, Titelseite, Impressum, erste
Wörter). Das ist eine Heuristik und trifft nicht jede Seite. Wörter). Das ist eine Heuristik und trifft nicht jede Seite.
- Aneinanderstoßende Inline-Elemente, zwischen denen auch in der Quelle kein Leerraum steht, laufen - Aneinanderstoßende Feld-Spans ohne Leerraum (z. B. Autor | Titel | Reihe in Springer-Kapitelköpfen)
zusammen (z. B. Autor- und Titelzeilen in Springer-Kapitelköpfen). erhalten ein trennendes Leerzeichen. Das gilt nur bei unterschiedlichen Klassen und wenn rechts ein
Großbuchstabe oder eine Ziffer folgt. Andere direkt aneinanderstoßende Inline-Elemente bleiben
unverändert, damit Initialen und Wortteile nicht zerrissen werden.
- Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen - Fehlerhafte Einrückungen der Vorlage werden zeichengetreu übernommen. Druck-Fortsetzungszeichen
(`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`). (`↪`/`➥`) am Zeilenanfang werden dagegen standardmäßig aufgelöst (siehe `--keep-wrap-markers`).
- Formelbilder ohne TeX-Alternativtext bleiben Bilder. - Formelbilder ohne TeX-Alternativtext bleiben Bilder.

View file

@ -102,6 +102,35 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id")) doc.toc_ids.update(e.get("id") for e in iter_elements(el) if e.get("id"))
remove_keep_tail(el) remove_keep_tail(el)
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path) diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
separated = _separate_field_spans(body)
if separated:
diag.info("inline-separator-added",
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path)
def _separate_field_spans(body: Any) -> int:
"""Insert a space between adjacent class-styled <span> "fields" that touch without whitespace.
Publishers sometimes lay out metadata as sibling spans (author | title | series) separated only by
CSS. Narrow rule to keep drop caps ("D|as") and split words ("Haupt|bahnhof") intact: both spans
carry different classes, no text between them, both have >= 2 characters and the right one starts
with an uppercase letter or digit.
"""
count = 0
for parent in iter_elements(body):
children = [c for c in parent if is_element(c)]
for left, right in zip(children, children[1:]):
if local(left.tag) != "span" or local(right.tag) != "span" or left.getnext() is not right:
continue
if left.tail or not classes(left) or not classes(right) or classes(left) == classes(right):
continue
lt, rt = text_content(left), text_content(right)
if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace():
continue
if rt[0].isupper() or rt[0].isdigit():
left.tail = " "
count += 1
return count
def _attached(el: Any, body: Any) -> bool: def _attached(el: Any, body: Any) -> bool:

View file

@ -152,3 +152,15 @@ def test_titles_derived_from_content_when_no_other_source(convert):
assert "cover" in types["chapters/001-cover.md"] assert "cover" in types["chapters/001-cover.md"]
assert "titlepage" in types["chapters/002-titelseite.md"] assert "titlepage" in types["chapters/002-titelseite.md"]
assert "colophon" in types["chapters/003-impressum.md"] assert "colophon" in types["chapters/003-impressum.md"]
def test_adjacent_field_spans_get_a_separator(convert):
body = ('<h1>K</h1><div class="ContextInformation"><span class="Names">J. Liesen, V. Mehrmann</span>'
'<span class="BookTitles"><span class="BookTitle">Lineare Algebra</span></span>'
'<span class="Series">Springer Studium</span></div>'
'<p><span class="dropcap">D</span><span class="rest">as Wetter</span> war schlecht.</p>'
'<p><span class="a">Haupt</span><span class="b">bahnhof</span> und x<sub>i</sub>.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text
assert "Das Wetter war schlecht." in text
assert "Hauptbahnhof und x<sub>i</sub>." in text