[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: препроцессинг ![[...]] → , 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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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\\]"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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".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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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("Текст с  внутри.\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]
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user