fix: Kastentyp aus Verlagsklasse, kein Feld-Trenner vor ausgeblendeten Spans

- _box_type erkennt hinweis/note/notiz/anmerkung/info als NOTE; damit
  gewinnt die Klasse "box hinweis" gegen ein Typwort im Titel ("Wichtige …")
- _separate_field_spans setzt kein Leerzeichen, wenn einer der Spans per CSS
  display:none ist ("<u>x</u> ." → "<u>x</u>.")
- gefunden mit der neuen QA-Fixture 14-typography (QA-Matrix jetzt 85 Fälle,
  inkl. epub2md_merge.py und Pandoc-Filter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 05:36:25 +02:00
commit e8fd6c2fb9
4 changed files with 25 additions and 9 deletions

View file

@ -221,6 +221,8 @@ def _box_type(hint: str) -> str:
return "important"
if re.search(r"tipp|\btip|hint", h):
return "tip"
if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h):
return "note"
return ""

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@ -80,7 +81,8 @@ def book_uses_title_tables(docs: list[Doc]) -> bool:
return False
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None:
def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool,
hidden: Callable[[Any], bool] | None = None) -> None:
body = doc.body
for el in list(iter_elements(body)):
if not _attached(el, body):
@ -107,19 +109,19 @@ 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"))
remove_keep_tail(el)
diag.info("html-toc-removed", "HTML-Inhaltsverzeichnis entfernt (ersetzt durch README.md).", doc.path)
separated = _separate_field_spans(body)
separated = _separate_field_spans(body, hidden or (lambda el: False))
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:
def _separate_field_spans(body: Any, hidden: Callable[[Any], bool]) -> 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.
with an uppercase letter or digit. Spans hidden by the book's CSS (display:none) get no separator.
"""
count = 0
for parent in iter_elements(body):
@ -132,7 +134,7 @@ def _separate_field_spans(body: Any) -> int:
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():
if (rt[0].isupper() or rt[0].isdigit()) and not hidden(left) and not hidden(right):
left.tail = " "
count += 1
return count

View file

@ -8,6 +8,7 @@ import re
import shutil
import sys
import tempfile
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
@ -16,7 +17,7 @@ from typing import Any
from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder
from .diagnostics import Diagnostics
from .css import StyleSheet, base_style
from .css import StyleSheet, base_style, emphasis
from .epub_reader import EpubReader, decode_markup, parse_xml
from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes
@ -140,7 +141,8 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
docs = _load_docs(reader, package, diag)
stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops <style>
_prepare_docs(docs, package, diag)
hidden = (lambda el: emphasis(stylesheet.computed(el))["hidden"]) if opts.css_emphasis else None
_prepare_docs(docs, package, diag, hidden)
notes, chapters, resolver = _build_structure(docs, package, opts, diag)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag, stylesheet)
files, all_chapters, report = _assemble(input_path, output_dir, opts, diag, generated_at, sha, package, docs,
@ -184,11 +186,12 @@ class _Rendered:
skipped_formula_images: int
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics) -> None:
def _prepare_docs(docs: list[Doc], package: Any, diag: Diagnostics,
hidden: Callable[[Any], bool] | None = None) -> None:
"""Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
title_tables = book_uses_title_tables(docs)
for doc in docs:
normalize_doc(doc, diag, title_tables)
normalize_doc(doc, diag, title_tables, hidden)
landmark_toc_docs = {lm.path for lm in package.landmarks if lm.type == "toc" and lm.path and not lm.fragment}
for lm in package.landmarks:
if lm.type == "toc" and lm.fragment:

View file

@ -192,3 +192,12 @@ def test_book_readme_suggests_callout_filter(convert):
assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme
plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md"
assert "--lua-filter" not in plain.read_text(encoding="utf-8")
def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert):
css = b".u { text-decoration: underline; } .weg { display: none; }"
body = ('<h1>K</h1><p><span class="u">unterstrichen</span><span class="weg">VERSTECKT</span>.</p>'
'<div class="box hinweis"><h3 class="boxheading">Wichtige Regel</h3><p>Immer testen.</p></div>')
text = convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": css})).chapter("001")
assert "<u>unterstrichen</u>." in text and "VERSTECKT" not in text
assert "> [!NOTE]\n> **Wichtige Regel**" in text # class 'hinweis' beats the word in the title