v0.5.3
- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'}"
|
||||
Reference in New Issue
Block a user