Compare commits

...
Author SHA1 Message Date
4b3d753b56 refactor: eigener Variablenname für Durchstreich-Marken (QA3-03)
In Renderer._inline_core stand marker erst für die Seitenmarke (str), dann
für das Paar der Durchstreich-Marken (tuple); mypy meldete den Typkonflikt.
Keine Verhaltensänderung (Ausgabe byte-identisch, mypy ohne Befund).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:09:20 +02:00
568844e8c4 fix: nur eingebundene Stylesheets auswerten (QA3-01)
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts
anderer Buchreihen. Im C-Buch (21 CSS, nur main.css verlinkt) wurden fette
Buchstaben zu Inline-Code und Registerbuchstaben zu <mark>; eine fremde
display:none-Regel konnte Text löschen.

_load_stylesheet wertet jetzt nur per <link rel="stylesheet"> eingebundene
Stylesheets (transitiv inkl. @import) und <style>-Elemente aus; nicht
eingebundene meldet die Info css-unlinked.

Test-Builder: Doc.css, Testdokumente verlinken ihr CSS wie echte EPUBs
(QA3-02). Regressionstests in tests/test_styles.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:07:49 +02:00
6 changed files with 68 additions and 16 deletions

View file

@ -29,6 +29,10 @@ Versionsnummern nach [Semantic Versioning](https://semver.org/lang/de/).
### Behoben
- CSS-Auswertung: Es zählen nur noch Stylesheets, die ein Dokument per `<link>` (oder `@import`) einbindet.
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts anderer Buchreihen. Im C-Buch
machte das aus fetten Buchstaben Inline-Code (`` `st`an`d`ard ``) und setzte falsche `<mark>`; eine
fremde `display:none`-Regel hätte Text gelöscht (QA3-01, Regressionstests in `tests/test_styles.py`).
- `pandoc/callouts.lua`: Die Kästen blieben ohne Rahmen, wenn man Pandoc zusätzlich `-H`/`--include-in-header`
übergab, und im EPUB hatten nur die Titelseite, nicht die Kapitel, das Kasten-CSS. Das CSS steht jetzt in
`pandoc/callouts.css`; der Filter setzt es bei HTML an den Anfang des Dokuments und fügt es bei EPUB den

View file

@ -249,6 +249,8 @@ weil CommonMark keine `[^…]`-Syntax kennt.
- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die
CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren,
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
Berücksichtigt werden nur Stylesheets, die ein Dokument per `<link>` (oder `@import`) einbindet;
mitgelieferte Layouts anderer Buchreihen bleiben wie im E-Book-Reader wirkungslos (Info `css-unlinked`).
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html`
bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller

View file

@ -1233,8 +1233,8 @@ class Renderer:
finally:
self.strong -= 1
if name in ("del", "s", "strike"):
marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
return pre + self._wrap(self.inline_children(el), marker[0], marker[1])
del_open, del_close = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
return pre + self._wrap(self.inline_children(el), del_open, del_close)
if name in ("u", "ins", "mark", "kbd", "sup", "sub"):
if name in ("sup", "sub"):
elems = [c for c in el if is_element(c)]

View file

@ -13,6 +13,7 @@ from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import unquote
from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder
@ -27,7 +28,7 @@ from .media import MediaRegistry
from .models import Chapter
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
from .references import LinkResolver
from .utils import is_element, is_inside, iter_elements, local, sha256_file, slugify, text_content
from .utils import is_element, is_inside, iter_elements, local, resolve_href, sha256_file, slugify, text_content
from . import writer
MARKER_FILES = ("conversion-report.json", "manifest.json")
@ -233,18 +234,39 @@ def _build_structure(docs: list[Doc], package: Any, opts: Options,
return notes, chapters, resolver
CSS_IMPORT = re.compile(r"""@import\s+(?:url\(\s*)?["']?([^"')\s;]+)""", re.I)
def _load_stylesheet(reader: EpubReader, package: Any, docs: list[Doc], diag: Diagnostics) -> StyleSheet:
"""All CSS of the book: manifest stylesheets (manifest order) plus <style> elements of the documents."""
texts: list[str] = []
for item in package.manifest.values():
if item.media_type == "text/css" or item.path.lower().endswith(".css"):
try:
texts.append(decode_markup(reader.read(item.path), item.path, diag))
except (KeyError, ContainerError):
diag.warn("css-unreadable", f"Stylesheet {item.path} nicht lesbar; seine Formatierung entfällt.", item.path)
"""The book's CSS: manifest stylesheets that a spine document links (directly or via @import), in manifest
order, plus <style> elements of the documents. Unlinked stylesheets (publishers ship the layouts of other
series) are ignored – a reading system would not apply them either."""
pending: list[str] = []
for doc in docs:
texts.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style")
return StyleSheet(texts)
for e in iter_elements(doc.root):
if local(e.tag) == "link" and "stylesheet" in (e.get("rel") or "").lower().split():
pending.append(resolve_href(doc.path, unquote((e.get("href") or "").split("#")[0].split("?")[0])) or "")
texts: dict[str, str] = {}
while pending:
path = pending.pop()
if not path or path in texts:
continue
try:
texts[path] = decode_markup(reader.read(path), path, diag)
except (KeyError, ContainerError):
diag.warn("css-unreadable", f"Stylesheet {path} nicht lesbar; seine Formatierung entfällt.", path)
texts[path] = ""
continue
pending.extend(resolve_href(path, unquote(m)) or "" for m in CSS_IMPORT.findall(texts[path]))
css_items = [i.path for i in package.manifest.values() if i.media_type == "text/css" or i.path.lower().endswith(".css")]
ordered = [texts[p] for p in css_items if p in texts] + [t for p, t in texts.items() if p not in css_items]
unlinked = len([p for p in css_items if p not in texts])
if unlinked:
diag.info("css-unlinked", f"{unlinked} Stylesheet(s) im Manifest werden von keinem Dokument eingebunden "
"und daher nicht ausgewertet.")
for doc in docs:
ordered.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style")
return StyleSheet(ordered)
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import json
import posixpath
import sys
import zipfile
from dataclasses import dataclass, field
@ -25,7 +26,7 @@ SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect wid
XHTML = """<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de">
<head><title>{title}</title></head>
<head><title>{title}</title>{head}</head>
<body>{body}</body>
</html>"""
@ -36,6 +37,7 @@ class Doc:
body: str
title: str = ""
linear: bool = True
css: list[str] | None = None # linked stylesheets (OEBPS-relative) – default: every .css in Book.media
@dataclass
@ -90,7 +92,7 @@ def build_epub(path: Path, book: Book) -> Path:
<spine{' toc="ncx"' if book.ncx else ''}>{''.join(spine)}{book.spine_extra}</spine>
</package>"""
nav_xhtml = XHTML.format(
title="Inhalt",
title="Inhalt", head="",
body='<nav epub:type="toc"><ol>' + "".join(f'<li><a href="{h}">{lbl}</a></li>' for lbl, h in nav_entries) + "</ol></nav>",
)
ncx = (
@ -114,7 +116,10 @@ def build_epub(path: Path, book: Book) -> Path:
if book.ncx:
z.writestr("OEBPS/toc.ncx", ncx)
for d in book.docs:
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body))
css = d.css if d.css is not None else [n for n in book.media if n.lower().endswith(".css")]
head = "".join(f'<link rel="stylesheet" type="text/css" href="{posixpath.relpath(c, posixpath.dirname(d.name) or ".")}"/>'
for c in css)
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, head=head, body=d.body))
for name, data in book.media.items():
z.writestr(f"OEBPS/{name}", data)
for name, data in book.extra_entries.items():

View file

@ -211,3 +211,22 @@ def test_page_marker_in_box_title_goes_before_the_box(convert):
text = convert(Book(docs=[Doc("c.xhtml", body)]), "--page-markers", "visible").chapter("001")
assert "\\[S. 62\\]\n\n> [!NOTE]\n> **Wozu Deklarationen?**" in text
assert "> Weil es \\[S. 63\\]hilft." in text # markers in the box body stay in place
def test_qa3_01_unlinked_stylesheets_are_ignored(convert):
"""Publishers ship layout CSS of other series in the manifest; only linked stylesheets apply (QA3-01)."""
css = {"main.css": b".bold { font-weight: bold; }",
"fremd.css": b"p > span.bold { font-weight: normal; font-family: monospace; } .weg { display: none; }"}
body = ('<h1>K</h1><p>Die (<span class="bold">st</span>an<span class="bold">d</span>ard '
'<span class="bold">in</span>put) und <span class="weg">sichtbarer Text</span>.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body, css=["main.css"])], media=css)).chapter("001")
assert "`" not in text # monospace rule of fremd.css must not turn bold into code
assert "**st**an**d**ard **in**put" in text
assert "sichtbarer Text" in text # display:none of fremd.css must not hide content
def test_qa3_01_imported_stylesheets_still_apply(convert):
css = {"main.css": b'@import url("extra.css");', "extra.css": b".k { font-style: italic; }"}
body = '<h1>K</h1><p><span class="k">kursiv</span> normal.</p>'
text = convert(Book(docs=[Doc("c.xhtml", body, css=["main.css"])], media=css)).chapter("001")
assert "*kursiv* normal." in text