150 lines
4.5 KiB
Python
150 lines
4.5 KiB
Python
"""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)
|