diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6e6f5ae..2b68082 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -243,6 +243,20 @@ Schriftart, Kapitälchen und zentrierte oder rechtsbündige Absätze als HTML er sie vom normalen Text abweichen. Fremdsprachige Passagen werden als solche gekennzeichnet. GitHub zeigt diese Angaben nicht an. +### Seitenzahlen der Druckausgabe (zum Zitieren) + +Viele E-Books enthalten unsichtbare Marken, wo im gedruckten Buch eine neue Seite beginnt. Standardmäßig +lässt das Programm sie weg. Mit `--page-markers visible` erscheinen sie im Text als `[S. 123]`, mit +`--page-markers comment` als unsichtbarer Kommentar ``. Den findest du über die Suche, er +stört aber beim Lesen nicht. + +```bash +python3 epub2md.py roman.epub roman-md --page-markers visible +``` + +In Codebeispielen stehen keine Seitenmarken, damit der Code unverändert bleibt. Enthält das E-Book keine +Seitenmarken, bleibt die Option ohne Wirkung; im Bericht steht dann `"page_markers": 0`. + ### Buch mit vielen Fußnoten Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen: @@ -477,6 +491,7 @@ python3 epub2md_merge.py --help | `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | | `--boxes callout/quote/html/plain` | Darstellung von Infokästen | | `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) | +| `--page-markers none/visible/comment` | Seitenzahlen der Druckausgabe als `[S. 123]` oder `` | | `--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 | diff --git a/README.md b/README.md index 9f074ef..57816ac 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--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` = `
`, `plain` = normaler Text wie bisher | | `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → ``). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als ``/`
` und fremdsprachige Passagen als ``, jeweils nur, wo sie vom normalen Text des Buches abweichen. Obsidian, VS Code und Pandoc (HTML/PDF/EPUB) zeigen das an, GitHub nicht | +| `--page-markers {none,visible,comment}` | `none` | Seitenzahlen der Druckausgabe an der Stelle des Seitenwechsels: `visible` = `\[S. 123\]` im Text (zum Zitieren), `comment` = `` (unsichtbar, aber durchsuchbar), `none` = weglassen | | `--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`) | @@ -249,6 +250,11 @@ weil CommonMark keine `[^…]`-Syntax kennt. 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. +- **Seitenzahlen (`--page-markers`):** Erkannt werden `epub:type="pagebreak"`/`role="doc-pagebreak"` + (Nummer aus `aria-label`, `title` oder Text) und Seitenanker mit IDs wie `page_47`, `page37`, `seite-12` + oder – nur bei leeren ``/`` – `p17`. In Codeblöcken und Inline-Code stehen keine Marker; ein + Seitenwechsel in einer Überschrift steht als eigener Absatz davor. Die Anzahl steht im Bericht unter + `layout.page_markers`. - **Infokästen:** - **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. diff --git a/epub2md/cli.py b/epub2md/cli.py index d66c4b8..21865a3 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -93,6 +93,9 @@ def build_parser() -> argparse.ArgumentParser: help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → ); html: zusätzlich Farbe, " "Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als /" " (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)") + p.add_argument("--page-markers", choices=["none", "visible", "comment"], default="none", + help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '' " + "(unsichtbar, durchsuchbar), none = weglassen (Standard: none)") 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)") @@ -137,6 +140,7 @@ def main(argv: list[str] | None = None) -> int: css_emphasis=args.css_emphasis, boxes=args.boxes, styles=args.styles, + page_markers=args.page_markers, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 25782ec..31fc82c 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -35,6 +35,7 @@ from .footnotes import NoteIndex from .mathml import Unsupported, is_display_tex, looks_like_tex, mathml_to_tex, strip_tex_delimiters from .media import MediaRegistry from .models import Chapter, Note +from .normalize import PAGE_ATTR from .references import LinkResolver from .utils import ( MATHML_NS, @@ -100,6 +101,7 @@ class RenderOptions: keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens boxes: str = "callout" # --boxes {callout,quote,html,plain} styles: str = "semantic" # --styles {semantic,html} + page_markers: str = "none" # --page-markers {none,visible,comment}: print page numbers css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses @@ -298,6 +300,7 @@ class Renderer: self.entry_problem = entry_problem or (lambda path: None) self.css = stylesheet self.scene_breaks = 0 + self.page_markers = 0 self.poems = 0 self.boxes = 0 self.in_box = False @@ -405,6 +408,15 @@ class Renderer: def _anchor(self, name: str) -> str: return f'' + def page_marker(self, el: Any) -> str: + """Visible '[S. 12]' or '' for a print page-break element (--page-markers).""" + page = el.get(PAGE_ATTR) + if not page or self.opt.page_markers == "none": + return "" + self.page_markers += 1 + page = html.escape(page, quote=False).replace("--", "") + return f"" if self.opt.page_markers == "comment" else f"\\[S. {md_escape(page)}\\]" + def anchors_of(self, el: Any, subtree: bool = False) -> str: nodes = iter_elements(el) if subtree else [el] names = [] @@ -729,13 +741,14 @@ class Renderer: level = heading_level(el) - (ch.shift if ch else 0) level = max(self.min_level, min(6, level)) anchors = self.anchors_of(el, subtree=True) + markers = "".join(self.page_marker(e) for e in iter_elements(el)) self.in_heading = True try: text = join_inline(self.inline_children(el), hard_break=" ") finally: self.in_heading = False text = re.sub(r"", "", text).strip() - out = [] + out = [markers] if markers else [] if anchors: out.append(anchors) if not text: @@ -1154,12 +1167,15 @@ class Renderer: name = local(el.tag) anchor = self.anchors_of(el) if name not in ("a",) else "" pre: list[Piece] = [("r", anchor)] if anchor and not self.in_heading else [] + marker = self.page_marker(el) if not self.in_heading else "" + if marker: + pre.insert(0, ("r", marker)) if name == "br": return pre + ([("t", " ")] if self.in_heading else [("br", "")]) if name == "wbr": return pre if name == "a": - return self.link(el) + return pre[:1] + self.link(el) if marker else self.link(el) if name == "img": return pre + self.image(el) if name == "image": @@ -1585,6 +1601,12 @@ class Renderer: return out.strip() def _html(self, el: Any, in_pre: bool, keep: bool = False) -> str: + marker = self.page_marker(el) if not in_pre else "" + if marker.startswith("\\["): + marker = marker.replace("\\", "") # inside HTML no Markdown escapes + return marker + self._html_core(el, in_pre, keep) + + def _html_core(self, el: Any, in_pre: bool, keep: bool = False) -> str: name = local(el.tag) if name == "math": # Markdown math ($…$, \(…\)) is not rendered inside an HTML block; MathML is. @@ -1615,7 +1637,7 @@ class Renderer: if keep and re.fullmatch(r"[a-z][a-z0-9-]*", name): safe = [f'{k.rsplit("}", 1)[-1]}="{html.escape(v)}"' for k, v in el.attrib.items() if re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", k.rsplit("}", 1)[-1]) - and not k.rsplit("}", 1)[-1].lower().startswith("on") and k.rsplit("}", 1)[-1] != "style" + and not k.rsplit("}", 1)[-1].lower().startswith(("on", "data-e2m-")) and k.rsplit("}", 1)[-1] != "style" and not v.strip().lower().startswith("javascript:")] return f"<{name}{(' ' + ' '.join(safe)) if safe else ''}>{content}" if name not in HTML_KEEP_TAGS or name == "span": diff --git a/epub2md/normalize.py b/epub2md/normalize.py index 6152766..8f9dc66 100644 --- a/epub2md/normalize.py +++ b/epub2md/normalize.py @@ -15,6 +15,9 @@ HEAD_CLASS_RE = re.compile(r"^(?:head|heading|h|ueberschrift|uberschrift)[_-]?(\ TITLE_CLASS_RE = re.compile(r"^(?:title[_-]?document|chapter[_-]?title|document[_-]?number|chaptertitle)$") TOC_TITLE_RE = re.compile(r"^\s*(inhalt|inhaltsverzeichnis|inhaltsübersicht|contents|table of contents|toc)\s*$", re.I) MEDIA_TAGS = {"img", "svg", "math", "audio", "video", "object", "embed", "iframe", "image"} +PAGE_ATTR = "data-e2m-page" # print page number of a page-break marker (rendered with --page-markers) +PAGE_ID_RE = re.compile(r"^(?:page|pg|seite)[_-]?(\d+|[ivxlcdm]+)$", re.I) # id="page_47", +PAGE_ID_SHORT_RE = re.compile(r"^p[_-]?(\d+)$", re.I) # id="p17": only on empty / @dataclass @@ -88,9 +91,11 @@ def normalize_doc(doc: Doc, diag: Diagnostics, title_tables: bool) -> None: continue types = epub_types(el) if "pagebreak" in types or "doc-pagebreak" in roles(el): - _replace_with_anchor(el) + _replace_with_anchor(el, _pagebreak_label(el)) doc.pagebreaks += 1 continue + if name in ("a", "span") and not el.get("href") and (page := _page_from_id(el)): + el.set(PAGE_ATTR, page) if name == "table": _maybe_heading_table(el, title_tables) elif name in ("p", "div") and len(classes(el)) == 1 and (m := HEAD_CLASS_RE.match(classes(el)[0])): @@ -142,13 +147,35 @@ def _attached(el: Any, body: Any) -> bool: return False -def _replace_with_anchor(el: Any) -> None: +def _pagebreak_label(el: Any) -> str: + """Page number of an epub:type="pagebreak" element (aria-label, title, text or id).""" + for value in (el.get("aria-label"), el.get("title"), text_content(el)): + value = norm_ws(value or "") + if value and len(value) <= 12: + return re.sub(r"^(?:seite|page|s\.|p\.)\s*", "", value, flags=re.I) + m = PAGE_ID_RE.match(el.get("id") or "") or PAGE_ID_SHORT_RE.match(el.get("id") or "") + return m.group(1) if m else "" + + +def _page_from_id(el: Any) -> str: + el_id = el.get("id") or "" + if m := PAGE_ID_RE.match(el_id): + return m.group(1) + if (m := PAGE_ID_SHORT_RE.match(el_id)) and not norm_ws(text_content(el)) and len(el) == 0: + return m.group(1) + return "" + + +def _replace_with_anchor(el: Any, page: str = "") -> None: ids = [e.get("id") for e in iter_elements(el) if e.get("id")] - if not ids: + if not ids and not page: remove_keep_tail(el) return anchor = _make(el, "a") - anchor.set("id", ids[0]) + if ids: + anchor.set("id", ids[0]) + if page: + anchor.set(PAGE_ATTR, page) anchor.tail = el.tail el.getparent().replace(el, anchor) @@ -177,6 +204,7 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None: level = max(1, min(6, level)) texts = [] ids = [e.get("id") for e in iter_elements(table) if e.get("id")] + pages = {e.get("id"): page for e in iter_elements(table) if local(e.tag) in ("a", "span") and (page := _page_from_id(e))} for cell in cells: parts = [] for node in cell.iter(): @@ -193,6 +221,8 @@ def _maybe_heading_table(table: Any, title_tables: bool) -> None: for anchor_id in ids: a = _make(table, "a") a.set("id", anchor_id) + if anchor_id in pages: + a.set(PAGE_ATTR, pages[anchor_id]) heading.append(a) if len(heading): heading[-1].tail = " ".join(texts) diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 551a61e..940be21 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -52,6 +52,7 @@ class Options: css_emphasis: bool = True boxes: str = "callout" styles: str = "semantic" + page_markers: str = "none" strict: bool = False verbose: bool = False dry_run: bool = False @@ -257,7 +258,7 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver: extract_media=opts.extract_media, keep_html=opts.keep_html, language=language, code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers, keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis, - boxes=opts.boxes, styles=opts.styles), + boxes=opts.boxes, styles=opts.styles, page_markers=opts.page_markers), 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, @@ -522,7 +523,8 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O "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}, - "layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems, "boxes": renderer.boxes}, + "layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems, "boxes": renderer.boxes, + "page_markers": renderer.page_markers}, "warnings": warnings, "errors": errors, "infos": [d.as_dict() for d in diag.items if d.level == "info"], diff --git a/tests/test_styles.py b/tests/test_styles.py index ff23373..dc3ca05 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -162,3 +162,25 @@ def test_p3_p7_html_styles(convert): assert '
\n\nWidmung\n\n
' 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 + + +PAGES = ('

K

Ende Seite 89.