v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 510f7e7adf
commit e6b58efeb3
95 changed files with 7165 additions and 8771 deletions
+42
View File
@@ -0,0 +1,42 @@
"""
Shared AST-walking building blocks for the md2gost export backends.
`md2latex` and `md2fodt` consume the same extended-markdown AST produced by
`md2gost.extended_markdown` and differ only in how each node is serialised.
This package holds the traversal and the backend-independent parsing so the
two emitters keep only their formatting code.
"""
from .blocks import (
BlockVisitor,
PendingCaption,
caption_tuple,
is_contents_heading,
match_bibliography,
parse_image_caption,
walk_blocks,
walk_paragraph,
)
from .inline import (
REFERENCE_FULL_NAMES,
InlineRenderer,
reference_full_name,
render_inline,
)
from .labels import sanitize_label
__all__ = [
"REFERENCE_FULL_NAMES",
"BlockVisitor",
"InlineRenderer",
"PendingCaption",
"caption_tuple",
"is_contents_heading",
"match_bibliography",
"parse_image_caption",
"reference_full_name",
"render_inline",
"sanitize_label",
"walk_blocks",
"walk_paragraph",
]
+149
View File
@@ -0,0 +1,149 @@
"""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)
+98
View File
@@ -0,0 +1,98 @@
"""Backend-agnostic traversal of md2gost inline AST nodes."""
from __future__ import annotations
from abc import ABC, abstractmethod
#: Wording used when a `@Тип:id` reference is spelled out, per ГОСТ 7.32.
REFERENCE_FULL_NAMES = {
"рисунок": "Рисунок",
"таблица": "Таблица",
"листинг": "Листинг",
"формула": "формула",
}
def reference_full_name(ref_type: str) -> str:
return REFERENCE_FULL_NAMES.get(ref_type.lower(), ref_type)
class InlineRenderer(ABC):
"""
Formatting hooks for :func:`render_inline`.
Defaults are provided only for the nodes every backend renders the same
way; everything else is abstract so a new backend cannot silently drop a
node type.
"""
@abstractmethod
def escape(self, text: str) -> str:
...
@abstractmethod
def code_span(self, node) -> str:
...
@abstractmethod
def emphasis(self, inner: str) -> str:
...
@abstractmethod
def strong_emphasis(self, inner: str) -> str:
...
@abstractmethod
def link(self, node, inner: str) -> str:
...
@abstractmethod
def reference(self, node) -> str:
...
@abstractmethod
def inline_equation(self, node) -> str:
...
@abstractmethod
def line_break(self) -> str:
...
def raw_text(self, text: str) -> str:
return self.escape(text)
def image(self, node) -> str:
return "" # images carry a caption, so they are emitted at block level
def unknown(self, node) -> str:
return self.escape(str(node))
def render_inline(children, renderer: InlineRenderer) -> str:
"""Render a list of inline nodes into a backend-specific string."""
parts: list[str] = []
for child in children or []:
node_type = type(child).__name__
if node_type in ("RawText", "Literal"):
parts.append(renderer.raw_text(getattr(child, "children", "") or ""))
elif node_type == "CodeSpan":
parts.append(renderer.code_span(child))
elif node_type == "Emphasis":
parts.append(renderer.emphasis(render_inline(child.children, renderer)))
elif node_type == "StrongEmphasis":
parts.append(renderer.strong_emphasis(render_inline(child.children, renderer)))
elif node_type == "Link":
parts.append(renderer.link(child, render_inline(child.children, renderer)))
elif node_type == "Reference":
parts.append(renderer.reference(child))
elif node_type == "InlineEquation":
parts.append(renderer.inline_equation(child))
elif node_type == "Image":
parts.append(renderer.image(child))
elif node_type == "LineBreak":
parts.append(renderer.line_break())
elif hasattr(child, "children"):
parts.append(render_inline(child.children, renderer))
else:
parts.append(renderer.unknown(child))
return "".join(parts)
+20
View File
@@ -0,0 +1,20 @@
"""Cross-reference slugs shared by the export backends."""
from __future__ import annotations
import re
_LABEL_RE = re.compile(r"[^\w\-]+", re.UNICODE)
def sanitize_label(name: str | None, prefix: str = "obj", separator: str = ":") -> str:
"""
Turn a `%id` caption name into a target usable as an anchor.
`separator` differs per backend: LaTeX `\\label` accepts a colon, an
XML `NCName` does not.
"""
if not name:
return prefix
cleaned = _LABEL_RE.sub("-", name.strip()).strip("-")
return f"{prefix}{separator}{cleaned or 'x'}"