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" return "important"
if re.search(r"tipp|\btip|hint", h): if re.search(r"tipp|\btip|hint", h):
return "tip" return "tip"
if re.search(r"hinweis|\bnote|notiz|anmerkung|\binfo", h):
return "note"
return "" return ""

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -80,7 +81,8 @@ def book_uses_title_tables(docs: list[Doc]) -> bool:
return False 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 body = doc.body
for el in list(iter_elements(body)): for el in list(iter_elements(body)):
if not _attached(el, 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")) 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) separated = _separate_field_spans(body, hidden or (lambda el: False))
if separated: if separated:
diag.info("inline-separator-added", diag.info("inline-separator-added",
f"{separated} Leerzeichen zwischen aneinanderstoßenden Feld-Spans eingefügt.", doc.path) 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. """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 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 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 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 count = 0
for parent in iter_elements(body): 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) 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(): if len(lt.strip()) < 2 or len(rt.strip()) < 2 or lt[-1].isspace() or rt[0].isspace():
continue 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 = " " left.tail = " "
count += 1 count += 1
return count return count

View file

@ -8,6 +8,7 @@ import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -16,7 +17,7 @@ from typing import Any
from . import TOOL_NAME, __version__ from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder from .chapters import ChapterBuilder
from .diagnostics import Diagnostics 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 .epub_reader import EpubReader, decode_markup, parse_xml
from .errors import ContainerError, InputError, StructureError from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes 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") diag.step(f"EPUB {package.version}: {package.metadata.title!r}, {len(package.spine)} Spine-Dokumente")
docs = _load_docs(reader, package, diag) docs = _load_docs(reader, package, diag)
stylesheet = _load_stylesheet(reader, package, docs, diag) # before normalization drops <style> 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) notes, chapters, resolver = _build_structure(docs, package, opts, diag)
rendered = _render(reader, package, chapters, resolver, notes, opts, diag, stylesheet) 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, 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 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.""" """Normalize publisher markup and drop HTML tables of contents that NAV/NCX already cover."""
title_tables = book_uses_title_tables(docs) title_tables = book_uses_title_tables(docs)
for doc in 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} 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: for lm in package.landmarks:
if lm.type == "toc" and lm.fragment: 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 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" plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md"
assert "--lua-filter" not in plain.read_text(encoding="utf-8") 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