Compare commits
9 changed files with 13 additions and 458 deletions
|
|
@ -434,8 +434,6 @@ python3 epub2md_merge.py --help
|
||||||
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
|
| `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) |
|
||||||
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
||||||
| `--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 |
|
|
||||||
| `--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 |
|
||||||
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
| `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme |
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,6 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
|
||||||
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
|
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
|
||||||
| `--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`) |
|
||||||
| `--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-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`) |
|
||||||
| `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) |
|
| `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) |
|
||||||
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
|
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
|
||||||
|
|
@ -239,10 +237,6 @@ weil CommonMark keine `[^…]`-Syntax kennt.
|
||||||
(`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen
|
(`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen
|
||||||
Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft
|
Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft
|
||||||
auch Programmausgaben und Shell-Befehle im Buch.
|
auch Programmausgaben und Shell-Befehle im Buch.
|
||||||
- **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.
|
|
||||||
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
|
|
||||||
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
|
- **Gescannte Texte:** nur Bilder, kein Text (keine OCR).
|
||||||
- **DRM:** wird erkannt und abgelehnt.
|
- **DRM:** wird erkannt und abgelehnt.
|
||||||
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
|
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,7 @@ from typing import Any
|
||||||
from .diagnostics import Diagnostics
|
from .diagnostics import Diagnostics
|
||||||
from .models import Chapter, NavEntry
|
from .models import Chapter, NavEntry
|
||||||
from .normalize import Doc, has_content
|
from .normalize import Doc, has_content
|
||||||
from .utils import (
|
from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content
|
||||||
classes,
|
|
||||||
epub_types,
|
|
||||||
is_element,
|
|
||||||
iter_elements,
|
|
||||||
local,
|
|
||||||
norm_ws,
|
|
||||||
remove_invisible,
|
|
||||||
roles,
|
|
||||||
text_content,
|
|
||||||
)
|
|
||||||
|
|
||||||
HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
|
HEADINGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
|
||||||
CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"}
|
CONTAINERS = {"body", "div", "section", "article", "aside", "main", "header", "footer", "nav", "span", "hgroup"}
|
||||||
|
|
@ -280,7 +270,6 @@ class ChapterBuilder:
|
||||||
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
|
if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]):
|
||||||
types.append("auxiliary")
|
types.append("auxiliary")
|
||||||
ch.types = list(dict.fromkeys(types))
|
ch.types = list(dict.fromkeys(types))
|
||||||
ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README
|
|
||||||
return ch
|
return ch
|
||||||
|
|
||||||
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
|
def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]:
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
p.add_argument("--keep-wrap-markers", action="store_true",
|
p.add_argument("--keep-wrap-markers", action="store_true",
|
||||||
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
||||||
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
|
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
|
||||||
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 "
|
|
||||||
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
|
|
||||||
p.add_argument("--keep-invisible-chars", action="store_true",
|
|
||||||
help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen "
|
|
||||||
"(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert")
|
|
||||||
p.add_argument("--strict", action="store_true",
|
p.add_argument("--strict", action="store_true",
|
||||||
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
|
help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares "
|
||||||
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
|
"XHTML, unsichere ZIP-Pfade, übersprungene (verdächtige) Medien")
|
||||||
|
|
@ -125,8 +119,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
keep_html=args.keep_html,
|
keep_html=args.keep_html,
|
||||||
code_language=args.code_language or "",
|
code_language=args.code_language or "",
|
||||||
keep_wrap_markers=args.keep_wrap_markers,
|
keep_wrap_markers=args.keep_wrap_markers,
|
||||||
keep_invisible_chars=args.keep_invisible_chars,
|
|
||||||
css_emphasis=args.css_emphasis,
|
|
||||||
strict=args.strict,
|
strict=args.strict,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
194
epub2md/css.py
194
epub2md/css.py
|
|
@ -1,194 +0,0 @@
|
||||||
"""Minimal CSS evaluation for formatting that publishers express only through stylesheets.
|
|
||||||
|
|
||||||
Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source
|
|
||||||
order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators
|
|
||||||
are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …)
|
|
||||||
are skipped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .utils import classes, local
|
|
||||||
|
|
||||||
COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$")
|
|
||||||
MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|"
|
|
||||||
r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Compound:
|
|
||||||
tag: str | None
|
|
||||||
classes: frozenset[str]
|
|
||||||
ids: frozenset[str]
|
|
||||||
|
|
||||||
def matches(self, el: Any) -> bool:
|
|
||||||
if self.tag and self.tag != "*" and local(el.tag) != self.tag:
|
|
||||||
return False
|
|
||||||
if self.ids and (el.get("id") or "").lower() not in self.ids:
|
|
||||||
return False
|
|
||||||
return not self.classes or self.classes <= set(classes(el))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Rule:
|
|
||||||
parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is ""
|
|
||||||
specificity: tuple[int, int, int]
|
|
||||||
order: int
|
|
||||||
decls: dict[str, str]
|
|
||||||
|
|
||||||
def matches(self, el: Any) -> bool:
|
|
||||||
if not self.parts[-1][1].matches(el):
|
|
||||||
return False
|
|
||||||
node = el
|
|
||||||
for i in range(len(self.parts) - 1, 0, -1):
|
|
||||||
comb = self.parts[i][0]
|
|
||||||
compound = self.parts[i - 1][1]
|
|
||||||
node = node.getparent()
|
|
||||||
if comb == ">":
|
|
||||||
if node is None or not compound.matches(node):
|
|
||||||
return False
|
|
||||||
else: # descendant
|
|
||||||
while node is not None and not compound.matches(node):
|
|
||||||
node = node.getparent()
|
|
||||||
if node is None:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_declarations(text: str) -> dict[str, str]:
|
|
||||||
decls: dict[str, str] = {}
|
|
||||||
for part in text.split(";"):
|
|
||||||
if ":" not in part:
|
|
||||||
continue
|
|
||||||
prop, value = part.split(":", 1)
|
|
||||||
prop = prop.strip().lower()
|
|
||||||
value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip()
|
|
||||||
if prop and value:
|
|
||||||
decls[prop] = value
|
|
||||||
return decls
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None:
|
|
||||||
sel = sel.strip().lower()
|
|
||||||
if not sel or re.search(r"[:\[\]+~]", sel):
|
|
||||||
return None
|
|
||||||
tokens = re.split(r"\s*(>)\s*|\s+", sel)
|
|
||||||
parts: list[tuple[str, _Compound]] = []
|
|
||||||
comb = ""
|
|
||||||
for tok in tokens:
|
|
||||||
if tok is None or tok == "":
|
|
||||||
continue
|
|
||||||
if tok == ">":
|
|
||||||
comb = ">"
|
|
||||||
continue
|
|
||||||
m = COMPOUND.match(tok)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
rest = m.group(2) or ""
|
|
||||||
parts.append((comb if parts else "", _Compound(
|
|
||||||
tag=m.group(1),
|
|
||||||
classes=frozenset(re.findall(r"\.([\w-]+)", rest)),
|
|
||||||
ids=frozenset(re.findall(r"#([\w-]+)", rest)),
|
|
||||||
)))
|
|
||||||
comb = " "
|
|
||||||
return parts or None
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_at_blocks(css: str) -> str:
|
|
||||||
"""Remove @-rules including nested blocks (@media, @font-face, @supports, @page …)."""
|
|
||||||
out: list[str] = []
|
|
||||||
i = 0
|
|
||||||
while i < len(css):
|
|
||||||
if css[i] == "@":
|
|
||||||
brace = css.find("{", i)
|
|
||||||
semi = css.find(";", i)
|
|
||||||
if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …;
|
|
||||||
i = semi + 1
|
|
||||||
continue
|
|
||||||
if brace == -1:
|
|
||||||
break
|
|
||||||
depth, j = 0, brace
|
|
||||||
while j < len(css):
|
|
||||||
if css[j] == "{":
|
|
||||||
depth += 1
|
|
||||||
elif css[j] == "}":
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0:
|
|
||||||
break
|
|
||||||
j += 1
|
|
||||||
i = j + 1
|
|
||||||
continue
|
|
||||||
out.append(css[i])
|
|
||||||
i += 1
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
class StyleSheet:
|
|
||||||
def __init__(self, css_texts: list[str]) -> None:
|
|
||||||
self.rules: list[_Rule] = []
|
|
||||||
self._index: dict[str, list[_Rule]] = {}
|
|
||||||
self._cache: dict[Any, dict[str, str]] = {}
|
|
||||||
order = 0
|
|
||||||
for css in css_texts:
|
|
||||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
|
||||||
css = _strip_at_blocks(css)
|
|
||||||
for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
|
|
||||||
decls = _parse_declarations(body)
|
|
||||||
if not decls:
|
|
||||||
continue
|
|
||||||
for sel in selectors.split(","):
|
|
||||||
parts = _parse_selector(sel)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts),
|
|
||||||
sum(1 for _, c in parts if c.tag and c.tag != "*"))
|
|
||||||
rule = _Rule(parts, spec, order, decls)
|
|
||||||
order += 1
|
|
||||||
self.rules.append(rule)
|
|
||||||
last = parts[-1][1]
|
|
||||||
keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"])
|
|
||||||
for key in keys[:1]: # one index entry suffices: every key must match anyway
|
|
||||||
self._index.setdefault(key, []).append(rule)
|
|
||||||
|
|
||||||
def computed(self, el: Any) -> dict[str, str]:
|
|
||||||
"""Declared properties for ``el`` (no inheritance), including its ``style`` attribute."""
|
|
||||||
cached = self._cache.get(el)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
candidates: list[_Rule] = []
|
|
||||||
for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]:
|
|
||||||
candidates.extend(self._index.get(key, ()))
|
|
||||||
result: dict[str, str] = {}
|
|
||||||
for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)):
|
|
||||||
if rule.matches(el):
|
|
||||||
result.update(rule.decls)
|
|
||||||
if el.get("style"):
|
|
||||||
result.update(_parse_declarations(el.get("style")))
|
|
||||||
self._cache[el] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def inline_style(el: Any) -> dict[str, str]:
|
|
||||||
"""Only the ``style`` attribute (used when no stylesheet is available)."""
|
|
||||||
return _parse_declarations(el.get("style") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def emphasis(style: dict[str, str]) -> dict[str, bool]:
|
|
||||||
"""Semantic formatting expressed by a computed style."""
|
|
||||||
weight = style.get("font-weight", "").lower()
|
|
||||||
deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower()
|
|
||||||
valign = style.get("vertical-align", "").lower()
|
|
||||||
return {
|
|
||||||
"bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600),
|
|
||||||
"italic": style.get("font-style", "").lower() in ("italic", "oblique"),
|
|
||||||
"underline": "underline" in deco,
|
|
||||||
"strike": "line-through" in deco,
|
|
||||||
"sup": valign == "super",
|
|
||||||
"sub": valign == "sub",
|
|
||||||
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
|
|
||||||
"hidden": style.get("display", "").lower() == "none",
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -18,7 +17,6 @@ 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 .codeblocks import (
|
from .codeblocks import (
|
||||||
code_text,
|
code_text,
|
||||||
detect_language,
|
detect_language,
|
||||||
|
|
@ -47,7 +45,6 @@ from .utils import (
|
||||||
local,
|
local,
|
||||||
norm_ws,
|
norm_ws,
|
||||||
rel_link,
|
rel_link,
|
||||||
remove_invisible,
|
|
||||||
resolve_href,
|
resolve_href,
|
||||||
text_content,
|
text_content,
|
||||||
url_path,
|
url_path,
|
||||||
|
|
@ -96,8 +93,6 @@ class RenderOptions:
|
||||||
language: str = "de"
|
language: str = "de"
|
||||||
code_language: str = "" # --code-language: label for blocks without a detected language
|
code_language: str = "" # --code-language: label for blocks without a detected language
|
||||||
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
|
|
||||||
css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses
|
|
||||||
|
|
||||||
|
|
||||||
def md_escape(text: str, table: bool = False) -> str:
|
def md_escape(text: str, table: bool = False) -> str:
|
||||||
|
|
@ -129,23 +124,9 @@ def escape_line_starts(text: str) -> str:
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece
|
|
||||||
|
|
||||||
|
|
||||||
def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str:
|
def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str:
|
||||||
out = ""
|
out = ""
|
||||||
last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis
|
|
||||||
for kind, s in pieces:
|
for kind, s in pieces:
|
||||||
if kind == "e":
|
|
||||||
open_, core, close = s.split(EMPH_SEP)
|
|
||||||
if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close:
|
|
||||||
# directly adjacent identical emphasis (<em>x</em><em>y</em>): merge, "*x**y*" would break
|
|
||||||
out = out[: last_e[0]]
|
|
||||||
core = last_e[2] + core
|
|
||||||
start = len(out)
|
|
||||||
out += open_ + core + close
|
|
||||||
last_e = (start, open_, core, close, len(out))
|
|
||||||
continue
|
|
||||||
if kind == "t":
|
if kind == "t":
|
||||||
s = WS.sub(" ", s)
|
s = WS.sub(" ", s)
|
||||||
if not out or out.endswith((" ", "\n")):
|
if not out or out.endswith((" ", "\n")):
|
||||||
|
|
@ -195,17 +176,6 @@ def _collapse_blank_lines(text: str) -> str:
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
|
|
||||||
BOLD_TAGS = {"strong", "b"}
|
|
||||||
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_text(text: str, marker: str) -> str:
|
|
||||||
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
|
|
||||||
core = text.strip()
|
|
||||||
return f"{marker}{core}{marker}" if core else text
|
|
||||||
|
|
||||||
|
|
||||||
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
|
_BLOCK_SYNTAX = ("```", "~~~", "|", "<table", "- ", "> ", "#", "$$", "\\[", "* * *")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -259,13 +229,8 @@ def serialize_mathml(math: Any) -> str:
|
||||||
class Renderer:
|
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) -> None:
|
||||||
stylesheet: StyleSheet | None = None) -> None:
|
|
||||||
self.entry_problem = entry_problem or (lambda path: None)
|
self.entry_problem = entry_problem or (lambda path: None)
|
||||||
self.css = stylesheet
|
|
||||||
self.css_applied: Counter[str] = Counter()
|
|
||||||
self.hidden_elements = 0
|
|
||||||
self.invisible_removed = 0
|
|
||||||
self.opt = options
|
self.opt = options
|
||||||
self.zip_names = zip_names
|
self.zip_names = zip_names
|
||||||
self.in_table_html = False
|
self.in_table_html = False
|
||||||
|
|
@ -328,7 +293,7 @@ class Renderer:
|
||||||
if doc:
|
if doc:
|
||||||
self.doc = doc
|
self.doc = doc
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
buf.append(("t", self._text(item)))
|
buf.append(("t", md_escape(item, self.in_table)))
|
||||||
elif isinstance(item, IdCarrier):
|
elif isinstance(item, IdCarrier):
|
||||||
anchor = self.resolver.anchor_for(self.doc, item.element)
|
anchor = self.resolver.anchor_for(self.doc, item.element)
|
||||||
if anchor:
|
if anchor:
|
||||||
|
|
@ -396,8 +361,6 @@ class Renderer:
|
||||||
|
|
||||||
def block(self, el: Any) -> list[str]:
|
def block(self, el: Any) -> list[str]:
|
||||||
name = local(el.tag)
|
name = local(el.tag)
|
||||||
if self._hidden(el):
|
|
||||||
return []
|
|
||||||
ch = self.ch
|
ch = self.ch
|
||||||
if ch is not None and el is ch.title_el and not heading_level(el):
|
if ch is not None and el is ch.title_el and not heading_level(el):
|
||||||
return self.heading(el, forced_level=1)
|
return self.heading(el, forced_level=1)
|
||||||
|
|
@ -438,24 +401,7 @@ class Renderer:
|
||||||
special = self._index_block(el, anchor)
|
special = self._index_block(el, anchor)
|
||||||
if special is not None:
|
if special is not None:
|
||||||
return special
|
return special
|
||||||
italic = bold = False
|
|
||||||
if self.opt.css_emphasis and local(el.tag) in ("p", "div") and not self.in_heading \
|
|
||||||
and not any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el) \
|
|
||||||
and text_content(el).strip():
|
|
||||||
e = self._emphasis(el)
|
|
||||||
italic, bold = e["italic"] and not self.em, e["bold"] and not self.strong
|
|
||||||
self.em += italic
|
|
||||||
self.strong += bold
|
|
||||||
try:
|
|
||||||
blocks = self.flow(el)
|
blocks = self.flow(el)
|
||||||
finally:
|
|
||||||
self.em -= italic
|
|
||||||
self.strong -= bold
|
|
||||||
if (italic or bold) and len(blocks) == 1 and not _is_block_syntax(blocks[0]) and not blocks[0].startswith("<"):
|
|
||||||
for flag, marker, key in ((italic, "*", "italic"), (bold, "**", "bold")):
|
|
||||||
if flag:
|
|
||||||
blocks[0] = _wrap_text(blocks[0], marker)
|
|
||||||
self.css_applied[key] += 1
|
|
||||||
if anchor:
|
if anchor:
|
||||||
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
|
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \
|
||||||
and not re.match(r"\d+\. ", blocks[0]):
|
and not re.match(r"\d+\. ", blocks[0]):
|
||||||
|
|
@ -809,66 +755,15 @@ class Renderer:
|
||||||
def inline_children(self, el: Any) -> list[Piece]:
|
def inline_children(self, el: Any) -> list[Piece]:
|
||||||
pieces: list[Piece] = []
|
pieces: list[Piece] = []
|
||||||
if el.text:
|
if el.text:
|
||||||
pieces.append(("t", self._text(el.text)))
|
pieces.append(("t", md_escape(el.text, self.in_table)))
|
||||||
for child in el:
|
for child in el:
|
||||||
if is_element(child):
|
if is_element(child):
|
||||||
pieces.extend(self.inline(child))
|
pieces.extend(self.inline(child))
|
||||||
if child.tail:
|
if child.tail:
|
||||||
pieces.append(("t", self._text(child.tail)))
|
pieces.append(("t", md_escape(child.tail, self.in_table)))
|
||||||
return pieces
|
return pieces
|
||||||
|
|
||||||
def _emphasis(self, el: Any) -> dict[str, bool]:
|
|
||||||
style = self.css.computed(el) if self.css is not None else inline_style(el)
|
|
||||||
return emphasis(style)
|
|
||||||
|
|
||||||
def _hidden(self, el: Any) -> bool:
|
|
||||||
"""display:none in the book's CSS: readers do not show the element, so neither do we."""
|
|
||||||
if not self.opt.css_emphasis or not is_element(el) or not self._emphasis(el)["hidden"]:
|
|
||||||
return False
|
|
||||||
self.hidden_elements += 1
|
|
||||||
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)."""
|
|
||||||
name = local(el.tag)
|
|
||||||
if not self.opt.css_emphasis or name in CSS_SKIP_TAGS:
|
|
||||||
return self._inline_core(el)
|
|
||||||
if self._hidden(el):
|
|
||||||
return []
|
|
||||||
if self.in_heading or is_inline_code(el) or not text_content(el).strip():
|
|
||||||
return self._inline_core(el)
|
|
||||||
e = self._emphasis(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):
|
|
||||||
self.css_applied["code"] += 1
|
|
||||||
anchor = self.anchors_of(el)
|
|
||||||
return ([("r", anchor)] if anchor else []) + [("r", inline_code(norm_ws(text_content(el))))]
|
|
||||||
has_link = name == "a" or any(local(d.tag) == "a" for d in iter_elements(el))
|
|
||||||
italic = e["italic"] and name not in ITALIC_TAGS and not self.em
|
|
||||||
bold = e["bold"] and name not in BOLD_TAGS and not self.strong
|
|
||||||
strike_marker = ("~~", "~~") if self.opt.format == "gfm" else ("<del>", "</del>")
|
|
||||||
wrappers = [
|
|
||||||
(e["sup"] and name != "sup", "<sup>", "</sup>", "sup"),
|
|
||||||
(e["sub"] and name != "sub", "<sub>", "</sub>", "sub"),
|
|
||||||
(e["underline"] and name not in ("u", "ins") and not has_link, "<u>", "</u>", "underline"),
|
|
||||||
(e["strike"] and name not in ("del", "s", "strike"), strike_marker[0], strike_marker[1], "strike"),
|
|
||||||
(italic, "*", "*", "italic"),
|
|
||||||
(bold, "**", "**", "bold"),
|
|
||||||
]
|
|
||||||
self.em += italic
|
|
||||||
self.strong += bold
|
|
||||||
try:
|
|
||||||
pieces = self._inline_core(el)
|
|
||||||
finally:
|
|
||||||
self.em -= italic
|
|
||||||
self.strong -= bold
|
|
||||||
for flag, open_, close, key in wrappers:
|
|
||||||
if flag:
|
|
||||||
pieces = self._wrap(pieces, open_, close)
|
|
||||||
self.css_applied[key] += 1
|
|
||||||
return pieces
|
|
||||||
|
|
||||||
def _inline_core(self, el: Any) -> list[Piece]:
|
|
||||||
name = local(el.tag)
|
name = local(el.tag)
|
||||||
anchor = self.anchors_of(el) if name not in ("a",) else ""
|
anchor = self.anchors_of(el) if name not in ("a",) else ""
|
||||||
pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else []
|
pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else []
|
||||||
|
|
@ -946,7 +841,7 @@ class Renderer:
|
||||||
core = core[: -len(hard)].rstrip()
|
core = core[: -len(hard)].rstrip()
|
||||||
if not core:
|
if not core:
|
||||||
return [("t", lead or trail)] if (lead or trail) else []
|
return [("t", lead or trail)] if (lead or trail) else []
|
||||||
return [("t", lead), ("e", f"{open_}{EMPH_SEP}{core}{EMPH_SEP}{close}"), ("t", trail)]
|
return [("t", lead), ("r", f"{open_}{core}{close}"), ("t", trail)]
|
||||||
|
|
||||||
# ------------------------------------------------------------------ links
|
# ------------------------------------------------------------------ links
|
||||||
|
|
||||||
|
|
@ -1151,16 +1046,7 @@ class Renderer:
|
||||||
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
|
title_part = f' "{title.replace(chr(34), chr(39))}"' if title else ""
|
||||||
return [("r", f")}{title_part})")]
|
return [("r", f")}{title_part})")]
|
||||||
|
|
||||||
def _text(self, text: str) -> str:
|
|
||||||
"""Escape prose text; zero-width characters and soft hyphens are dropped (P6)."""
|
|
||||||
if not self.opt.keep_invisible_chars:
|
|
||||||
text, n = remove_invisible(text)
|
|
||||||
self.invisible_removed += n
|
|
||||||
return md_escape(text, self.in_table)
|
|
||||||
|
|
||||||
def _alt(self, alt: str) -> str:
|
def _alt(self, alt: str) -> str:
|
||||||
if not self.opt.keep_invisible_chars:
|
|
||||||
alt = remove_invisible(alt)[0]
|
|
||||||
return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip()
|
return WS.sub(" ", alt).replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").strip()
|
||||||
|
|
||||||
def _is_formula_image(self, el: Any, alt: str, path: str) -> bool:
|
def _is_formula_image(self, el: Any, alt: str, path: str) -> bool:
|
||||||
|
|
@ -1338,17 +1224,6 @@ class Renderer:
|
||||||
return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>"
|
return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}</{name}>"
|
||||||
if name not in HTML_KEEP_TAGS or name == "span":
|
if name not in HTML_KEEP_TAGS or name == "span":
|
||||||
anchor = self.anchors_of(el)
|
anchor = self.anchors_of(el)
|
||||||
if self.opt.css_emphasis and content.strip():
|
|
||||||
e = self._emphasis(el)
|
|
||||||
if e["hidden"]:
|
|
||||||
self.hidden_elements += 1
|
|
||||||
return ""
|
|
||||||
for flag, tag, key in ((e["italic"], "em", "italic"), (e["bold"], "strong", "bold"),
|
|
||||||
(e["underline"] and "<a " not in content, "u", "underline"),
|
|
||||||
(e["strike"], "del", "strike"), (e["sup"], "sup", "sup"), (e["sub"], "sub", "sub")):
|
|
||||||
if flag:
|
|
||||||
content = f"<{tag}>{content}</{tag}>"
|
|
||||||
self.css_applied[key] += 1
|
|
||||||
return anchor + content
|
return anchor + content
|
||||||
attrs = []
|
attrs = []
|
||||||
for k, v in el.attrib.items():
|
for k, v in el.attrib.items():
|
||||||
|
|
|
||||||
|
|
@ -16,8 +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 .epub_reader import EpubReader, 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
|
||||||
from .html_converter import RenderOptions, Renderer
|
from .html_converter import RenderOptions, Renderer
|
||||||
|
|
@ -26,7 +25,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_inside, iter_elements, sha256_file, slugify
|
||||||
from . import writer
|
from . import writer
|
||||||
|
|
||||||
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
MARKER_FILES = ("conversion-report.json", "manifest.json")
|
||||||
|
|
@ -48,8 +47,6 @@ class Options:
|
||||||
keep_html: bool = False
|
keep_html: bool = False
|
||||||
code_language: str = ""
|
code_language: str = ""
|
||||||
keep_wrap_markers: bool = False
|
keep_wrap_markers: bool = False
|
||||||
keep_invisible_chars: bool = False
|
|
||||||
css_emphasis: bool = True
|
|
||||||
strict: bool = False
|
strict: bool = False
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
@ -136,10 +133,9 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
|
||||||
package = reader.load_package()
|
package = reader.load_package()
|
||||||
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>
|
|
||||||
_prepare_docs(docs, package, diag)
|
_prepare_docs(docs, package, diag)
|
||||||
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)
|
||||||
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,
|
||||||
notes, chapters, resolver, rendered)
|
notes, chapters, resolver, rendered)
|
||||||
if opts.dry_run:
|
if opts.dry_run:
|
||||||
|
|
@ -227,22 +223,8 @@ def _build_structure(docs: list[Doc], package: Any, opts: Options,
|
||||||
return notes, chapters, resolver
|
return notes, chapters, resolver
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
for doc in docs:
|
|
||||||
texts.extend(text_content(e) for e in iter_elements(doc.root) if local(e.tag) == "style")
|
|
||||||
return StyleSheet(texts)
|
|
||||||
|
|
||||||
|
|
||||||
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
|
def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: LinkResolver, notes: Any,
|
||||||
opts: Options, diag: Diagnostics, stylesheet: StyleSheet | None = None) -> _Rendered:
|
opts: Options, diag: Diagnostics) -> _Rendered:
|
||||||
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
|
"""Render all chapters (and the end-of-book notes file) to Markdown and register media."""
|
||||||
md = package.metadata
|
md = package.metadata
|
||||||
media = MediaRegistry(package, opts.images, opts.slug_language)
|
media = MediaRegistry(package, opts.images, opts.slug_language)
|
||||||
|
|
@ -253,9 +235,8 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
|
||||||
renderer = Renderer(
|
renderer = Renderer(
|
||||||
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
||||||
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),
|
resolver, media, notes, diag, set(reader.names), reader.entry_problem,
|
||||||
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
|
|
||||||
)
|
)
|
||||||
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"
|
||||||
|
|
@ -514,9 +495,6 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O
|
||||||
"referenced": sum(1 for n in notes.notes.values() if n.ref_count),
|
"referenced": sum(1 for n in notes.notes.values() if n.ref_count),
|
||||||
"rendered": len(renderer.book_notes)},
|
"rendered": len(renderer.book_notes)},
|
||||||
"special_sections": special,
|
"special_sections": special,
|
||||||
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
|
|
||||||
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
|
|
||||||
"hidden_elements": renderer.hidden_elements},
|
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
||||||
|
|
|
||||||
|
|
@ -185,15 +185,6 @@ def text_content(el: Any) -> str:
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
INVISIBLE = re.compile("[\u200b\u200c\u200d\u2060\ufeff\u00ad]") # zero-width chars, BOM, soft hyphen
|
|
||||||
|
|
||||||
|
|
||||||
def remove_invisible(text: str) -> tuple[str, int]:
|
|
||||||
"""Remove zero-width characters and soft hyphens; returns (text, number removed)."""
|
|
||||||
cleaned, n = INVISIBLE.subn("", text)
|
|
||||||
return cleaned, n
|
|
||||||
|
|
||||||
|
|
||||||
def norm_ws(text: str) -> str:
|
def norm_ws(text: str) -> str:
|
||||||
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()
|
return re.sub(r"[ \t\r\n\f]+", " ", text or "").strip()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
"""Typography and layout: invisible characters, CSS emphasis, scene breaks, boxes, styles, page markers."""
|
|
||||||
|
|
||||||
from conftest import Book, Doc
|
|
||||||
|
|
||||||
ZW, SHY = "", ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_p6_invisible_characters_removed_outside_code(convert):
|
|
||||||
body = (f"<h1>Kapitel 1.{ZW}1</h1><p>Lese{SHY}rinnen und 1.{ZW}1 sowie{ZW}Wort.</p>"
|
|
||||||
f"<pre>a{ZW}b</pre><p><code>x{SHY}y</code></p>")
|
|
||||||
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
||||||
text = run.chapter("001")
|
|
||||||
assert "Leserinnen und 1.1 sowieWort." in text
|
|
||||||
assert "# Kapitel 1.1" in text and 'chapter_title: "Kapitel 1.1"' in text
|
|
||||||
assert f"a{ZW}b" in text and f"x{SHY}y" in text # code stays verbatim
|
|
||||||
assert run.report["text_cleanup"]["invisible_chars_removed"] == 4
|
|
||||||
kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-invisible-chars").chapter("001")
|
|
||||||
assert f"Lese{SHY}rinnen" in kept
|
|
||||||
|
|
||||||
|
|
||||||
CSS = b""".i { font-style: italic } .b { font-weight: bold } span.u { text-decoration: underline }
|
|
||||||
.s { text-decoration: line-through } .hoch { vertical-align: super } .weg { display: none }
|
|
||||||
li p.first > span.b { font-weight: normal; font-family: monospace } .schrift-c { font-family: "Courier New", monospace }
|
|
||||||
p.zitat { font-style: italic } h2.t { font-weight: bold; font-style: italic } .url { text-decoration: underline }
|
|
||||||
@media amzn-kf8 { .i { font-style: normal } }"""
|
|
||||||
|
|
||||||
|
|
||||||
def styled(body: str, *args: str, convert=None):
|
|
||||||
return convert(Book(docs=[Doc("c.xhtml", body)], media={"style.css": CSS}), *args)
|
|
||||||
|
|
||||||
|
|
||||||
def test_p1_css_classes_become_markdown_emphasis(convert):
|
|
||||||
body = ('<h1>K</h1><p>Ein <span class="i">kursives</span> und <span class="b">fettes</span> Wort, '
|
|
||||||
'<span class="u">unterstrichen</span>, <span class="s">gestrichen</span>, x<span class="hoch">2</span>, '
|
|
||||||
'<span class="weg">[»] </span>sichtbar, <span class="schrift-c">printf</span> und '
|
|
||||||
'<span class="schrift-c">zwei Wörter</span>, <span style="font-weight:700">inline</span>, '
|
|
||||||
'<span class="url"><a href="https://example.org">example.org</a></span>.</p>'
|
|
||||||
'<ul><li><p class="first"><span class="b">nicht fett im Listenkopf</span></p></li></ul>'
|
|
||||||
'<p class="zitat">Ein ganzer kursiver Absatz mit <em>Betonung</em>.</p>'
|
|
||||||
'<h2 class="t">Überschrift bleibt</h2><p><em class="i">nur einmal</em></p><pre><span class="b">code</span></pre>')
|
|
||||||
run = styled(body, convert=convert)
|
|
||||||
text = run.chapter("001")
|
|
||||||
assert "Ein *kursives* und **fettes** Wort, <u>unterstrichen</u>, ~~gestrichen~~, x<sup>2</sup>, sichtbar," in text
|
|
||||||
assert "`printf` und zwei Wörter, **inline**, [example.org](https://example.org)." in text
|
|
||||||
assert "[»]" not in text and "\\[»\\]" not in text
|
|
||||||
assert "- nicht fett im Listenkopf" in text
|
|
||||||
assert "*Ein ganzer kursiver Absatz mit Betonung.*" in text
|
|
||||||
assert "## Überschrift bleibt" in text and "*nur einmal*" in text and "**nur einmal**" not in text
|
|
||||||
assert "```\ncode\n```" in text
|
|
||||||
assert run.report["css"]["hidden_elements"] == 1
|
|
||||||
assert run.report["css"]["applied"]["italic"] >= 2
|
|
||||||
plain = styled(body, "--no-css-emphasis", convert=convert).chapter("001")
|
|
||||||
assert "Ein kursives und fettes Wort" in plain
|
|
||||||
|
|
||||||
|
|
||||||
def test_p1_css_emphasis_inside_html_tables(convert):
|
|
||||||
body = ('<h1>T</h1><table><tr><td colspan="2"><span class="i">kursiv</span> und <span class="b">fett</span></td></tr>'
|
|
||||||
'<tr><td>a</td><td>b</td></tr></table>')
|
|
||||||
text = styled(body, convert=convert).chapter("001")
|
|
||||||
assert "<em>kursiv</em> und <strong>fett</strong>" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjacent_emphasis_is_merged(convert):
|
|
||||||
body = ('<h1>K</h1><p>Fläche (A=r<sup>2</sup><span class="i">×</span><span class="i">π</span>) und '
|
|
||||||
'<em>x</em><em>y</em> sowie <strong>a</strong><strong>b</strong> und <em>c</em> <em>d</em>.</p>')
|
|
||||||
text = styled(body, convert=convert).chapter("001")
|
|
||||||
assert "<sup>2</sup>*×π*" in text
|
|
||||||
assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text
|
|
||||||
Loading…
Reference in a new issue