[ADD] поддержка изображений и рефакторинг диспетчера токенов

- registry.py: реестры BLOCK/PATTERNS/INLINE с декораторами @block/@pattern/@inline
- render.py: единый диспетчер, слой паттернов, проверка контракта pos (RuntimeError),
  pending_toc вместо current_heading для единственной вставки СОДЕРЖАНИЯ
- handlers/image.py: вики-ссылки ![[путь|ширина]], подпись Рисунок N —— ... ,
  figure-окружение, авто-нумерация, копирование файлов в tmpdir
- handlers/inline.py: табличная диспетчеризация вместо if/elif
- Все block-обработчики подписаны @block, корректно продвигают pos
- parser.py: препроцессинг ![[...]] → ![](path), iter_image_sources()
- main.py: _copy_assets (emblem+картинки в tmpdir), фикс лога (имя .tex),
  cwd=tmpdir для xelatex, цикл вместо дублирования, force-include emblem
- tests/test_render.py: pytest (10 тестов), pyproject dev-dep, make test
- preamble.py: name=Рисунок для figure-caption (было Рис. от babel)
- Исправлены баги: мёртвый обработчик, emblem не копировалась,
  множественный TOC, захардкожен report.log, маскировка pos
- README/AGENTS.md обновлены
This commit is contained in:
Arseny
2026-08-25 13:58:01 +03:00
parent 3e67937b8b
commit 8a921e2337
20 changed files with 592 additions and 138 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build run stop format tidy
.PHONY: build run stop format tidy test
IMAGE := md2gost
@@ -18,3 +18,6 @@ format:
tidy:
uv run ruff check md2gost/
uv run mypy md2gost/
test:
uv run pytest
+31 -4
View File
@@ -48,20 +48,46 @@ docker build -t md2gost .
- Параграфы, списки (маркированные и нумерованные)
- Таблицы
- Код с подсветкой синтаксиса
- Изображения
- Изображения (см. ниже)
- Аббревиатуры (раздел "Перечень принятых сокращений")
- Ссылки, жирный, курсив
### Изображения
Рисунок — вики-ссылка отдельным абзацем; подпись — следующий абзац вида
`Рисунок N <тире> Название` (номер отбрасывается, LaTeX нумерует сам):
```markdown
![[images/antenna.png]]
Рисунок 1 —— Антенна
Данная антенна используется для ...
```
Ширина задаётся суффиксом `|N` (доля от ширины текста):
```markdown
![[images/scheme.png|50]]
```
Файлы картинок копируются в каталог компиляции автоматически; эмблема
титульного листа ищется рядом со входным файлом или берётся из пакета.
Картинка внутри текстового абзаца не поддерживается (по ГОСТ рисунок
оформляется отдельным абзацем) и пропускается с предупреждением.
## Структура проекта
```
md2gost/
md2gost/
main.py # точка входа
parser.py # парсинг markdown
render.py # рендер в LaTeX
handlers/ # обработчики блоков
parser.py # парсинг markdown (+ вики-ссылки картинок)
registry.py # реестры block/pattern/inline обработчиков
render.py # проход по токенам и диспетчеризация
handlers/ # обработчики блоков (декораторы @block/@pattern/@inline)
latex/ # преамбула, титульный лист
tests/ # pytest
md2gost.sh # обёртка для Docker
Dockerfile
Makefile
@@ -74,4 +100,5 @@ make build # сборка Docker-образа
make run # сборка + конвертация report.md
make format # форматирование кода (black + ruff)
make tidy # статический анализ (ruff + mypy)
make test # pytest
```
+3 -2
View File
@@ -1,7 +1,7 @@
from md2gost.handlers.code import fence
from md2gost.handlers.heading import heading
from md2gost.handlers.hr import hr
from md2gost.handlers.image import image
from md2gost.handlers.image import figure, match_figure
from md2gost.handlers.lists import bullet_list, ordered_list
from md2gost.handlers.math import math_block
from md2gost.handlers.table import table
@@ -11,7 +11,8 @@ __all__ = [
"fence",
"hr",
"heading",
"image",
"figure",
"match_figure",
"bullet_list",
"math_block",
"ordered_list",
+4 -5
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
def fence(r: Renderer, tok: Token) -> str:
@block("fence")
def fence(r, tok: Token) -> str:
r.pos += 1
lang = tok.info.strip()
code = tok.content.rstrip()
opts = f"[language={lang}]" if lang else ""
+5 -8
View File
@@ -1,13 +1,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
from md2gost.handlers.inline import render_inline
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
UNNUMBERED = {
"АННОТАЦИЯ",
@@ -26,17 +22,18 @@ NEWPAGE_BEFORE = {
}
def heading(r: Renderer, tok: Token | None = None) -> str:
tok = r.tokens[r.pos]
@block("heading_open")
def heading(r, tok: Token) -> str:
level = int(tok.tag[1])
inline_tok = r.tokens[r.pos + 1]
text = render_inline(inline_tok)
r.pos += 2
upper = text.upper()
r.current_heading = upper
r.pos += 2
if upper == TOC_HEADING:
r.pending_toc = True
return ""
if upper in UNNUMBERED:
+4 -5
View File
@@ -1,12 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
def hr(r: Renderer, tok: Token | None = None) -> str:
@block("hr")
def hr(r, tok: Token) -> str:
r.pos += 1
return r"\noindent\rule{\textwidth}{0.4pt}"
+95 -8
View File
@@ -1,16 +1,103 @@
"""Обработка рисунков по ГОСТ.
Синтаксис в Markdown — вики-ссылка отдельным абзацем, подпись — абзац
под ней (см. parser._preprocess_wiki_images):
![[images/antenna.png]]
Рисунок 1 —— Антенна
Правила:
- подпись распознаётся по шаблону «Рисунок N <тире> Название»; номер
отбрасывается — LaTeX нумерует рисунки сам (последовательность
гарантируется);
- абзац с картинкой и посторонним текстом не является рисунком: инлайн-
картинка внутри текста пропускается с предупреждением;
- ширина задаётся суффиксом ``|N`` или ``|N%`` (доля от textwidth),
по умолчанию 80 %.
Паттерн чистый: match() смотрит на срез токенов, render_figure() строит
LaTeX из того же среза; renderer.pos двигает рендерер.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import re
from urllib.parse import unquote
from markdown_it.token import Token
from md2gost.handlers.inline import render_inline
from md2gost.handlers.inline import escape_latex
from md2gost.registry import pattern
if TYPE_CHECKING:
from md2gost.render import Renderer
DEFAULT_WIDTH = 0.8
_CAPTION_RE = re.compile(r"^\s*Рисунок\s+\d+\s*[—–-]+\s*(.+?)\s*$")
_WIDTH_RE = re.compile(r"^\d{1,3}%?$")
def image(r: Renderer, tok: Token) -> str:
src = tok.attrGet("src") or ""
alt = render_inline(tok) if tok.children else ""
return f"\\includegraphics[width=0.8\\textwidth]{{{src}}}\n\\captionof{{figure}}{{{alt}}}"
def _parse_image_src(raw: str) -> tuple[str, str]:
"""Разобрать 'путь|ширина' -> (путь, LaTeX-опция ширины)."""
path, sep, width = raw.rpartition("|")
if sep and _WIDTH_RE.match(width.strip()):
fraction = int(width.strip().rstrip("%")) / 100
return path, f"{fraction:g}\\textwidth"
return raw, f"{DEFAULT_WIDTH:g}\\textwidth"
def _single_image_paragraph(tokens: list[Token], pos: int) -> Token | None:
"""Inline-токен, если абзац на pos состоит ровно из одной картинки."""
if pos + 2 >= len(tokens):
return None
if tokens[pos].type != "paragraph_open" or tokens[pos + 1].type != "inline":
return None
children = tokens[pos + 1].children or []
images = [t for t in children if t.type == "image"]
if len(images) != 1:
return None
if any(
t.type not in ("image", "text", "softbreak") or t.content.strip()
for t in children
):
return None
return images[0]
def match_figure(tokens: list[Token], pos: int) -> int | None:
"""Абзац-картинка (+2 за подпись, если следующий абзац её содержит)."""
img_tok = _single_image_paragraph(tokens, pos)
if img_tok is None:
return None
caption_pos = pos + 3
if (
caption_pos + 2 < len(tokens)
and tokens[caption_pos].type == "paragraph_open"
and tokens[caption_pos + 1].type == "inline"
and _CAPTION_RE.match(tokens[caption_pos + 1].content)
):
return 5
return 3
@pattern(match_figure)
def figure(tokens: list[Token]) -> str:
img_tok = _single_image_paragraph(tokens, 0)
assert img_tok is not None
raw_src = unquote(str(img_tok.attrGet("src") or ""))
path, width = _parse_image_src(raw_src)
caption = ""
if len(tokens) >= 5:
m = _CAPTION_RE.match(tokens[4].content)
if m:
caption = escape_latex(m.group(1))
lines = [
"\\begin{figure}[ht]",
" \\centering",
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
]
if caption:
lines.append(f" \\caption{{{caption}}}")
lines.append("\\end{figure}")
return "\n".join(lines)
+75 -21
View File
@@ -1,7 +1,17 @@
"""Инлайн-рендер: обработка токенов внутри абзацев и заголовков.
Диспетчеризация табличная (registry.INLINE): тип токена -> функция
``(tok) -> str``. Новый инлайн-узел = одна функция с декоратором.
"""
from __future__ import annotations
import sys
from markdown_it.token import Token
from md2gost.registry import INLINE, inline
_LATEX_SPECIAL = str.maketrans(
{
"&": r"\&",
@@ -22,6 +32,68 @@ def escape_latex(text: str) -> str:
return text.translate(_LATEX_SPECIAL)
@inline("text")
def _text(tok: Token) -> str:
return escape_latex(tok.content)
@inline("strong_open")
def _strong_open(tok: Token) -> str:
return r"\textbf{"
@inline("strong_close")
def _strong_close(tok: Token) -> str:
return "}"
@inline("em_open")
def _em_open(tok: Token) -> str:
return r"\textit{"
@inline("em_close")
def _em_close(tok: Token) -> str:
return "}"
@inline("code_inline")
def _code_inline(tok: Token) -> str:
return f"\\verb|{tok.content}|"
@inline("link_open")
def _link_open(tok: Token) -> str:
href = str(tok.attrGet("href") or "")
return f"\\href{{{escape_latex(href)}}}{{"
@inline("link_close")
def _link_close(tok: Token) -> str:
return "}"
@inline("softbreak")
@inline("hardbreak")
def _break(tok: Token) -> str:
return "~\\\\"
@inline("math_inline")
def _math_inline(tok: Token) -> str:
return f"${tok.content}$"
@inline("image")
def _image_in_text(tok: Token) -> str:
print(
"предупреждение: картинка внутри текста пропущена "
"(по ГОСТ рисунок оформляется отдельным абзацем)",
file=sys.stderr,
)
return ""
def render_inline(tok: Token) -> str:
if not tok.children:
return escape_latex(tok.content or "")
@@ -31,27 +103,9 @@ def render_inline(tok: Token) -> str:
def render_inline_tokens(tokens: list[Token]) -> str:
parts: list[str] = []
for tok in tokens:
if tok.type == "text":
parts.append(escape_latex(tok.content))
elif tok.type == "strong_open":
parts.append(r"\textbf{")
elif tok.type == "strong_close":
parts.append("}")
elif tok.type == "em_open":
parts.append(r"\textit{")
elif tok.type == "em_close":
parts.append("}")
elif tok.type == "code_inline":
parts.append(f"\\verb|{tok.content}|")
elif tok.type == "link_open":
href = str(tok.attrGet("href") or "")
parts.append(f"\\href{{{escape_latex(href)}}}{{")
elif tok.type == "link_close":
parts.append("}")
elif tok.type in ("softbreak", "hardbreak"):
parts.append("~\\\\")
elif tok.type == "math_inline":
parts.append(f"${tok.content}$")
handler = INLINE.get(tok.type)
if handler is not None:
parts.append(handler(tok))
else:
parts.append(escape_latex(tok.content) if tok.content else "")
return "".join(parts)
+7 -9
View File
@@ -1,16 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
from md2gost.handlers.inline import render_inline_tokens
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
def bullet_list(r: Renderer, tok: Token | None = None) -> str:
@block("bullet_list_open")
def bullet_list(r, tok: Token) -> str:
items = _collect_list_items(r)
lines = []
for item in items:
@@ -23,7 +20,8 @@ def bullet_list(r: Renderer, tok: Token | None = None) -> str:
return f"\\begin{{itemize}}\n{body}\n\\end{{itemize}}"
def ordered_list(r: Renderer, tok: Token | None = None) -> str:
@block("ordered_list_open")
def ordered_list(r, tok: Token) -> str:
items = _collect_list_items(r)
lines = []
for item in items:
@@ -38,7 +36,7 @@ def ordered_list(r: Renderer, tok: Token | None = None) -> str:
return f"\\begin{{enumerate}}\n{body}\n\\end{{enumerate}}"
def _collect_list_items(r: Renderer) -> list[list[Token]]:
def _collect_list_items(r) -> list[list[Token]]:
items: list[list[Token]] = []
r.pos += 1
while r.pos < len(r.tokens):
@@ -56,7 +54,7 @@ def _collect_list_items(r: Renderer) -> list[list[Token]]:
return items
def _find_inline_in(r: Renderer) -> Token | None:
def _find_inline_in(r) -> Token | None:
depth = 0
while r.pos < len(r.tokens):
tok = r.tokens[r.pos]
+4 -7
View File
@@ -1,15 +1,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
def math_block(r: Renderer, tok: Token | None = None) -> str:
tok = r.tokens[r.pos]
content = tok.content.strip()
@block("math_block")
def math_block(r, tok: Token) -> str:
r.pos += 1
content = tok.content.strip()
return f"\\[\n{content}\n\\]"
+4 -6
View File
@@ -1,15 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
from md2gost.handlers.inline import render_inline
if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer
from md2gost.registry import block
def table(r: Renderer, tok: Token | None = None) -> str:
@block("table_open")
def table(r, tok: Token) -> str:
rows: list[list[str]] = []
r.pos += 1
while r.pos < len(r.tokens):
+3 -6
View File
@@ -1,16 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
from md2gost.handlers.inline import render_inline
if TYPE_CHECKING:
from md2gost.render import Renderer
from md2gost.registry import block
def paragraph(r: Renderer, tok: Token | None = None) -> str:
@block("paragraph_open")
def paragraph(r, tok: Token) -> str:
inline_tok = r.tokens[r.pos + 1]
text = render_inline(inline_tok)
r.pos += 2
+1 -1
View File
@@ -23,7 +23,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage{hyperref}
\hypersetup {unicode=true}
\DeclareCaptionLabelSeparator*{emdash}{~--- }
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom}
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок}
\usepackage{listings}
\lstset{
basicstyle=\ttfamily\footnotesize,
+47 -13
View File
@@ -5,7 +5,8 @@ import sys
import tempfile
from pathlib import Path
from md2gost.parser import Parser
import md2gost
from md2gost.parser import Parser, iter_image_sources
from md2gost.render import Renderer
@@ -25,6 +26,9 @@ def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
str(output_dir),
str(tex_path),
],
# Относительные пути картинок резолвятся от CWD, поэтому компилируем
# из каталога, куда _copy_assets сложил все изображения.
cwd=str(output_dir),
capture_output=True,
text=True,
)
@@ -32,6 +36,37 @@ def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
return pdf_path.exists() and result.returncode == 0
def _find_emblem(input_dir: Path) -> Path | None:
candidates = [
input_dir / "emblem.png",
Path(md2gost.__file__).parent / "emblem.png",
]
return next((p for p in candidates if p.is_file()), None)
def _copy_assets(parser: Parser, input_dir: Path, cache_dir: Path) -> None:
"""Копирует эмблему и все картинки документа в каталог компиляции."""
emblem = _find_emblem(input_dir)
if emblem is None:
print(
"предупреждение: emblem.png не найден — титульный лист без эмблемы",
file=sys.stderr,
)
else:
shutil.copy2(emblem, cache_dir / "emblem.png")
for src in iter_image_sources(parser.tokens):
src_path = input_dir / src
if not src_path.is_file():
print(
f"предупреждение: изображение не найдено: {src_path}", file=sys.stderr
)
continue
dest = cache_dir / src
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dest)
def main() -> None:
cli = argparse.ArgumentParser(
prog="md2gost",
@@ -70,25 +105,24 @@ def main() -> None:
tex_path = cache_dir / tex_name
tex_path.write_text(latex, encoding="utf-8")
_copy_assets(parser, args.input.parent, cache_dir)
final_tex = args.output / tex_name
shutil.copy2(tex_path, final_tex)
print(f"-> {final_tex}")
if not args.no_pdf:
if not run_xelatex(tex_path, cache_dir):
print("ошибка: xelatex не собрал PDF", file=sys.stderr)
_print_latex_errors(cache_dir / "report.log")
sys.exit(1)
if not run_xelatex(tex_path, cache_dir):
print("ошибка: xelatex (2-й проход) не собрал PDF", file=sys.stderr)
_print_latex_errors(cache_dir / "report.log")
sys.exit(1)
log_path = cache_dir / tex_path.with_suffix(".log").name
pdf_name = args.input.with_suffix(".pdf").name
pdf_path = cache_dir / pdf_name
for attempt in (1, 2):
if not run_xelatex(tex_path, cache_dir):
stage = "" if attempt == 1 else " (2-й проход)"
print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr)
_print_latex_errors(log_path)
sys.exit(1)
final_pdf = args.output / pdf_name
shutil.copy2(pdf_path, final_pdf)
shutil.copy2(cache_dir / pdf_name, final_pdf)
print(f"-> {final_pdf}")
+33 -1
View File
@@ -1,7 +1,22 @@
import re
from urllib.parse import unquote
from markdown_it import MarkdownIt
from markdown_it.token import Token
from mdit_py_plugins.dollarmath import dollarmath_plugin
from mdit_py_plugins.front_matter import front_matter_plugin
_WIKI_IMAGE_RE = re.compile(r"!\[\[([^\]]+)\]\]")
def _preprocess_wiki_images(md_text: str) -> str:
"""![[путь|ширина]] -> стандартный Markdown-синтаксис картинки."""
def repl(m: re.Match[str]) -> str:
return f"![]({m.group(1).strip()})"
return _WIKI_IMAGE_RE.sub(repl, md_text)
class Parser:
def __init__(self, md_text: str) -> None:
@@ -12,7 +27,7 @@ class Parser:
.use(front_matter_plugin)
.use(dollarmath_plugin)
)
self.tokens = self.md.parse(md_text)
self.tokens = self.md.parse(_preprocess_wiki_images(md_text))
self.front_matter = self._extract_front_matter()
def _extract_front_matter(self) -> dict[str, str]:
@@ -25,3 +40,20 @@ class Parser:
key, _, value = line.partition(":")
result[key.strip()] = value.strip()
return result
def iter_image_sources(tokens: list[Token]) -> list[str]:
"""Все пути картинок в дереве токенов (для копирования рядом с .tex)."""
sources: list[str] = []
def walk(toks: list[Token]) -> None:
for tok in toks:
if tok.type == "image":
src = tok.attrGet("src")
if src:
sources.append(unquote(str(src)).split("|")[0])
if tok.children:
walk(tok.children)
walk(tokens)
return sources
+70
View File
@@ -0,0 +1,70 @@
"""Реестры обработчиков токенов.
Три механизма диспетчеризации:
- ``BLOCK`` — обработчики top-level токенов (heading, paragraph, list...).
Обработчик получает ``(renderer, tok)`` и ОБЯЗАН продвинуть ``renderer.pos``
за все потреблённые токены.
- ``PATTERNS`` — упорядоченный список составных условий. Функция сопоставления
``match(tokens, pos) -> int | None`` возвращает число потребляемых токенов
или None; чистая функция без побочных эффектов. Проверяются до BLOCK.
- ``INLINE`` — обработчики инлайн-токенов внутри абзацев/заголовков.
Обработчик получает только токен и возвращает LaTeX-строку.
Новый тип блока регистрируется декоратором в одном месте — в модуле самого
обработчика, правка render.py не требуется.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from md2gost.render import Renderer
from markdown_it.token import Token
BlockHandler = Callable[["Renderer", "Token"], str]
InlineHandler = Callable[["Token"], str]
MatchFn = Callable[[list["Token"], int], "int | None"]
PatternHandler = Callable[[list["Token"]], str]
BLOCK: dict[str, BlockHandler] = {}
INLINE: dict[str, InlineHandler] = {}
PATTERNS: list[tuple[MatchFn, PatternHandler]] = []
def block(*token_types: str) -> Callable[[BlockHandler], BlockHandler]:
"""Зарегистрировать обработчик top-level токена данного типа."""
def deco(fn: BlockHandler) -> BlockHandler:
for tt in token_types:
BLOCK[tt] = fn
return fn
return deco
def pattern(match: MatchFn) -> Callable[[PatternHandler], PatternHandler]:
"""Зарегистрировать составное условие.
``match(tokens, pos)`` возвращает количество потребляемых токенов,
начиная с ``pos``, либо None если условие не выполнено. Обработчик
получает срез ``tokens[pos : pos + n]``; pos двигает сам рендерер.
"""
def deco(fn: PatternHandler) -> PatternHandler:
PATTERNS.append((match, fn))
return fn
return deco
def inline(token_type: str) -> Callable[[InlineHandler], InlineHandler]:
"""Зарегистрировать обработчик инлайн-токена данного типа."""
def deco(fn: InlineHandler) -> InlineHandler:
INLINE[token_type] = fn
return fn
return deco
+52 -40
View File
@@ -1,38 +1,38 @@
"""Главный цикл рендера: проход по токенам и диспетчеризация.
Порядок диспетчеризации для каждого top-level токена:
1. Паттерны (registry.PATTERNS) — составные условия на набор токенов
(например «абзац из ровно одной картинки»).
2. Словарь registry.BLOCK — обработчики одиночных top-level токенов.
Контракт block-обработчика: ``handler(renderer, tok) -> str``; обработчик
обязан продвинуть ``renderer.pos`` за все потреблённые токены. Нарушение
контракта — ошибка (RuntimeError), а не тихий неверный вывод.
"""
from __future__ import annotations
from typing import Callable
from md2gost.handlers import (
from markdown_it.token import Token
from md2gost import registry
from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
# Импорт обработчиков регистрирует их в реестрах.
from md2gost.handlers import ( # noqa: F401
bullet_list,
fence,
figure,
heading,
hr,
image,
math_block,
ordered_list,
paragraph,
table,
)
from md2gost.handlers.inline import render_inline
from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
from markdown_it.token import Token
_BLOCK: dict[str, Callable[..., str]] = {
"heading_open": heading,
"paragraph_open": paragraph,
"bullet_list_open": bullet_list,
"ordered_list_open": ordered_list,
"table_open": table,
"hr": hr,
"math_block": math_block,
}
_INLINE: dict[str, Callable[..., str]] = {
"fence": fence,
"image": image,
"inline": render_inline,
}
TOC_HEADING = "СОДЕРЖАНИЕ"
class Renderer:
@@ -41,6 +41,7 @@ class Renderer:
self.fm = front_matter
self.pos = 0
self.current_heading = ""
self.pending_toc = False
def render(self) -> str:
parts: list[str] = [
@@ -50,24 +51,35 @@ class Renderer:
]
while self.pos < len(self.tokens):
tok = self.tokens[self.pos]
prev_pos = self.pos
tt = tok.type
if tt in _BLOCK:
result = _BLOCK[tt](self, tok)
parts.append(result)
if self.current_heading == "СОДЕРЖАНИЕ":
rendered = self._dispatch()
parts.append(rendered)
if self.pending_toc and rendered:
parts.append("\\newpage\n\\tableofcontents\n\\newpage")
elif tt in _INLINE:
result = _INLINE[tt](self, tok)
parts.append(result)
else:
self.pos += 1
continue
if self.pos == prev_pos:
self.pos += 1
self.pending_toc = False
parts.append(END_DOCUMENT)
return "\n\n".join(parts)
return "\n\n".join(p for p in parts if p)
def _dispatch(self) -> str:
tokens = self.tokens
pos = self.pos
tok = tokens[pos]
for match_fn, handler_fn in registry.PATTERNS:
span = match_fn(tokens, pos)
if span is not None and span > 0:
self.pos += span
return handler_fn(tokens[pos : pos + span])
handler = registry.BLOCK.get(tok.type)
if handler is None:
self.pos += 1
return ""
result = handler(self, tok)
if self.pos <= pos:
raise RuntimeError(
f"обработчик {handler.__name__} не продвинул pos "
f"для токена '{tok.type}'"
)
return result
+2
View File
@@ -4,6 +4,7 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["md2gost"]
force-include = {"emblem.png" = "md2gost/emblem.png"}
[project]
name = "md2gost"
@@ -21,5 +22,6 @@ md2gost = "md2gost.main:main"
dev = [
"black>=26.5.1",
"mypy>=2.3.0",
"pytest>=8.3.0",
"ruff>=0.15.21",
]
+102
View File
@@ -0,0 +1,102 @@
from md2gost.parser import Parser
from md2gost.render import Renderer
FRONT_MATTER = "---\nназвание: Тестовая работа\nтип: Отчёт\n---\n\n"
def render(md: str) -> str:
parser = Parser(FRONT_MATTER + md)
return Renderer(parser.tokens, parser.front_matter).render()
def test_heading_numbering() -> None:
tex = render("# Исследовательская часть\n\n## Обзор\n")
assert "\\section{Исследовательская часть}" in tex
assert "\\subsection{Обзор}" in tex
def test_unnumbered_heading() -> None:
tex = render("# ВВЕДЕНИЕ\n\nТекст.\n")
assert "\\section{" not in tex
assert "\\clearpage" in tex
assert "ВВЕДЕНИЕ" in tex
def test_list_punctuation() -> None:
tex = render("- один\n- два\n- три\n")
assert " \\item один;" in tex
assert " \\item три." in tex
def test_figure_with_caption(tmp_path) -> None:
tex = render(
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
"Данная антенна используется для ...\n"
)
assert "\\begin{figure}[ht]" in tex
assert "\\centering" in tex
assert (
"\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}"
in tex
)
assert "\\caption{Антенна}" in tex
assert "Данная антенна используется для ..." in tex
def test_figure_without_caption() -> None:
tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n")
assert "\\begin{figure}[ht]" in tex
assert "\\caption{" not in tex
assert "Обычный абзац после картинки." in tex
def test_figure_custom_width() -> None:
tex = render("![[images/scheme.png|50]]\n\nРисунок 2 —— Схема\n")
assert (
"\\includegraphics[width=0.5\\textwidth]{\\detokenize{images/scheme.png}}"
in tex
)
def test_caption_number_is_discarded() -> None:
tex = render("![[images/a.png]]\n\nРисунок 7 —— Что-то\n")
assert "7" not in _figure_block(tex)
def test_inline_image_skipped_with_warning(capsys) -> None:
tex = render("Текст с ![alt](img.png) внутри.\n")
assert "предупреждение: картинка внутри текста пропущена" in capsys.readouterr().err
assert "\\begin{figure}" not in tex
assert "img.png" not in tex
def test_toc_inserted_once() -> None:
tex = render("# СОДЕРЖАНИЕ\n\n# ВВЕДЕНИЕ\n\nТекст.\n\n# ЗАКЛЮЧЕНИЕ\n\nВыводы.\n")
assert tex.count("\\tableofcontents") == 1
def test_handler_must_advance_pos() -> None:
from markdown_it import MarkdownIt
tokens = MarkdownIt().parse(FRONT_MATTER + "# Заголовок\n")
renderer = Renderer(tokens, {"название": "Т", "тип": "Отчёт"})
renderer.render()
broken = Renderer(tokens, {"название": "Т", "тип": "Отчёт"})
from md2gost.registry import BLOCK
original = BLOCK["heading_open"]
BLOCK["heading_open"] = lambda r, tok: ""
try:
try:
broken.render()
raise AssertionError("ожидался RuntimeError")
except RuntimeError as e:
assert "heading_open" in str(e)
finally:
BLOCK["heading_open"] = original
def _figure_block(tex: str) -> str:
start = tex.index("\\begin{figure}")
end = tex.index("\\end{figure}") + len("\\end{figure}")
return tex[start:end]
Generated
+45
View File
@@ -100,6 +100,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.13.0"
@@ -191,6 +200,7 @@ dependencies = [
dev = [
{ name = "black" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
]
@@ -201,6 +211,7 @@ requires-dist = [{ name = "markdown-it-py", extras = ["plugins"], specifier = ">
dev = [
{ name = "black", specifier = ">=26.5.1" },
{ name = "mypy", specifier = ">=2.3.0" },
{ name = "pytest", specifier = ">=8.3.0" },
{ name = "ruff", specifier = ">=0.15.21" },
]
@@ -306,6 +317,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"