Compare commits

...

18 Commits

Author SHA1 Message Date
Arseny 035300d372 [FIX] рисунок как float H и маркер списка длинное тире 2026-08-25 16:31:51 +03:00
Arseny e0c25cc0fe [FIX] TOC для введения/заключения/источников и уменьшение шрифта инлайн-кода 2026-08-25 16:14:18 +03:00
Arseny 974d49b056 [FIX] порядок TOC и монтирование картинок в Docker
- md2gost.sh: монтировать директорию входного файла (/input:ro) вместо
  одного файла — картинки по относительным путям (img/) теперь видны
  внутри контейнера; основное исправление падений make run
- render.py: вставлять TOC до следующего блока, а не после — порядок
  стал АННОТАЦИЯ -> СОДЕРЖАНИЕ -> ПЕРЕЧЕНЬ -> ВВЕДЕНИЕ (ранее заголовок
  ПЕРЕЧЕНЬ без тела оказывался перед СОДЕРЖАНИЕМ, а его тело — после)
- heading.py: не устанавливать current_heading для СОДЕРЖАНИЕ, чтобы
  не сбивать спецрежим перечня сокращений
- .gitignore: игнорировать сгенерированные report.tex/pdf и img/
2026-08-25 14:27:37 +03:00
Arseny 8a921e2337 [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 обновлены
2026-08-25 13:58:01 +03:00
Arseny 3e67937b8b [FIX] Makefile: дефолтная директория вывода изменена на . 2026-07-16 11:22:01 +03:00
Arseny d7a2b01be2 [UPDATE] даунгред Python 3.14 -> 3.12: Dockerfile, pyproject.toml, .python-version 2026-07-16 11:20:34 +03:00
Arseny 2917c3bca4 [ADD] поддержка LaTeX-математики: dollarmath plugin, math_inline, math_block 2026-07-16 09:23:50 +03:00
Arseny 876d6f28eb [FIX] md2gost.sh: дефолтная директория вывода изменена на . 2026-07-16 09:06:29 +03:00
Arseny c116d7d9af [UPDATE] рефакторинг: Unicode -> ASCII, README на русском, форматирование
- Unicode стрелки (->) -> ASCII (->)
- Добавлен README.md на русском языке
- from __future__ import annotations во всех файлах
- Исправлены ошибки mypy (Callable[..., str], Token import)
- make format + make tidy проходят чисто
2026-07-16 09:00:24 +03:00
Arseny 59ba0555bb [FIX] рефакторинг: C1-C4 критичные + W1-W10 предупреждения
C1: LaTeX escaping спецсимволов (& % $ # _ { } ~ ^ \)
C2: text.capitalize() → first char uppercase only
C3: run_xelatex проверяет returncode
C4: парсинг лога при ошибке второго прохода

W1: Удалены мёртвые счётчики section/subsection/subsubsection
W2: text.upper() вычисляется один раз
W3: Единая сигнатура хендлеров (r, tok) для всех
W4: Джэгged строки таблицы паддятся до max колонок
W5: Пустой lang в code fence → без [language=]
W6: hr() вынесен из code.py в hr.py
W7: Валидация обязательных полей front matter
W8: Имя вуза конфигурируется через front matter
W9: -> None аннотация main()
W10: \n в конце END_DOCUMENT

+ Dockerfile: добавлен texlive-fonts-recommended
2026-07-16 08:52:58 +03:00
Arseny 288a5682db [FIX] заголовки: уменьшен пробел между номером и текстом (\quad → пробел) 2026-07-16 08:31:01 +03:00
Arseny 74054aa780 [FIX] добавлен build/ в .gitignore 2026-07-16 08:20:43 +03:00
Arseny 08fc5d9aaa [UPDATE] интеграция: безопасные маунты, кэш в tmpdir, только .tex/.pdf 2026-07-16 08:20:35 +03:00
Arseny 3a1acafc24 [ADD] Times New Roman: установка шрифтов в Docker + fontconfig 2026-07-15 22:30:56 +03:00
Arseny 0c3c3496a8 [FIX] содержание: убран дублирующий заголовок, добавлен tocloft 2026-07-15 22:30:56 +03:00
Arseny b6f98e529f [FIX] сокращения: вторая колонка с переносом текста 2026-07-15 22:30:56 +03:00
Arseny 0b07fd4048 [FIX] нумерация страниц начинается с 2 2026-07-15 22:30:56 +03:00
Arseny 890d21fe9b [FIX] нумерация заголовков начинается с 1 2026-07-15 22:30:56 +03:00
25 changed files with 999 additions and 210 deletions
+5
View File
@@ -16,3 +16,8 @@ build/
# Report # Report
report.md report.md
report.tex
report.pdf
output/
build/
img/
+1 -1
View File
@@ -1 +1 @@
3.14 3.12
+18 -5
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.9.21 /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:0.9.21 /uv /uvx /bin/
@@ -6,11 +6,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-xetex \ texlive-xetex \
texlive-lang-cyrillic \ texlive-lang-cyrillic \
texlive-latex-extra \ texlive-latex-extra \
texlive-fonts-recommended \
fonts-liberation \ fonts-liberation \
cabextract \
wget \
fontconfig \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app RUN cd /tmp && \
COPY . . wget -q https://downloads.sourceforge.net/corefonts/times32.exe && \
RUN uv sync --frozen --no-dev cabextract times32.exe && \
mkdir -p /usr/share/fonts/truetype/msttcorefonts && \
cp -f *.TTF /usr/share/fonts/truetype/msttcorefonts/ && \
fc-cache -f && \
rm -f /tmp/*.TTF /tmp/times32.exe
ENTRYPOINT ["uv", "run", "--no-dev", "md2gost"] WORKDIR /build
COPY . .
RUN uv pip install --system --no-cache .
ENV UV_CACHE_DIR=/tmp/uv-cache
ENTRYPOINT ["md2gost"]
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build run stop format tidy .PHONY: build run stop format tidy test
IMAGE := md2gost IMAGE := md2gost
@@ -6,7 +6,7 @@ build:
docker build -t $(IMAGE) . docker build -t $(IMAGE) .
run: build run: build
./md2gost.sh report.md -o ./build ./md2gost.sh report.md -o .
stop: stop:
docker stop $(IMAGE) 2>/dev/null || true docker stop $(IMAGE) 2>/dev/null || true
@@ -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
+104
View File
@@ -0,0 +1,104 @@
# md2gost
Конвертер Markdown в LaTeX по ГОСТ Р 7.0.5-2008. Генерирует PDF-отчёты с титульным листом, содержанием, нумерацией и форматированием по ГОСТ.
## Установка
Требуется Docker. Локальная установка LaTeX не нужна.
```bash
docker build -t md2gost .
```
## Использование
```bash
./md2gost.sh report.md -o ./build
```
Скрипт создаст `build/report.tex` и `build/report.pdf`.
### Флаги
- `-o dir` -- выходная директория (по умолчанию `./build`)
- `--no-pdf` -- только .tex, без компиляции
## Формат Markdown
Используется front matter в начале файла:
```yaml
---
факультет: Информатика и системы управления
кафедра: Программные средства
тип: Отчёт по практике
название: Исследование алгоритмов оптимизации
студент: Иванов И.И.
преподаватель: Петров П.П.
группа: ИУ1-12
год: 2026
---
```
Обязательные поля: `название`, `тип`.
### Поддерживаемые элементы
- Заголовки (1-3 уровень) с нумерацией
- Параграфы, списки (маркированные и нумерованные)
- Таблицы
- Код с подсветкой синтаксиса
- Изображения (см. ниже)
- Аббревиатуры (раздел "Перечень принятых сокращений")
- Ссылки, жирный, курсив
### Изображения
Рисунок — вики-ссылка отдельным абзацем; подпись — следующий абзац вида
`Рисунок N <тире> Название` (номер отбрасывается, LaTeX нумерует сам):
```markdown
![[images/antenna.png]]
Рисунок 1 —— Антенна
Данная антенна используется для ...
```
Ширина задаётся суффиксом `|N` (доля от ширины текста):
```markdown
![[images/scheme.png|50]]
```
Файлы картинок копируются в каталог компиляции автоматически; эмблема
титульного листа ищется рядом со входным файлом или берётся из пакета.
Картинка внутри текстового абзаца не поддерживается (по ГОСТ рисунок
оформляется отдельным абзацем) и пропускается с предупреждением.
## Структура проекта
```
md2gost/
md2gost/
main.py # точка входа
parser.py # парсинг markdown (+ вики-ссылки картинок)
registry.py # реестры block/pattern/inline обработчиков
render.py # проход по токенам и диспетчеризация
handlers/ # обработчики блоков (декораторы @block/@pattern/@inline)
latex/ # преамбула, титульный лист
tests/ # pytest
md2gost.sh # обёртка для Docker
Dockerfile
Makefile
```
## Команды
```bash
make build # сборка Docker-образа
make run # сборка + конвертация report.md
make format # форматирование кода (black + ruff)
make tidy # статический анализ (ruff + mypy)
make test # pytest
```
+60 -4
View File
@@ -4,12 +4,68 @@ set -euo pipefail
IMAGE="md2gost" IMAGE="md2gost"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
usage() {
echo "Использование: md2gost file.md [-o dir]"
echo " file.md - исходный Markdown-файл"
echo " -o dir - выходная директория (по умолчанию .)"
exit 1
}
# Парсим аргументы
INPUT_FILE=""
OUTPUT_DIR="."
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
OUTPUT_DIR="${2:-}"
[[ -z "$OUTPUT_DIR" ]] && usage
shift 2
;;
-h|--help)
usage
;;
-*)
echo "неизвестный флаг: $1" >&2
usage
;;
*)
INPUT_FILE="$1"
shift
;;
esac
done
[[ -z "$INPUT_FILE" ]] && usage
# Проверяем входной файл
if [[ ! -f "$INPUT_FILE" ]]; then
echo "ошибка: $INPUT_FILE не найден" >&2
exit 1
fi
# Абсолютные пути
INPUT_ABS="$(realpath "$INPUT_FILE")"
OUTPUT_ABS="$(realpath -m "$OUTPUT_DIR")"
mkdir -p "$OUTPUT_ABS"
INPUT_BASE="$(basename "$INPUT_FILE")"
INPUT_DIR="$(dirname "$INPUT_ABS")"
# Собираем образ если нужно
if ! docker image inspect "$IMAGE" &>/dev/null; then if ! docker image inspect "$IMAGE" &>/dev/null; then
echo " образ $IMAGE не найден, собираю..." echo "образ $IMAGE не найден, собираю..."
docker build -t "$IMAGE" "$SCRIPT_DIR" docker build -t "$IMAGE" "$SCRIPT_DIR"
fi fi
echo "-> конвертация $INPUT_FILE -> $OUTPUT_DIR/"
# Запуск контейнера:
# - вход: директория с .md и картинками (read-only, нужен для img/)
# - выход: директория для .tex и .pdf
# - user: текущий пользователь (не root)
docker run --rm \ docker run --rm \
-e UV_CACHE_DIR=/tmp/uv-cache \ -v "$INPUT_DIR:/input:ro" \
-v "$(pwd):/app" \ -v "$OUTPUT_ABS:/output" \
"$IMAGE" "$@" --user "$(id -u):$(id -g)" \
"$IMAGE" "/input/$INPUT_BASE" -o /output
+7 -3
View File
@@ -1,7 +1,9 @@
from md2gost.handlers.code import fence, hr from md2gost.handlers.code import fence
from md2gost.handlers.heading import heading from md2gost.handlers.heading import heading
from md2gost.handlers.image import image from md2gost.handlers.hr import hr
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.table import table from md2gost.handlers.table import table
from md2gost.handlers.text import paragraph from md2gost.handlers.text import paragraph
@@ -9,8 +11,10 @@ __all__ = [
"fence", "fence",
"hr", "hr",
"heading", "heading",
"image", "figure",
"match_figure",
"bullet_list", "bullet_list",
"math_block",
"ordered_list", "ordered_list",
"table", "table",
"paragraph", "paragraph",
+7 -10
View File
@@ -1,17 +1,14 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from markdown_it.token import Token
if TYPE_CHECKING: from md2gost.registry import block
from markdown_it.token import Token
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()
return f"\\begin{{lstlisting}}[language={lang}]\n{code}\n\\end{{lstlisting}}" opts = f"[language={lang}]" if lang else ""
return f"\\begin{{lstlisting}}{opts}\n{code}\n\\end{{lstlisting}}"
def hr(r: Renderer) -> str:
return r"\noindent\rule{\textwidth}{0.4pt}"
+25 -26
View File
@@ -1,21 +1,20 @@
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 md2gost.render import Renderer
UNNUMBERED = { UNNUMBERED = {
"АННОТАЦИЯ", "АННОТАЦИЯ",
"СОДЕРЖАНИЕ",
"ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ", "ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ",
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
} }
TOC_HEADING = "СОДЕРЖАНИЕ"
NEWPAGE_BEFORE = { NEWPAGE_BEFORE = {
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
@@ -23,39 +22,39 @@ NEWPAGE_BEFORE = {
} }
def heading(r: Renderer) -> 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.current_heading = text.upper()
r.pos += 2 r.pos += 2
if text.upper() in UNNUMBERED: upper = text.upper()
if upper == TOC_HEADING:
r.pending_toc = True
return ""
r.current_heading = upper
if upper in UNNUMBERED:
prefix = "" prefix = ""
if text.upper() in NEWPAGE_BEFORE: if upper in NEWPAGE_BEFORE:
prefix = "\\clearpage\n" prefix = "\\clearpage\n"
toc_entry = ""
if upper in NEWPAGE_BEFORE:
toc_entry = f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}"
return ( return (
f"{prefix}" f"{prefix}"
f"\\begin{{center}}\n" f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}"
f"\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries {text}\n" f"{toc_entry}"
f"\\end{{center}}"
) )
if level == 1: if level == 1:
r.section += 1 return f"\\section{{{text}}}"
r.subsection = 0
r.subsubsection = 0
num = f"{r.section}. "
elif level == 2: elif level == 2:
r.subsection += 1 return f"\\subsection{{{text}}}"
r.subsubsection = 0
num = f"{r.section}.{r.subsection} "
elif level == 3: elif level == 3:
r.subsubsection += 1 return f"\\subsubsection{{{text}}}"
num = f"{r.section}.{r.subsection}.{r.subsubsection} "
else: else:
num = "" return f"\\textbf{{{text}}}"
return f"\\textbf{{{num}{text}}}"
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from markdown_it.token import Token
from md2gost.registry import block
@block("hr")
def hr(r, tok: Token) -> str:
r.pos += 1
return r"\noindent\rule{\textwidth}{0.4pt}"
+97 -9
View File
@@ -1,15 +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 md2gost.handlers.inline import render_inline from markdown_it.token import Token
if TYPE_CHECKING: from md2gost.handlers.inline import escape_latex
from markdown_it.token import Token from md2gost.registry import pattern
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: 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}[H]",
" \\centering",
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
]
if caption:
lines.append(f" \\caption{{{caption}}}")
lines.append("\\end{figure}")
return "\n".join(lines)
+96 -24
View File
@@ -1,39 +1,111 @@
"""Инлайн-рендер: обработка токенов внутри абзацев и заголовков.
Диспетчеризация табличная (registry.INLINE): тип токена -> функция
``(tok) -> str``. Новый инлайн-узел = одна функция с декоратором.
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING import sys
if TYPE_CHECKING: from markdown_it.token import Token
from markdown_it.token import Token
from md2gost.registry import INLINE, inline
_LATEX_SPECIAL = str.maketrans(
{
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
"\\": r"\textbackslash{}",
}
)
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"\\texttt{{\\small {escape_latex(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 tok.content or "" return escape_latex(tok.content or "")
return render_inline_tokens(tok.children) return render_inline_tokens(tok.children)
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(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 = tok.attrGet("href") or ""
parts.append(f"\\href{{{href}}}{{")
elif tok.type == "link_close":
parts.append("}")
elif tok.type in ("softbreak", "hardbreak"):
parts.append("~\\\\")
else: else:
parts.append(tok.content if tok.content else "") parts.append(escape_latex(tok.content) if tok.content else "")
return "".join(parts) return "".join(parts)
+11 -11
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, render_inline_tokens from md2gost.handlers.inline import render_inline_tokens
from md2gost.registry import block
if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer
def bullet_list(r: Renderer) -> 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:
@@ -22,7 +20,8 @@ def bullet_list(r: Renderer) -> str:
return f"\\begin{{itemize}}\n{body}\n\\end{{itemize}}" return f"\\begin{{itemize}}\n{body}\n\\end{{itemize}}"
def ordered_list(r: Renderer) -> 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:
@@ -31,12 +30,13 @@ def ordered_list(r: Renderer) -> str:
text = text.rstrip(".") text = text.rstrip(".")
lines.append(f" \\item {text}.") lines.append(f" \\item {text}.")
else: else:
lines.append(f" \\item {text.capitalize()}.") first = text[0].upper() if text else text
lines.append(f" \\item {first}{text[1:]}.")
body = "\n".join(lines) body = "\n".join(lines)
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):
@@ -54,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]
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from markdown_it.token import Token
from md2gost.registry import block
@block("math_block")
def math_block(r, tok: Token) -> str:
r.pos += 1
content = tok.content.strip()
return f"\\[\n{content}\n\\]"
+8 -6
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 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 table(r: Renderer) -> 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):
@@ -40,7 +39,10 @@ def table(r: Renderer) -> str:
if not rows: if not rows:
return "" return ""
cols = len(rows[0]) cols = max(len(row) for row in rows)
for row in rows:
row.extend([""] * (cols - len(row)))
col_spec = "|".join(["l"] * cols) col_spec = "|".join(["l"] * cols)
lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"] lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"]
for row in rows: for row in rows:
+10 -8
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 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) -> 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
@@ -35,11 +34,14 @@ def abbreviations(text: str) -> str:
return text return text
max_abbr = max(len(r[0]) for r in rows) max_abbr = max(len(r[0]) for r in rows)
abbr_col = max_abbr + 2
tabular_rows = [] tabular_rows = []
for abbr, desc in rows: for abbr, desc in rows:
pad = max_abbr - len(abbr) + 2 pad = max_abbr - len(abbr) + 2
tabular_rows.append(f"{abbr} {' ' * pad}& --- {desc} \\\\") tabular_rows.append(
f"{abbr} {' ' * pad}& \\hangafter=1\\hangindent=2em ------ {desc} \\\\"
)
col_spec = f"@{{\\hspace{{0pt}}}}p{{{max_abbr + 2}em}}@{{\\hspace{{0pt}}}}l" col_spec = f"@{{\\hspace{{0pt}}}}p{{{abbr_col}em}}@{{\\hspace{{0pt}}}}p{{\\dimexpr\\linewidth-{abbr_col}em\\relax}}"
body = "\n".join(tabular_rows) body = "\n".join(tabular_rows)
return f"\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}" return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
+35 -3
View File
@@ -2,7 +2,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage{geometry} \usepackage{geometry}
\geometry{a4paper,tmargin=2cm,bmargin=2cm,lmargin=3cm,rmargin=1.5cm} \geometry{a4paper,tmargin=2cm,bmargin=2cm,lmargin=3cm,rmargin=1.5cm}
\usepackage{fontspec} \usepackage{fontspec}
\setmainfont{Liberation Serif} \setmainfont{Times New Roman}
\setsansfont{Liberation Sans} \setsansfont{Liberation Sans}
\setmonofont{Liberation Mono} \setmonofont{Liberation Mono}
\usepackage[english, main=russian]{babel} \usepackage[english, main=russian]{babel}
@@ -13,17 +13,19 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\onehalfspacing \onehalfspacing
\usepackage{graphicx} \usepackage{graphicx}
\graphicspath{ {./} {./images/} } \graphicspath{ {./} {./images/} }
\usepackage{float}
\usepackage{wrapfig} \usepackage{wrapfig}
\sloppy \sloppy
\clubpenalty=10000 \clubpenalty=10000
\widowpenalty=10000 \widowpenalty=10000
\usepackage{enumitem} \usepackage{enumitem}
\setlist[enumerate,1]{label=\arabic*., leftmargin=1.25cm, itemsep=0pt, parsep=0pt} \setlist[enumerate,1]{label=\arabic*., leftmargin=1.25cm, itemsep=0pt, parsep=0pt}
\setlist[itemize]{label={---}, leftmargin=1.25cm, itemsep=0pt, parsep=0pt}
\usepackage{caption} \usepackage{caption}
\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,
@@ -49,9 +51,39 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage[perpage]{footmisc} \usepackage[perpage]{footmisc}
\usepackage{tabularx} \usepackage{tabularx}
\usepackage{titlesec} \usepackage{titlesec}
\renewcommand{\thesection}{\arabic{section}}
\renewcommand{\thesubsection}{\thesection.\arabic{subsection}}
\renewcommand{\thesubsubsection}{\thesubsection.\arabic{subsubsection}}
\titleformat{\section}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesection. }
\titleformat{\subsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsection. }
\titleformat{\subsubsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsubsection. }
\titlespacing*{\section}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\section}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex}
\titlespacing*{\subsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\subsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex}
\titlespacing*{\subsubsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\subsubsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex}
\usepackage{tocloft}
\addto\captionsrussian{%
\renewcommand{\contentsname}{СОДЕРЖАНИЕ}%
}
\renewcommand{\cfttoctitlefont}{\hfill\fontsize{14pt}{18pt}\selectfont\bfseries}
\renewcommand{\cftaftertoctitle}{\hfill\null}
\setlength{\cftbeforesecskip}{0pt}
\renewcommand{\cftsecaftersnum}{.}
\renewcommand{\cftsubsecaftersnum}{.}
\renewcommand{\cftsubsubsecaftersnum}{.}
\renewcommand{\cftsecleader}{\cftdotfill{\cftdotsep}}
\renewcommand{\cftdotsep}{1}
\cftsetpnumwidth{0.7em}
\cftsetrmarg{1em}
\renewcommand{\cftsecfont}{\mdseries}
\renewcommand{\cftsecpagefont}{\mdseries}
\renewcommand{\cftsubsecfont}{\mdseries}
\renewcommand{\cftsubsubsecfont}{\mdseries}
\setlength{\cftsecindent}{0em}
\setlength{\cftsecnumwidth}{1em}
\setlength{\cftsubsecindent}{0em}
\setlength{\cftsubsecnumwidth}{1.5em}
\setlength{\cftsubsubsecindent}{0em}
\setlength{\cftsubsubsecnumwidth}{2.1em}
\newcommand{\sigline}[3][4cm]{% \newcommand{\sigline}[3][4cm]{%
\begin{tabular}[t]{@{}c@{}}% \begin{tabular}[t]{@{}c@{}}%
@@ -63,4 +95,4 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\begin{document}""" \begin{document}"""
END_DOCUMENT = r"\end{document}" END_DOCUMENT = r"\end{document}" + "\n"
+22 -15
View File
@@ -1,31 +1,38 @@
from __future__ import annotations from __future__ import annotations
from md2gost.handlers.inline import escape_latex
def render_titlepage(fm: dict[str, str]) -> str: def render_titlepage(fm: dict[str, str]) -> str:
faculty = fm.get("факультет", "") required = {"название": "заголовок работы", "тип": "тип работы"}
department = fm.get("кафедра", "") for key, desc in required.items():
report_type = fm.get("тип", "") if key not in fm:
title = fm.get("название", "") raise ValueError(f"front matter: отсутствует поле '{key}' ({desc})")
student = fm.get("студент", "")
advisor = fm.get("преподаватель", "") faculty = escape_latex(fm.get("факультет", ""))
group = fm.get("группа", "") department = escape_latex(fm.get("кафедра", ""))
year = fm.get("год", "2026") report_type = escape_latex(fm.get("тип", ""))
title = escape_latex(fm.get("название", ""))
student = escape_latex(fm.get("студент", ""))
advisor = escape_latex(fm.get("преподаватель", ""))
group = escape_latex(fm.get("группа", ""))
year = escape_latex(fm.get("год", "2026"))
ministry_lines = ( ministry_lines = (
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\hspace*{-6pt}\\centerline{Министерство науки и высшего образования Российской Федерации}\n" f"\\hspace*{{-6pt}}\\centerline{{{fm.get('министерство', 'Министерство науки и высшего образования Российской Федерации')}}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{Федеральное государственное бюджетное образовательное учреждение }\n" f"\\centerline{{{fm.get('вуз_тип', 'Федеральное государственное бюджетное образовательное учреждение ')}}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{высшего образования}\n" f"\\centerline{{{fm.get('вуз_тип2', 'высшего образования')}}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{<<Московский государственный технический университет}\n" f"\\centerline{{<<{fm.get('вуз', 'Московский государственный технический университет')}}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{имени Н.Э. Баумана>>}\n" f"\\centerline{{имени Н.Э. Баумана>>}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{(национальный исследовательский университет)}\n" f"\\centerline{{({fm.get('вуз_статус', 'национальный исследовательский университет')}}}\n"
"\\vspace*{-0.7ex}\n" "\\vspace*{-0.7ex}\n"
"\\centerline{(МГТУ им. Н.Э. Баумана)}" f"\\centerline{{({fm.get('вуз_аббревиатура', 'МГТУ им. Н.Э. Баумана')}}}"
) )
titlepage = ( titlepage = (
+85 -26
View File
@@ -1,34 +1,88 @@
import argparse import argparse
import shutil
import subprocess import subprocess
import sys import sys
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
def _print_latex_errors(log_path: Path) -> None:
if log_path.exists():
for line in log_path.read_text(errors="replace").splitlines():
if line.startswith("!"):
print(f" {line}", file=sys.stderr)
def run_xelatex(tex_path: Path, output_dir: Path) -> bool: def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
result = subprocess.run( result = subprocess.run(
["xelatex", "-interaction=nonstopmode", [
"-output-directory", str(output_dir), str(tex_path)], "xelatex",
capture_output=True, text=True, "-interaction=nonstopmode",
"-output-directory",
str(output_dir),
str(tex_path),
],
# Относительные пути картинок резолвятся от CWD, поэтому компилируем
# из каталога, куда _copy_assets сложил все изображения.
cwd=str(output_dir),
capture_output=True,
text=True,
) )
pdf_path = output_dir / tex_path.with_suffix(".pdf").name pdf_path = output_dir / tex_path.with_suffix(".pdf").name
return pdf_path.exists() return pdf_path.exists() and result.returncode == 0
def main(): 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( cli = argparse.ArgumentParser(
prog="md2gost", prog="md2gost",
description="Markdown LaTeX конвертер по ГОСТ Р 7.0.5-2008", description="Markdown -> LaTeX конвертер по ГОСТ Р 7.0.5-2008",
) )
cli.add_argument("input", type=Path, help="путь к .md файлу") cli.add_argument("input", type=Path, help="путь к .md файлу")
cli.add_argument( cli.add_argument(
"-o", "--output", type=Path, default=Path("."), "-o",
"--output",
type=Path,
default=Path("."),
help="выходная директория (по умолчанию текущая)", help="выходная директория (по умолчанию текущая)",
) )
cli.add_argument( cli.add_argument(
"--no-pdf", action="store_true", "--no-pdf",
action="store_true",
help="только .tex, без компиляции в PDF", help="только .tex, без компиляции в PDF",
) )
args = cli.parse_args() args = cli.parse_args()
@@ -44,27 +98,32 @@ def main():
renderer = Renderer(parser.tokens, parser.front_matter) renderer = Renderer(parser.tokens, parser.front_matter)
latex = renderer.render() latex = renderer.render()
tex_path = args.output / args.input.with_suffix(".tex").name with tempfile.TemporaryDirectory(prefix="md2gost-") as cache:
tex_path.write_text(latex, encoding="utf-8") cache_dir = Path(cache)
print(f"{tex_path}")
if not args.no_pdf: tex_name = args.input.with_suffix(".tex").name
pdf_path = args.output / args.input.with_suffix(".pdf").name tex_path = cache_dir / tex_name
log_path = args.output / "report.log" tex_path.write_text(latex, encoding="utf-8")
if not run_xelatex(tex_path, args.output): _copy_assets(parser, args.input.parent, cache_dir)
print("ошибка: xelatex не собрал PDF", file=sys.stderr)
if log_path.exists():
for line in log_path.read_text(errors="replace").splitlines():
if line.startswith("!"):
print(f" {line}", file=sys.stderr)
sys.exit(1)
if not run_xelatex(tex_path, args.output): final_tex = args.output / tex_name
print("ошибка: xelatex (2-й проход) не собрал PDF", file=sys.stderr) shutil.copy2(tex_path, final_tex)
sys.exit(1) print(f"-> {final_tex}")
print(f"{pdf_path}") if not args.no_pdf:
log_path = cache_dir / tex_path.with_suffix(".log").name
pdf_name = args.input.with_suffix(".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(cache_dir / pdf_name, final_pdf)
print(f"-> {final_pdf}")
if __name__ == "__main__": if __name__ == "__main__":
+35 -1
View File
@@ -1,6 +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.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:
@@ -9,8 +25,9 @@ class Parser:
MarkdownIt("commonmark", {"breaks": True, "html": True}) MarkdownIt("commonmark", {"breaks": True, "html": True})
.enable("table") .enable("table")
.use(front_matter_plugin) .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() self.front_matter = self._extract_front_matter()
def _extract_front_matter(self) -> dict[str, str]: def _extract_front_matter(self) -> dict[str, str]:
@@ -23,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
+63 -54
View File
@@ -1,47 +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 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,
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 = "СОДЕРЖАНИЕ"
def _fence(r, tok):
return fence(r, tok)
def _image(r, tok):
return image(r, tok)
def _inline(r, tok):
return render_inline(tok)
_BLOCK = {
"heading_open": heading,
"paragraph_open": paragraph,
"bullet_list_open": bullet_list,
"ordered_list_open": ordered_list,
"table_open": table,
"hr": hr,
}
_INLINE = {
"fence": _fence,
"image": _image,
"inline": _inline,
}
class Renderer: class Renderer:
@@ -49,32 +40,50 @@ class Renderer:
self.tokens = tokens self.tokens = tokens
self.fm = front_matter self.fm = front_matter
self.pos = 0 self.pos = 0
self.section = 1
self.subsection = 0
self.subsubsection = 0
self.current_heading = "" self.current_heading = ""
self.pending_toc = False
def render(self) -> str: def render(self) -> str:
parts: list[str] = [PREAMBLE, render_titlepage(self.fm)] parts: list[str] = [
PREAMBLE,
render_titlepage(self.fm),
"\\setcounter{page}{2}",
]
while self.pos < len(self.tokens): while self.pos < len(self.tokens):
tok = self.tokens[self.pos] if self.pending_toc:
prev_pos = self.pos parts.append("\\newpage\n\\tableofcontents\n\\newpage")
tt = tok.type self.pending_toc = False
rendered = self._dispatch()
parts.append(rendered)
if tt in _BLOCK: # TOC в конце файла (если # СОДЕРЖАНИЕ был последним блоком)
result = _BLOCK[tt](self) if self.pending_toc:
parts.append(result) parts.append("\\newpage\n\\tableofcontents\n\\newpage")
if self.current_heading == "СОДЕРЖАНИЕ":
parts.append("\\newpage\n\\tableofcontents\n\\newpage")
elif tt in _INLINE:
parts.append(_INLINE[tt](self, tok))
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
+3 -1
View File
@@ -4,12 +4,13 @@ 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"
version = "0.1.0" version = "0.1.0"
description = "Markdown → LaTeX конвертер по ГОСТ Р 7.0.5-2008" description = "Markdown → LaTeX конвертер по ГОСТ Р 7.0.5-2008"
requires-python = ">=3.14" requires-python = ">=3.12"
dependencies = [ dependencies = [
"markdown-it-py[plugins]>=4.2.0", "markdown-it-py[plugins]>=4.2.0",
] ]
@@ -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
+107 -1
View File
@@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15'", "python_full_version >= '3.15'",
"python_full_version < '3.15'", "python_full_version < '3.15'",
@@ -61,6 +61,16 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
{ url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
{ url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
{ url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
{ url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
{ url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
{ url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
{ url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
{ url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
@@ -90,12 +100,48 @@ 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"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
{ url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" },
{ url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" },
{ url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" },
{ url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" },
{ url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
@@ -154,6 +200,7 @@ dependencies = [
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -164,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" },
] ]
@@ -201,6 +249,20 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" },
{ url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" },
{ url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" },
{ url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" },
{ url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" },
{ url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
{ url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
@@ -255,12 +317,56 @@ 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"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
{ url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
{ url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
{ url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },