diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6b942ec..afb00f6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -434,8 +434,6 @@ python3 epub2md_merge.py --help | `--chapter-level N` | ab welcher Überschriftenebene eine neue Datei beginnt (Standard 1) | | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--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 | | `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | diff --git a/README.md b/README.md index 92d572f..f48bfdd 100644 --- a/README.md +++ b/README.md @@ -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 | | `--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`) | -| `--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`) | | `--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) | @@ -239,10 +237,6 @@ weil CommonMark keine `[^…]`-Syntax kennt. (`#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 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). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne diff --git a/epub2md/chapters.py b/epub2md/chapters.py index 0b16de3..5845852 100644 --- a/epub2md/chapters.py +++ b/epub2md/chapters.py @@ -9,17 +9,7 @@ from typing import Any from .diagnostics import Diagnostics from .models import Chapter, NavEntry from .normalize import Doc, has_content -from .utils import ( - classes, - epub_types, - is_element, - iter_elements, - local, - norm_ws, - remove_invisible, - roles, - text_content, -) +from .utils import classes, epub_types, is_element, iter_elements, local, norm_ws, roles, text_content 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"} @@ -280,7 +270,6 @@ class ChapterBuilder: if not doc.linear and all(not d or d == doc.path for d in ch.docs[:1]): types.append("auxiliary") ch.types = list(dict.fromkeys(types)) - ch.title = remove_invisible(ch.title)[0] # titles feed file names, frontmatter and README return ch def _title_from_content(self, items: list[tuple[str, Any]], number: int) -> tuple[str, str]: diff --git a/epub2md/cli.py b/epub2md/cli.py index 0555128..4679493 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -85,12 +85,6 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--keep-wrap-markers", action="store_true", help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte " "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", help="Bei kritischen Fehlern abbrechen: fehlende/unlesbare Spine-Dokumente, nicht dekodierbares " "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, code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, - keep_invisible_chars=args.keep_invisible_chars, - css_emphasis=args.css_emphasis, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/css.py b/epub2md/css.py deleted file mode 100644 index 528afa4..0000000 --- a/epub2md/css.py +++ /dev/null @@ -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", - } diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index a053fb8..68c0c4d 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,7 +9,6 @@ from __future__ import annotations import html import re -from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -18,7 +17,6 @@ from urllib.parse import unquote from lxml import etree from .chapters import IdCarrier, Marker, heading_level -from .css import StyleSheet, emphasis, inline_style from .codeblocks import ( code_text, detect_language, @@ -47,7 +45,6 @@ from .utils import ( local, norm_ws, rel_link, - remove_invisible, resolve_href, text_content, url_path, @@ -96,8 +93,6 @@ class RenderOptions: language: str = "de" 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_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: @@ -129,23 +124,9 @@ def escape_line_starts(text: str) -> str: 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: 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: - 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 (xy): 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": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): @@ -195,17 +176,6 @@ def _collapse_blank_lines(text: str) -> str: 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 = ("```", "~~~", "|", "