feat: Farben, Schriften, Größen und Sprache per --styles (P3, P7)
--styles semantic (Standard): Hintergrundmarkierung wird zu <mark>, sonst nur bedeutungstragende Gestaltung. --styles html: Farbe, Hintergrund, Schriftgröße/-art, Kapitälchen und Ausrichtung bleiben als <span style>/<div style> erhalten, fremdsprachige Passagen als <span lang>. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller Absätze, Schriftgröße ab ±15 %); Formel- und Bildabsätze bekommen keine Ausrichtung; unsichere Werte werden verworfen. Gilt im Fließtext, für Absätze und in HTML-Tabellen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4992672ca6
commit
5cef9d7885
7 changed files with 227 additions and 8 deletions
|
|
@ -228,6 +228,21 @@ Andere Darstellungen wählst du mit `--boxes`:
|
||||||
- `html`: HTML-`<div>`
|
- `html`: HTML-`<div>`
|
||||||
- `plain`: wie normaler Text
|
- `plain`: wie normaler Text
|
||||||
|
|
||||||
|
### Farben, Schriftarten und Schriftgrößen
|
||||||
|
|
||||||
|
Markdown kennt keine Farben, Schriftarten oder Schriftgrößen. Standardmäßig übernimmt das Programm daher
|
||||||
|
nur, was eine Bedeutung trägt:
|
||||||
|
|
||||||
|
- **fett** und *kursiv*, auch wenn das Buch sie nur über sein Stylesheet setzt
|
||||||
|
- unterstrichen, hoch- und tiefgestellt
|
||||||
|
- farbig hinterlegter Text als `<mark>`-Markierung
|
||||||
|
|
||||||
|
Möchtest du das Aussehen möglichst erhalten, etwa für eine HTML-, EPUB- oder PDF-Ausgabe mit Pandoc oder zum
|
||||||
|
Lesen in Obsidian, wandle mit `--styles html` um. Dann bleiben Farbe, Hintergrund, Schriftgröße,
|
||||||
|
Schriftart, Kapitälchen und zentrierte oder rechtsbündige Absätze als HTML erhalten. Das gilt nur dort, wo
|
||||||
|
sie vom normalen Text abweichen. Fremdsprachige Passagen werden als solche gekennzeichnet. GitHub zeigt
|
||||||
|
diese Angaben nicht an.
|
||||||
|
|
||||||
### Buch mit vielen Fußnoten
|
### Buch mit vielen Fußnoten
|
||||||
|
|
||||||
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
|
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
|
||||||
|
|
@ -461,6 +476,7 @@ python3 epub2md_merge.py --help
|
||||||
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
||||||
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
||||||
| `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
|
| `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
|
||||||
|
| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) |
|
||||||
| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches |
|
| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches |
|
||||||
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
||||||
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
|
||||||
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
|
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
|
||||||
| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
|
| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
|
||||||
| `--boxes {callout,quote,html,plain}` | `callout` | Infokästen (Tipp, Hinweis, Warnung, Exkurs, Merke …): `callout` = `> [!TIP]` mit fettem Titel (GitHub-Alerts, Obsidian-Callouts; anderswo ein Zitatblock), `quote` = Zitatblock mit fettem Titel, `html` = `<div class="box box-tip">`, `plain` = normaler Text wie bisher |
|
| `--boxes {callout,quote,html,plain}` | `callout` | Infokästen (Tipp, Hinweis, Warnung, Exkurs, Merke …): `callout` = `> [!TIP]` mit fettem Titel (GitHub-Alerts, Obsidian-Callouts; anderswo ein Zitatblock), `quote` = Zitatblock mit fettem Titel, `html` = `<div class="box box-tip">`, `plain` = normaler Text wie bisher |
|
||||||
|
| `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → `<mark>`). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als `<span style>`/`<div style>` und fremdsprachige Passagen als `<span lang>`, jeweils nur, wo sie vom normalen Text des Buches abweichen. Obsidian, VS Code und Pandoc (HTML/PDF/EPUB) zeigen das an, GitHub nicht |
|
||||||
| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css` |
|
| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css` |
|
||||||
| `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert |
|
| `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert |
|
||||||
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
|
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
|
||||||
|
|
@ -244,6 +245,10 @@ weil CommonMark keine `[^…]`-Syntax kennt.
|
||||||
CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren,
|
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.
|
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
|
||||||
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
|
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
|
||||||
|
Absätze; Schriftgröße erst ab ±15 %). Abgesetzte Formeln und Bildabsätze bekommen keine Ausrichtung. Werte
|
||||||
|
mit `url(...)`, `expression` usw. werden verworfen.
|
||||||
- **Infokästen:**
|
- **Infokästen:**
|
||||||
- **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis,
|
- **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis,
|
||||||
warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext.
|
warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext.
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
|
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
|
||||||
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
|
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
|
||||||
"(Standard: callout)")
|
"(Standard: callout)")
|
||||||
|
p.add_argument("--styles", choices=["semantic", "html"], default="semantic",
|
||||||
|
help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → <mark>); html: zusätzlich Farbe, "
|
||||||
|
"Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als <span style>/"
|
||||||
|
"<span lang> (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)")
|
||||||
p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True,
|
p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True,
|
||||||
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
|
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
|
||||||
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
|
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
|
||||||
|
|
@ -132,6 +136,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
keep_invisible_chars=args.keep_invisible_chars,
|
keep_invisible_chars=args.keep_invisible_chars,
|
||||||
css_emphasis=args.css_emphasis,
|
css_emphasis=args.css_emphasis,
|
||||||
boxes=args.boxes,
|
boxes=args.boxes,
|
||||||
|
styles=args.styles,
|
||||||
strict=args.strict,
|
strict=args.strict,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,95 @@ def emphasis(style: dict[str, str]) -> dict[str, bool]:
|
||||||
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
|
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
|
||||||
"hidden": style.get("display", "").lower() == "none",
|
"hidden": style.get("display", "").lower() == "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- visual styles (--styles html)
|
||||||
|
|
||||||
|
STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align")
|
||||||
|
UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I)
|
||||||
|
TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$")
|
||||||
|
COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I)
|
||||||
|
KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5,
|
||||||
|
"xx-large": 2.0, "smaller": 0.83, "larger": 1.2}
|
||||||
|
|
||||||
|
|
||||||
|
def _font_size_em(value: str) -> float | None:
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in KEYWORD_SIZES:
|
||||||
|
return KEYWORD_SIZES[v]
|
||||||
|
m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n = float(m.group(1))
|
||||||
|
return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _background_color(style: dict[str, str]) -> str:
|
||||||
|
bg = style.get("background-color", "")
|
||||||
|
if not bg and style.get("background"):
|
||||||
|
for tok in COLOR_TOKEN.findall(style["background"]):
|
||||||
|
if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right",
|
||||||
|
"bottom", "url"):
|
||||||
|
bg = tok
|
||||||
|
break
|
||||||
|
bg = bg.strip()
|
||||||
|
return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg
|
||||||
|
|
||||||
|
|
||||||
|
def _family(value: str) -> str:
|
||||||
|
parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()]
|
||||||
|
return ", ".join(f"'{p}'" if " " in p else p for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_black(value: str) -> bool:
|
||||||
|
return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext")
|
||||||
|
|
||||||
|
|
||||||
|
def has_highlight(style: dict[str, str]) -> bool:
|
||||||
|
return bool(_background_color(style))
|
||||||
|
|
||||||
|
|
||||||
|
def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]:
|
||||||
|
"""Most frequent value per visual property over the book's paragraphs (the 'normal' text style)."""
|
||||||
|
counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS}
|
||||||
|
for el in paragraphs:
|
||||||
|
style = stylesheet.computed(el)
|
||||||
|
for prop in STYLE_PROPS:
|
||||||
|
value = _background_color(style) if prop == "background-color" else style.get(prop, "")
|
||||||
|
counters[prop][value] = counters[prop].get(value, 0) + 1
|
||||||
|
return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c}
|
||||||
|
|
||||||
|
|
||||||
|
def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]:
|
||||||
|
"""Visual properties of ``style`` that differ from the book's base text style (sanitized)."""
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for prop in STYLE_PROPS:
|
||||||
|
if prop == "text-align" and not block:
|
||||||
|
continue
|
||||||
|
value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip()
|
||||||
|
if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"):
|
||||||
|
continue
|
||||||
|
reference = base.get(prop, "")
|
||||||
|
if prop == "color" and not reference:
|
||||||
|
reference = "black" # no colour declared = default text colour
|
||||||
|
if prop == "color" and _is_black(value) and _is_black(reference):
|
||||||
|
continue
|
||||||
|
if prop == "font-size":
|
||||||
|
size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0
|
||||||
|
if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15:
|
||||||
|
continue
|
||||||
|
elif prop == "font-family":
|
||||||
|
if _family(value).lower() == _family(reference).lower():
|
||||||
|
continue
|
||||||
|
value = _family(value)
|
||||||
|
elif prop == "font-variant":
|
||||||
|
if "small-caps" not in value.lower() or "small-caps" in reference.lower():
|
||||||
|
continue
|
||||||
|
value = "small-caps"
|
||||||
|
elif prop == "text-align":
|
||||||
|
if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower():
|
||||||
|
continue
|
||||||
|
elif value.lower() == reference.lower():
|
||||||
|
continue
|
||||||
|
out.append((prop, value))
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from urllib.parse import unquote
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from .chapters import IdCarrier, Marker, heading_level
|
from .chapters import IdCarrier, Marker, heading_level
|
||||||
from .css import StyleSheet, emphasis, inline_style
|
from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props
|
||||||
from .codeblocks import (
|
from .codeblocks import (
|
||||||
code_text,
|
code_text,
|
||||||
detect_language,
|
detect_language,
|
||||||
|
|
@ -99,6 +99,7 @@ class RenderOptions:
|
||||||
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
|
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
|
||||||
keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens
|
keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens
|
||||||
boxes: str = "callout" # --boxes {callout,quote,html,plain}
|
boxes: str = "callout" # --boxes {callout,quote,html,plain}
|
||||||
|
styles: str = "semantic" # --styles {semantic,html}
|
||||||
css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses
|
css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -228,6 +229,10 @@ BOLD_TAGS = {"strong", "b"}
|
||||||
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
|
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
|
||||||
|
|
||||||
|
|
||||||
|
def _style_attr(props: list[tuple[str, str]]) -> str:
|
||||||
|
return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'")
|
||||||
|
|
||||||
|
|
||||||
def _wrap_text(text: str, marker: str) -> str:
|
def _wrap_text(text: str, marker: str) -> str:
|
||||||
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
|
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
|
||||||
core = text.strip()
|
core = text.strip()
|
||||||
|
|
@ -288,7 +293,8 @@ class Renderer:
|
||||||
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
|
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
|
||||||
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
|
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
|
||||||
entry_problem: Callable[[str], str | None] | None = None,
|
entry_problem: Callable[[str], str | None] | None = None,
|
||||||
stylesheet: StyleSheet | None = None) -> None:
|
stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None:
|
||||||
|
self.base_style = base_style or {}
|
||||||
self.entry_problem = entry_problem or (lambda path: None)
|
self.entry_problem = entry_problem or (lambda path: None)
|
||||||
self.css = stylesheet
|
self.css = stylesheet
|
||||||
self.scene_breaks = 0
|
self.scene_breaks = 0
|
||||||
|
|
@ -684,8 +690,9 @@ class Renderer:
|
||||||
if flag:
|
if flag:
|
||||||
blocks[0] = _wrap_text(blocks[0], marker)
|
blocks[0] = _wrap_text(blocks[0], marker)
|
||||||
self.css_applied[key] += 1
|
self.css_applied[key] += 1
|
||||||
|
blocks = self._block_styles(el, blocks)
|
||||||
if anchor:
|
if anchor:
|
||||||
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
|
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "<div", "> ", "- ", "$$", "\\[", "* * *")) \
|
||||||
and not re.match(r"\d+\. ", blocks[0]):
|
and not re.match(r"\d+\. ", blocks[0]):
|
||||||
blocks[0] = anchor + blocks[0]
|
blocks[0] = anchor + blocks[0]
|
||||||
else:
|
else:
|
||||||
|
|
@ -1057,14 +1064,61 @@ class Renderer:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def inline(self, el: Any) -> list[Piece]:
|
def inline(self, el: Any) -> list[Piece]:
|
||||||
"""Inline rendering plus formatting that only the stylesheet expresses (P1)."""
|
"""Inline rendering plus formatting that only the stylesheet expresses (P1, P3, P7)."""
|
||||||
name = local(el.tag)
|
name = local(el.tag)
|
||||||
if not self.opt.css_emphasis or name in CSS_SKIP_TAGS:
|
if name in CSS_SKIP_TAGS:
|
||||||
return self._inline_core(el)
|
return self._inline_core(el)
|
||||||
if self._hidden(el):
|
if self._hidden(el):
|
||||||
return []
|
return []
|
||||||
if self.in_heading or is_inline_code(el) or not text_content(el).strip():
|
if self.in_heading or is_inline_code(el) or not text_content(el).strip():
|
||||||
return self._inline_core(el)
|
return self._inline_core(el)
|
||||||
|
pieces = self._inline_emphasis(el) if self.opt.css_emphasis else self._inline_core(el)
|
||||||
|
return self._inline_styles(el, pieces)
|
||||||
|
|
||||||
|
def _inline_styles(self, el: Any, pieces: list[Piece]) -> list[Piece]:
|
||||||
|
"""--styles semantic: background highlight → <mark>; --styles html: deviating visual properties and
|
||||||
|
foreign-language spans as <span style>/<span lang>."""
|
||||||
|
if self.css is None or (pieces and pieces[0][0] == "r" and pieces[0][1].startswith("`")):
|
||||||
|
return pieces # no stylesheet, or rendered as inline code
|
||||||
|
style = self.css.computed(el)
|
||||||
|
if self.opt.styles == "html":
|
||||||
|
props = style_props(style, self.base_style)
|
||||||
|
if props:
|
||||||
|
pieces = self._wrap(pieces, f'<span style="{_style_attr(props)}">', "</span>")
|
||||||
|
self.css_applied["style"] += 1
|
||||||
|
lang = self._foreign_lang(el)
|
||||||
|
if lang:
|
||||||
|
pieces = self._wrap(pieces, f'<span lang="{html.escape(lang)}">', "</span>")
|
||||||
|
self.css_applied["lang"] += 1
|
||||||
|
elif has_highlight(style):
|
||||||
|
pieces = self._wrap(pieces, "<mark>", "</mark>")
|
||||||
|
self.css_applied["mark"] += 1
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
def _foreign_lang(self, el: Any) -> str:
|
||||||
|
lang = (el.get("lang") or el.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip()
|
||||||
|
if lang and re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*", lang) \
|
||||||
|
and lang.split("-")[0].lower() != self.opt.language.split("-")[0].lower():
|
||||||
|
return lang
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _block_styles(self, el: Any, blocks: list[str]) -> list[str]:
|
||||||
|
"""--styles html: paragraph alignment, colour, size, font and language as a wrapping <div>."""
|
||||||
|
if self.opt.styles != "html" or self.css is None or self.in_table or not blocks or local(el.tag) not in ("p", "div") \
|
||||||
|
or any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el):
|
||||||
|
return blocks
|
||||||
|
if all(b.startswith(("$$", "\\[", "![", "<img", "<math", "<a id")) for b in blocks):
|
||||||
|
return blocks # display formulas and image paragraphs are set apart anyway
|
||||||
|
props = style_props(self.css.computed(el), self.base_style, block=True)
|
||||||
|
lang = self._foreign_lang(el)
|
||||||
|
if not props and not lang:
|
||||||
|
return blocks
|
||||||
|
attrs = (f' style="{_style_attr(props)}"' if props else "") + (f' lang="{html.escape(lang)}"' if lang else "")
|
||||||
|
self.css_applied["style" if props else "lang"] += 1
|
||||||
|
return [f"<div{attrs}>"] + blocks + ["</div>"]
|
||||||
|
|
||||||
|
def _inline_emphasis(self, el: Any) -> list[Piece]:
|
||||||
|
name = local(el.tag)
|
||||||
e = self._emphasis(el)
|
e = self._emphasis(el)
|
||||||
if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \
|
if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \
|
||||||
and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el):
|
and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el):
|
||||||
|
|
@ -1577,6 +1631,15 @@ class Renderer:
|
||||||
if flag:
|
if flag:
|
||||||
content = f"<{tag}>{content}</{tag}>"
|
content = f"<{tag}>{content}</{tag}>"
|
||||||
self.css_applied[key] += 1
|
self.css_applied[key] += 1
|
||||||
|
if self.css is not None and content.strip():
|
||||||
|
style = self.css.computed(el)
|
||||||
|
props = style_props(style, self.base_style) if self.opt.styles == "html" else []
|
||||||
|
if props:
|
||||||
|
content = f'<span style="{_style_attr(props)}">{content}</span>'
|
||||||
|
self.css_applied["style"] += 1
|
||||||
|
elif self.opt.styles != "html" and has_highlight(style):
|
||||||
|
content = f"<mark>{content}</mark>"
|
||||||
|
self.css_applied["mark"] += 1
|
||||||
return anchor + content
|
return anchor + content
|
||||||
attrs = []
|
attrs = []
|
||||||
for k, v in el.attrib.items():
|
for k, v in el.attrib.items():
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,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
|
from .css import StyleSheet, base_style
|
||||||
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
|
||||||
|
|
@ -26,7 +26,7 @@ from .media import MediaRegistry
|
||||||
from .models import Chapter
|
from .models import Chapter
|
||||||
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
|
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
|
||||||
from .references import LinkResolver
|
from .references import LinkResolver
|
||||||
from .utils import is_inside, iter_elements, local, sha256_file, slugify, text_content
|
from .utils import is_element, is_inside, iter_elements, local, sha256_file, slugify, text_content
|
||||||
from . import writer
|
from . import writer
|
||||||
|
|
||||||
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
||||||
|
|
@ -51,6 +51,7 @@ class Options:
|
||||||
keep_invisible_chars: bool = False
|
keep_invisible_chars: bool = False
|
||||||
css_emphasis: bool = True
|
css_emphasis: bool = True
|
||||||
boxes: str = "callout"
|
boxes: str = "callout"
|
||||||
|
styles: str = "semantic"
|
||||||
strict: bool = False
|
strict: bool = False
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
@ -256,8 +257,10 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
|
||||||
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
||||||
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
|
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
|
||||||
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis,
|
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis,
|
||||||
boxes=opts.boxes),
|
boxes=opts.boxes, styles=opts.styles),
|
||||||
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
|
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
|
||||||
|
base_style(stylesheet, [p for c in chapters for _, it in c.items if is_element(it)
|
||||||
|
for p in iter_elements(it) if local(p.tag) == "p"][:20000]) if stylesheet else None,
|
||||||
)
|
)
|
||||||
width = max(3, len(str(len(chapters) + 1)))
|
width = max(3, len(str(len(chapters) + 1)))
|
||||||
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
|
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
|
||||||
|
|
|
||||||
|
|
@ -127,3 +127,38 @@ def test_p2_box_modes(convert):
|
||||||
assert '<div class="box box-tip">\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n</div>' in htm
|
assert '<div class="box box-tip">\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n</div>' in htm
|
||||||
plain = boxed(body, "--boxes", "plain", convert=convert).chapter("001")
|
plain = boxed(body, "--boxes", "plain", convert=convert).chapter("001")
|
||||||
assert "## Hinweis zum Pinsel" in plain and "Sauber halten." in plain and ">" not in plain.split("---")[-1]
|
assert "## Hinweis zum Pinsel" in plain and "Sauber halten." in plain and ">" not in plain.split("---")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
STYLE_CSS = b"""p { font-family: "Aldus", serif; font-size: 0.9em; color: #000 }
|
||||||
|
.rot { color: #942f6a } .gelb { background-color: #ffff00 } .gross { font-size: 1.5em }
|
||||||
|
.kap { font-variant: small-caps } p.mitte { text-align: center } .script { font-family: "Mulled Wine", cursive }"""
|
||||||
|
|
||||||
|
|
||||||
|
def styles_book(body: str, *args: str, convert=None):
|
||||||
|
return convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": STYLE_CSS}), *args)
|
||||||
|
|
||||||
|
|
||||||
|
BODY_STYLES = ('<h1>S</h1><p>Normal <span class="rot">rot</span>, <span class="gelb">markiert</span>, '
|
||||||
|
'<span class="gross">groß</span>, <span class="kap">Kapitälchen</span>, <span class="script">Zierschrift</span>, '
|
||||||
|
'<span lang="en">English words</span> und <span lang="de">deutsch</span>.</p>'
|
||||||
|
'<p>Zweiter Absatz.</p><p>Dritter Absatz.</p><p class="mitte">Widmung</p>')
|
||||||
|
|
||||||
|
|
||||||
|
def test_p3_semantic_styles_default(convert):
|
||||||
|
text = styles_book(BODY_STYLES, convert=convert).chapter("001")
|
||||||
|
assert "Normal rot, <mark>markiert</mark>, groß, Kapitälchen, Zierschrift, English words und deutsch." in text
|
||||||
|
assert "style=" not in text and "lang=" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_p3_p7_html_styles(convert):
|
||||||
|
run = styles_book(BODY_STYLES, "--styles", "html", convert=convert)
|
||||||
|
text = run.chapter("001")
|
||||||
|
assert '<span style="color: #942f6a">rot</span>' in text
|
||||||
|
assert '<span style="background-color: #ffff00">markiert</span>' in text
|
||||||
|
assert '<span style="font-size: 1.5em">groß</span>' in text
|
||||||
|
assert '<span style="font-variant: small-caps">Kapitälchen</span>' in text
|
||||||
|
assert "<span style=\"font-family: 'Mulled Wine', cursive\">Zierschrift</span>" in text
|
||||||
|
assert '<span lang="en">English words</span> und deutsch.' in text
|
||||||
|
assert '<div style="text-align: center">\n\nWidmung\n\n</div>' in text
|
||||||
|
assert "Zweiter Absatz." in text and "Aldus" not in text # base paragraph style is not repeated
|
||||||
|
assert run.report["css"]["applied"]["style"] >= 5
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue