[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 IMAGE := md2gost
@@ -18,3 +18,6 @@ format:
tidy: tidy:
uv run ruff check md2gost/ uv run ruff check md2gost/
uv run mypy 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/
md2gost/ md2gost/
main.py # точка входа main.py # точка входа
parser.py # парсинг markdown parser.py # парсинг markdown (+ вики-ссылки картинок)
render.py # рендер в LaTeX registry.py # реестры block/pattern/inline обработчиков
handlers/ # обработчики блоков render.py # проход по токенам и диспетчеризация
handlers/ # обработчики блоков (декораторы @block/@pattern/@inline)
latex/ # преамбула, титульный лист latex/ # преамбула, титульный лист
tests/ # pytest
md2gost.sh # обёртка для Docker md2gost.sh # обёртка для Docker
Dockerfile Dockerfile
Makefile Makefile
@@ -74,4 +100,5 @@ make build # сборка Docker-образа
make run # сборка + конвертация report.md make run # сборка + конвертация report.md
make format # форматирование кода (black + ruff) make format # форматирование кода (black + ruff)
make tidy # статический анализ (ruff + mypy) make tidy # статический анализ (ruff + mypy)
make test # pytest
``` ```
+3 -2
View File
@@ -1,7 +1,7 @@
from md2gost.handlers.code import fence from md2gost.handlers.code import fence
from md2gost.handlers.heading import heading from md2gost.handlers.heading import heading
from md2gost.handlers.hr import hr 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.lists import bullet_list, ordered_list
from md2gost.handlers.math import math_block from md2gost.handlers.math import math_block
from md2gost.handlers.table import table from md2gost.handlers.table import table
@@ -11,7 +11,8 @@ __all__ = [
"fence", "fence",
"hr", "hr",
"heading", "heading",
"image", "figure",
"match_figure",
"bullet_list", "bullet_list",
"math_block", "math_block",
"ordered_list", "ordered_list",
+4 -5
View File
@@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
if TYPE_CHECKING: from md2gost.registry import block
from md2gost.render import Renderer
def fence(r: Renderer, tok: Token) -> str: @block("fence")
def fence(r, tok: Token) -> str:
r.pos += 1
lang = tok.info.strip() lang = tok.info.strip()
code = tok.content.rstrip() code = tok.content.rstrip()
opts = f"[language={lang}]" if lang else "" opts = f"[language={lang}]" if lang else ""
+5 -8
View File
@@ -1,13 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.handlers.inline import render_inline from md2gost.handlers.inline import render_inline
from md2gost.registry import block
if TYPE_CHECKING:
from md2gost.render import Renderer
UNNUMBERED = { UNNUMBERED = {
"АННОТАЦИЯ", "АННОТАЦИЯ",
@@ -26,17 +22,18 @@ NEWPAGE_BEFORE = {
} }
def heading(r: Renderer, tok: Token | None = None) -> str: @block("heading_open")
tok = r.tokens[r.pos] def heading(r, tok: Token) -> str:
level = int(tok.tag[1]) level = int(tok.tag[1])
inline_tok = r.tokens[r.pos + 1] inline_tok = r.tokens[r.pos + 1]
text = render_inline(inline_tok) text = render_inline(inline_tok)
r.pos += 2
upper = text.upper() upper = text.upper()
r.current_heading = upper r.current_heading = upper
r.pos += 2
if upper == TOC_HEADING: if upper == TOC_HEADING:
r.pending_toc = True
return "" return ""
if upper in UNNUMBERED: if upper in UNNUMBERED:
+4 -5
View File
@@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
if TYPE_CHECKING: from md2gost.registry import block
from md2gost.render import Renderer
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}" 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 __future__ import annotations
from typing import TYPE_CHECKING import re
from urllib.parse import unquote
from markdown_it.token import Token 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: DEFAULT_WIDTH = 0.8
from md2gost.render import Renderer _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: def _parse_image_src(raw: str) -> tuple[str, str]:
src = tok.attrGet("src") or "" """Разобрать 'путь|ширина' -> (путь, LaTeX-опция ширины)."""
alt = render_inline(tok) if tok.children else "" path, sep, width = raw.rpartition("|")
return f"\\includegraphics[width=0.8\\textwidth]{{{src}}}\n\\captionof{{figure}}{{{alt}}}" 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 from __future__ import annotations
import sys
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.registry import INLINE, inline
_LATEX_SPECIAL = str.maketrans( _LATEX_SPECIAL = str.maketrans(
{ {
"&": r"\&", "&": r"\&",
@@ -22,6 +32,68 @@ def escape_latex(text: str) -> str:
return text.translate(_LATEX_SPECIAL) 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: def render_inline(tok: Token) -> str:
if not tok.children: if not tok.children:
return escape_latex(tok.content or "") return escape_latex(tok.content or "")
@@ -31,27 +103,9 @@ def render_inline(tok: Token) -> str:
def render_inline_tokens(tokens: list[Token]) -> str: def render_inline_tokens(tokens: list[Token]) -> str:
parts: list[str] = [] parts: list[str] = []
for tok in tokens: for tok in tokens:
if tok.type == "text": handler = INLINE.get(tok.type)
parts.append(escape_latex(tok.content)) if handler is not None:
elif tok.type == "strong_open": parts.append(handler(tok))
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}$")
else: else:
parts.append(escape_latex(tok.content) if tok.content else "") parts.append(escape_latex(tok.content) if tok.content else "")
return "".join(parts) return "".join(parts)
+7 -9
View File
@@ -1,16 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.handlers.inline import render_inline_tokens from md2gost.handlers.inline import render_inline_tokens
from md2gost.registry import block
if TYPE_CHECKING:
from md2gost.render import Renderer
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) items = _collect_list_items(r)
lines = [] lines = []
for item in items: 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}}" 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) items = _collect_list_items(r)
lines = [] lines = []
for item in items: 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}}" 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]] = [] items: list[list[Token]] = []
r.pos += 1 r.pos += 1
while r.pos < len(r.tokens): while r.pos < len(r.tokens):
@@ -56,7 +54,7 @@ def _collect_list_items(r: Renderer) -> list[list[Token]]:
return items return items
def _find_inline_in(r: Renderer) -> Token | None: def _find_inline_in(r) -> Token | None:
depth = 0 depth = 0
while r.pos < len(r.tokens): while r.pos < len(r.tokens):
tok = r.tokens[r.pos] tok = r.tokens[r.pos]
+4 -7
View File
@@ -1,15 +1,12 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
if TYPE_CHECKING: from md2gost.registry import block
from md2gost.render import Renderer
def math_block(r: Renderer, tok: Token | None = None) -> str: @block("math_block")
tok = r.tokens[r.pos] def math_block(r, tok: Token) -> str:
content = tok.content.strip()
r.pos += 1 r.pos += 1
content = tok.content.strip()
return f"\\[\n{content}\n\\]" return f"\\[\n{content}\n\\]"
+4 -6
View File
@@ -1,15 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from markdown_it.token import Token
from md2gost.handlers.inline import render_inline from md2gost.handlers.inline import render_inline
from md2gost.registry import block
if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer
def table(r: Renderer, tok: Token | None = None) -> str: @block("table_open")
def table(r, tok: Token) -> str:
rows: list[list[str]] = [] rows: list[list[str]] = []
r.pos += 1 r.pos += 1
while r.pos < len(r.tokens): while r.pos < len(r.tokens):
+3 -6
View File
@@ -1,16 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.handlers.inline import render_inline from md2gost.handlers.inline import render_inline
from md2gost.registry import block
if TYPE_CHECKING:
from md2gost.render import Renderer
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] inline_tok = r.tokens[r.pos + 1]
text = render_inline(inline_tok) text = render_inline(inline_tok)
r.pos += 2 r.pos += 2
+1 -1
View File
@@ -23,7 +23,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage{hyperref} \usepackage{hyperref}
\hypersetup {unicode=true} \hypersetup {unicode=true}
\DeclareCaptionLabelSeparator*{emdash}{~--- } \DeclareCaptionLabelSeparator*{emdash}{~--- }
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom} \captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок}
\usepackage{listings} \usepackage{listings}
\lstset{ \lstset{
basicstyle=\ttfamily\footnotesize, basicstyle=\ttfamily\footnotesize,
+47 -13
View File
@@ -5,7 +5,8 @@ import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from md2gost.parser import Parser import md2gost
from md2gost.parser import Parser, iter_image_sources
from md2gost.render import Renderer from md2gost.render import Renderer
@@ -25,6 +26,9 @@ def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
str(output_dir), str(output_dir),
str(tex_path), str(tex_path),
], ],
# Относительные пути картинок резолвятся от CWD, поэтому компилируем
# из каталога, куда _copy_assets сложил все изображения.
cwd=str(output_dir),
capture_output=True, capture_output=True,
text=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 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: def main() -> None:
cli = argparse.ArgumentParser( cli = argparse.ArgumentParser(
prog="md2gost", prog="md2gost",
@@ -70,25 +105,24 @@ def main() -> None:
tex_path = cache_dir / tex_name tex_path = cache_dir / tex_name
tex_path.write_text(latex, encoding="utf-8") tex_path.write_text(latex, encoding="utf-8")
_copy_assets(parser, args.input.parent, cache_dir)
final_tex = args.output / tex_name final_tex = args.output / tex_name
shutil.copy2(tex_path, final_tex) shutil.copy2(tex_path, final_tex)
print(f"-> {final_tex}") print(f"-> {final_tex}")
if not args.no_pdf: if not args.no_pdf:
if not run_xelatex(tex_path, cache_dir): log_path = cache_dir / tex_path.with_suffix(".log").name
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)
pdf_name = args.input.with_suffix(".pdf").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 final_pdf = args.output / pdf_name
shutil.copy2(pdf_path, final_pdf) shutil.copy2(cache_dir / pdf_name, final_pdf)
print(f"-> {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 import MarkdownIt
from markdown_it.token import Token
from mdit_py_plugins.dollarmath import dollarmath_plugin from mdit_py_plugins.dollarmath import dollarmath_plugin
from mdit_py_plugins.front_matter import front_matter_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: class Parser:
def __init__(self, md_text: str) -> None: def __init__(self, md_text: str) -> None:
@@ -12,7 +27,7 @@ class Parser:
.use(front_matter_plugin) .use(front_matter_plugin)
.use(dollarmath_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() self.front_matter = self._extract_front_matter()
def _extract_front_matter(self) -> dict[str, str]: def _extract_front_matter(self) -> dict[str, str]:
@@ -25,3 +40,20 @@ class Parser:
key, _, value = line.partition(":") key, _, value = line.partition(":")
result[key.strip()] = value.strip() result[key.strip()] = value.strip()
return result 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 __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, bullet_list,
fence, fence,
figure,
heading, heading,
hr, hr,
image,
math_block, math_block,
ordered_list, ordered_list,
paragraph, paragraph,
table, table,
) )
from md2gost.handlers.inline import render_inline
from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
from markdown_it.token import Token TOC_HEADING = "СОДЕРЖАНИЕ"
_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,
}
class Renderer: class Renderer:
@@ -41,6 +41,7 @@ class Renderer:
self.fm = front_matter self.fm = front_matter
self.pos = 0 self.pos = 0
self.current_heading = "" self.current_heading = ""
self.pending_toc = False
def render(self) -> str: def render(self) -> str:
parts: list[str] = [ parts: list[str] = [
@@ -50,24 +51,35 @@ class Renderer:
] ]
while self.pos < len(self.tokens): while self.pos < len(self.tokens):
tok = self.tokens[self.pos] rendered = self._dispatch()
prev_pos = self.pos parts.append(rendered)
tt = tok.type if self.pending_toc and rendered:
if tt in _BLOCK:
result = _BLOCK[tt](self, tok)
parts.append(result)
if self.current_heading == "СОДЕРЖАНИЕ":
parts.append("\\newpage\n\\tableofcontents\n\\newpage") parts.append("\\newpage\n\\tableofcontents\n\\newpage")
elif tt in _INLINE: self.pending_toc = False
result = _INLINE[tt](self, tok)
parts.append(result)
else:
self.pos += 1
continue
if self.pos == prev_pos:
self.pos += 1
parts.append(END_DOCUMENT) 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] [tool.hatch.build.targets.wheel]
packages = ["md2gost"] packages = ["md2gost"]
force-include = {"emblem.png" = "md2gost/emblem.png"}
[project] [project]
name = "md2gost" name = "md2gost"
@@ -21,5 +22,6 @@ md2gost = "md2gost.main:main"
dev = [ dev = [
"black>=26.5.1", "black>=26.5.1",
"mypy>=2.3.0", "mypy>=2.3.0",
"pytest>=8.3.0",
"ruff>=0.15.21", "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" }, { 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]] [[package]]
name = "librt" name = "librt"
version = "0.13.0" version = "0.13.0"
@@ -191,6 +200,7 @@ dependencies = [
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -201,6 +211,7 @@ requires-dist = [{ name = "markdown-it-py", extras = ["plugins"], specifier = ">
dev = [ dev = [
{ name = "black", specifier = ">=26.5.1" }, { name = "black", specifier = ">=26.5.1" },
{ name = "mypy", specifier = ">=2.3.0" }, { name = "mypy", specifier = ">=2.3.0" },
{ name = "pytest", specifier = ">=8.3.0" },
{ name = "ruff", specifier = ">=0.15.21" }, { 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" }, { 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]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"