From 8a921e233767eb40c91a6a9d22b22ddf9299c2f6 Mon Sep 17 00:00:00 2001 From: Arseny <20096ninja@gmail.com> Date: Tue, 25 Aug 2026 13:58:01 +0300 Subject: [PATCH] =?UTF-8?q?[ADD]=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20=D0=B8=D0=B7=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D1=82=D1=87=D0=B5=D1=80=D0=B0=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 обновлены --- Makefile | 5 +- README.md | 35 ++++++++++-- md2gost/handlers/__init__.py | 5 +- md2gost/handlers/code.py | 9 ++- md2gost/handlers/heading.py | 13 ++--- md2gost/handlers/hr.py | 9 ++- md2gost/handlers/image.py | 103 ++++++++++++++++++++++++++++++++--- md2gost/handlers/inline.py | 96 +++++++++++++++++++++++++------- md2gost/handlers/lists.py | 16 +++--- md2gost/handlers/math.py | 11 ++-- md2gost/handlers/table.py | 10 ++-- md2gost/handlers/text.py | 9 +-- md2gost/latex/preamble.py | 2 +- md2gost/main.py | 60 +++++++++++++++----- md2gost/parser.py | 34 +++++++++++- md2gost/registry.py | 70 ++++++++++++++++++++++++ md2gost/render.py | 94 ++++++++++++++++++-------------- pyproject.toml | 2 + tests/test_render.py | 102 ++++++++++++++++++++++++++++++++++ uv.lock | 45 +++++++++++++++ 20 files changed, 592 insertions(+), 138 deletions(-) create mode 100644 md2gost/registry.py create mode 100644 tests/test_render.py diff --git a/Makefile b/Makefile index 883360f..01b3169 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 7af00f1..c568dda 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/md2gost/handlers/__init__.py b/md2gost/handlers/__init__.py index 9a5a88b..240247c 100644 --- a/md2gost/handlers/__init__.py +++ b/md2gost/handlers/__init__.py @@ -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", diff --git a/md2gost/handlers/code.py b/md2gost/handlers/code.py index d5823ff..3daa88d 100644 --- a/md2gost/handlers/code.py +++ b/md2gost/handlers/code.py @@ -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 "" diff --git a/md2gost/handlers/heading.py b/md2gost/handlers/heading.py index ffe421a..b932506 100644 --- a/md2gost/handlers/heading.py +++ b/md2gost/handlers/heading.py @@ -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: diff --git a/md2gost/handlers/hr.py b/md2gost/handlers/hr.py index 3ac62a6..cef4760 100644 --- a/md2gost/handlers/hr.py +++ b/md2gost/handlers/hr.py @@ -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}" diff --git a/md2gost/handlers/image.py b/md2gost/handlers/image.py index 7570dc9..d6c65e7 100644 --- a/md2gost/handlers/image.py +++ b/md2gost/handlers/image.py @@ -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) diff --git a/md2gost/handlers/inline.py b/md2gost/handlers/inline.py index a7bea8a..2473545 100644 --- a/md2gost/handlers/inline.py +++ b/md2gost/handlers/inline.py @@ -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) diff --git a/md2gost/handlers/lists.py b/md2gost/handlers/lists.py index c3aa660..5c99250 100644 --- a/md2gost/handlers/lists.py +++ b/md2gost/handlers/lists.py @@ -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] diff --git a/md2gost/handlers/math.py b/md2gost/handlers/math.py index 09d8d3a..90ca3ae 100644 --- a/md2gost/handlers/math.py +++ b/md2gost/handlers/math.py @@ -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\\]" diff --git a/md2gost/handlers/table.py b/md2gost/handlers/table.py index 64553c8..3cd9e5f 100644 --- a/md2gost/handlers/table.py +++ b/md2gost/handlers/table.py @@ -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): diff --git a/md2gost/handlers/text.py b/md2gost/handlers/text.py index eb3327f..19999ee 100644 --- a/md2gost/handlers/text.py +++ b/md2gost/handlers/text.py @@ -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 diff --git a/md2gost/latex/preamble.py b/md2gost/latex/preamble.py index 6de3ca9..58cfc1c 100644 --- a/md2gost/latex/preamble.py +++ b/md2gost/latex/preamble.py @@ -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, diff --git a/md2gost/main.py b/md2gost/main.py index 4659d65..4722f47 100644 --- a/md2gost/main.py +++ b/md2gost/main.py @@ -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}") diff --git a/md2gost/parser.py b/md2gost/parser.py index 8e77f33..7b57e17 100644 --- a/md2gost/parser.py +++ b/md2gost/parser.py @@ -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 diff --git a/md2gost/registry.py b/md2gost/registry.py new file mode 100644 index 0000000..9f275e0 --- /dev/null +++ b/md2gost/registry.py @@ -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 diff --git a/md2gost/render.py b/md2gost/render.py index 0097f4b..f7d387b 100644 --- a/md2gost/render.py +++ b/md2gost/render.py @@ -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 == "СОДЕРЖАНИЕ": - 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 + rendered = self._dispatch() + parts.append(rendered) + if self.pending_toc and rendered: + parts.append("\\newpage\n\\tableofcontents\n\\newpage") + 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 diff --git a/pyproject.toml b/pyproject.toml index 28b7a8f..da4e0e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..cd604f4 --- /dev/null +++ b/tests/test_render.py @@ -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] diff --git a/uv.lock b/uv.lock index 5957e37..84a5b21 100644 --- a/uv.lock +++ b/uv.lock @@ -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"