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>
This commit is contained in:
Dieter Schlüter 2026-10-06 21:07:49 +02:00
commit 568844e8c4
2 changed files with 66 additions and 14 deletions

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