feat: Seitenzahlen der Druckausgabe per --page-markers (P5)

- Erkennung: epub:type/role pagebreak (aria-label, title, Text) sowie
  Seitenanker-IDs page_47, page37, seite-12 und p17 (nur leere a/span)
- Ausgabe: visible = \[S. 123\], comment = <!-- S. 123 -->, none (Standard)
- keine Marker in Code; Marker in Überschriften als Absatz davor;
  Layout-Tabellen-Überschriften behalten ihre Seitenmarke
- Bericht: layout.page_markers; interne data-e2m-*-Attribute nie im HTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 05:10:38 +02:00
commit 16e2d186a4
7 changed files with 110 additions and 9 deletions

View file

@ -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 `<!-- S. 123 -->`. 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 `<!-- S. 123 -->` |
| `--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 |

View file

@ -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` = `<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 |
| `--page-markers {none,visible,comment}` | `none` | Seitenzahlen der Druckausgabe an der Stelle des Seitenwechsels: `visible` = `\[S. 123\]` im Text (zum Zitieren), `comment` = `<!-- S. 123 -->` (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 `<a>`/`<span>` – `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.

View file

@ -93,6 +93,9 @@ def build_parser() -> argparse.ArgumentParser:
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("--page-markers", choices=["none", "visible", "comment"], default="none",
help="Seitenzahlen der Druckausgabe: visible = '[S. 123]' im Text, comment = '<!-- S. 123 -->' "
"(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,

View file

@ -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'<a id="{name}"></a>'
def page_marker(self, el: Any) -> str:
"""Visible '[S. 12]' or '<!-- S. 12 -->' 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"<!-- S. {page} -->" 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"<a id=\"[^\"]*\"></a>", "", 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}</{name}>"
if name not in HTML_KEEP_TAGS or name == "span":

View file

@ -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", <span id="page37">
PAGE_ID_SHORT_RE = re.compile(r"^p[_-]?(\d+)$", re.I) # id="p17": only on empty <a>/<span>
@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")
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)

View file

@ -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"],

View file

@ -162,3 +162,25 @@ def test_p3_p7_html_styles(convert):
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
PAGES = ('<h1>K</h1><p>Ende Seite 89.</p><div aria-label="90" epub:type="pagebreak" id="page90" role="doc-pagebreak"/>'
'<p>Text<a id="page_12"/> weiter, <span id="page_13">Wort</span> und<a id="p14"/> fort.</p>'
'<p id="p15">Absatz mit Absatz-ID.</p><h2><a id="page_16"/>Kapitelanfang</h2><p>x</p><pre><a id="page_17"/>code</pre>'
'<table class="Head2"><tr><td class="sec_num"><a id="page_18"/>1.2</td><td>Tabellentitel</td></tr></table>')
def test_p5_page_markers(convert):
none = convert(Book(docs=[Doc("c.xhtml", PAGES)])).chapter("001")
assert "S. 90" not in none and "S. 12" not in none
vis = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "visible")
text = vis.chapter("001")
assert "Ende Seite 89.\n\n\\[S. 90\\]" in text
assert "Text\\[S. 12\\] weiter, \\[S. 13\\]Wort und\\[S. 14\\] fort." in text
assert "S. 15" not in text # paragraph id, not a page
assert "\\[S. 16\\]\n\n## Kapitelanfang" in text # marker before the heading, not inside it
assert "```\ncode\n```" in text # nothing inside code
assert "\\[S. 18\\]\n\n## 1.2 Tabellentitel" in text # layout-table heading keeps the page
assert vis.report["layout"]["page_markers"] == 6
com = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "comment").chapter("001")
assert "<!-- S. 90 -->" in com and "Text<!-- S. 12 --> weiter" in com