"""Backend-agnostic traversal of the top level of a md2gost markdown document.""" from __future__ import annotations import re from abc import ABC, abstractmethod #: `(unique_name, text, with_listing)` captured from a `%id Подпись` line. PendingCaption = tuple[str | None, str | None, bool] _BIBLIO_RE = re.compile(r"^\[([^\]]+)\]:\s*(.+)$") _CONTENTS_TITLE = "СОДЕРЖАНИЕ" def caption_tuple(element) -> PendingCaption: return (element.unique_name, element.text, getattr(element, "with_listing", False)) def match_bibliography(children) -> tuple[str, str] | None: """Detect a `[key]: описание` source line, ignoring formatted spans.""" probe = "" for child in children or []: if type(child).__name__ in ("RawText", "Literal"): probe += child.children or "" m = _BIBLIO_RE.match(probe.strip()) if not m: return None return m.group(1), m.group(2).strip() def is_contents_heading(text: str) -> bool: upper = text.upper() return upper == _CONTENTS_TITLE or upper.startswith(_CONTENTS_TITLE) def parse_image_caption(image, default_id: str | None) -> tuple[str | None, str]: """Split an image title of the form `%id Подпись` into id and caption text.""" title = getattr(image, "title", None) or "" if not title.startswith("%"): return default_id, title parts = title[1:].split(None, 1) return parts[0], parts[1] if len(parts) > 1 else "" class BlockVisitor(ABC): """ Emit hooks for :func:`walk_blocks` and :func:`walk_paragraph`. Implementations own `pending_caption` and `biblio` because the concrete emitters need to read them outside the walk as well. """ pending_caption: PendingCaption | None biblio: list[tuple[str, str]] def take_caption(self) -> PendingCaption: pending = self.pending_caption if pending is None: return (None, None, False) self.pending_caption = None return pending @abstractmethod def on_heading(self, element) -> None: ... @abstractmethod def on_table(self, element, caption_id: str | None, caption_text: str | None) -> None: ... @abstractmethod def on_code(self, element, lang: str) -> None: ... @abstractmethod def on_list(self, element) -> None: ... @abstractmethod def on_equation(self, element) -> None: ... @abstractmethod def on_quote(self, element) -> None: ... @abstractmethod def on_other(self, element) -> None: ... @abstractmethod def on_image(self, image) -> None: ... @abstractmethod def on_body_text(self, children) -> None: ... def on_biblio(self, key: str, text: str) -> None: self.biblio.append((key, text)) def on_paragraph(self, element) -> None: walk_paragraph(element, self) def walk_paragraph(paragraph, visitor: BlockVisitor) -> None: """Route one paragraph to the bibliography, image or plain-text hook.""" biblio = match_bibliography(paragraph.children) if biblio is not None: visitor.on_biblio(*biblio) return children = paragraph.children or [] images = [c for c in children if type(c).__name__ == "Image"] if images: for image in images: visitor.on_image(image) visitor.on_body_text([c for c in children if type(c).__name__ != "Image"]) return visitor.on_body_text(children) def walk_blocks(children, visitor: BlockVisitor) -> None: """Dispatch every top-level element of a parsed document to `visitor`.""" for element in children: name = type(element).__name__ if name in ("BlankLine", "TOC"): continue if name == "Caption": visitor.pending_caption = caption_tuple(element) elif name == "Heading": visitor.on_heading(element) elif name == "Paragraph": visitor.on_paragraph(element) elif name == "Table": caption_id, caption_text, _ = visitor.take_caption() visitor.on_table(element, caption_id, caption_text) elif name in ("FencedCode", "CodeBlock"): visitor.on_code(element, getattr(element, "lang", "") or "") elif name == "List": visitor.on_list(element) elif name == "Equation": visitor.on_equation(element) elif name == "Quote": visitor.on_quote(element) elif hasattr(element, "children"): visitor.on_other(element)