@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.
'
+ 'Text weiter, Wort und fort.
'
+ 'Absatz mit Absatz-ID.
Kapitelanfang
x
code
'
+ '')
+
+
+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 "" in com and "Text weiter" in com