Compare commits

..

10 Commits

Author SHA1 Message Date
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
17 changed files with 357 additions and 121 deletions
+2
View File
@@ -16,3 +16,5 @@ build/
# Report # Report
report.md report.md
output/
build/
+17 -4
View File
@@ -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"]
+77
View File
@@ -0,0 +1,77 @@
# 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 уровень) с нумерацией
- Параграфы, списки (маркированные и нумерованные)
- Таблицы
- Код с подсветкой синтаксиса
- Изображения
- Аббревиатуры (раздел "Перечень принятых сокращений")
- Ссылки, жирный, курсив
## Структура проекта
```
md2gost/
md2gost/
main.py # точка входа
parser.py # парсинг markdown
render.py # рендер в LaTeX
handlers/ # обработчики блоков
latex/ # преамбула, титульный лист
md2gost.sh # обёртка для Docker
Dockerfile
Makefile
```
## Команды
```bash
make build # сборка Docker-образа
make run # сборка + конвертация report.md
make format # форматирование кода (black + ruff)
make tidy # статический анализ (ruff + mypy)
```
+59 -4
View File
@@ -4,12 +4,67 @@ 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 - выходная директория (по умолчанию ./build)"
exit 1
}
# Парсим аргументы
INPUT_FILE=""
OUTPUT_DIR="./build"
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")"
# Собираем образ если нужно
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)
# - выход: директория для .tex и .pdf
# - user: текущий пользователь (не root)
docker run --rm \ docker run --rm \
-e UV_CACHE_DIR=/tmp/uv-cache \ -v "$INPUT_ABS:/input/$INPUT_BASE:ro" \
-v "$(pwd):/app" \ -v "$OUTPUT_ABS:/output" \
"$IMAGE" "$@" --user "$(id -u):$(id -g)" \
"$IMAGE" "/input/$INPUT_BASE" -o /output
+2 -1
View File
@@ -1,5 +1,6 @@
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.hr import hr
from md2gost.handlers.image import image from md2gost.handlers.image import image
from md2gost.handlers.lists import bullet_list, ordered_list from md2gost.handlers.lists import bullet_list, ordered_list
from md2gost.handlers.table import table from md2gost.handlers.table import table
+4 -6
View File
@@ -2,16 +2,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from markdown_it.token import Token
if TYPE_CHECKING: if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer from md2gost.render import Renderer
def fence(r: Renderer, tok: Token) -> str: def fence(r: Renderer, tok: Token) -> str:
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}"
+17 -20
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -9,13 +11,14 @@ if TYPE_CHECKING:
UNNUMBERED = { UNNUMBERED = {
"АННОТАЦИЯ", "АННОТАЦИЯ",
"СОДЕРЖАНИЕ",
"ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ", "ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ",
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
} }
TOC_HEADING = "СОДЕРЖАНИЕ"
NEWPAGE_BEFORE = { NEWPAGE_BEFORE = {
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
@@ -23,39 +26,33 @@ NEWPAGE_BEFORE = {
} }
def heading(r: Renderer) -> str: def heading(r: Renderer, tok: Token | None = None) -> str:
tok = r.tokens[r.pos] tok = r.tokens[r.pos]
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() upper = text.upper()
r.current_heading = upper
r.pos += 2 r.pos += 2
if text.upper() in UNNUMBERED: if upper == TOC_HEADING:
return ""
if upper in UNNUMBERED:
prefix = "" prefix = ""
if text.upper() in NEWPAGE_BEFORE: if upper in NEWPAGE_BEFORE:
prefix = "\\clearpage\n" prefix = "\\clearpage\n"
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"\\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}}}"
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from markdown_it.token import Token
if TYPE_CHECKING:
from md2gost.render import Renderer
def hr(r: Renderer, tok: Token | None = None) -> str:
return r"\noindent\rule{\textwidth}{0.4pt}"
+2 -1
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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
if TYPE_CHECKING: if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer from md2gost.render import Renderer
+24 -8
View File
@@ -1,14 +1,30 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from markdown_it.token import Token
if TYPE_CHECKING: _LATEX_SPECIAL = str.maketrans(
from markdown_it.token import Token {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
"\\": r"\textbackslash{}",
}
)
def escape_latex(text: str) -> str:
return text.translate(_LATEX_SPECIAL)
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)
@@ -16,7 +32,7 @@ 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": if tok.type == "text":
parts.append(tok.content) parts.append(escape_latex(tok.content))
elif tok.type == "strong_open": elif tok.type == "strong_open":
parts.append(r"\textbf{") parts.append(r"\textbf{")
elif tok.type == "strong_close": elif tok.type == "strong_close":
@@ -28,12 +44,12 @@ def render_inline_tokens(tokens: list[Token]) -> str:
elif tok.type == "code_inline": elif tok.type == "code_inline":
parts.append(f"\\verb|{tok.content}|") parts.append(f"\\verb|{tok.content}|")
elif tok.type == "link_open": elif tok.type == "link_open":
href = tok.attrGet("href") or "" href = str(tok.attrGet("href") or "")
parts.append(f"\\href{{{href}}}{{") parts.append(f"\\href{{{escape_latex(href)}}}{{")
elif tok.type == "link_close": elif tok.type == "link_close":
parts.append("}") parts.append("}")
elif tok.type in ("softbreak", "hardbreak"): elif tok.type in ("softbreak", "hardbreak"):
parts.append("~\\\\") 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)
+7 -5
View File
@@ -2,14 +2,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from md2gost.handlers.inline import render_inline, render_inline_tokens from markdown_it.token import Token
from md2gost.handlers.inline import render_inline_tokens
if TYPE_CHECKING: if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer from md2gost.render import Renderer
def bullet_list(r: Renderer) -> str: def bullet_list(r: Renderer, tok: Token | None = None) -> str:
items = _collect_list_items(r) items = _collect_list_items(r)
lines = [] lines = []
for item in items: for item in items:
@@ -22,7 +23,7 @@ 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: def ordered_list(r: Renderer, tok: Token | None = None) -> str:
items = _collect_list_items(r) items = _collect_list_items(r)
lines = [] lines = []
for item in items: for item in items:
@@ -31,7 +32,8 @@ 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}}"
+6 -2
View File
@@ -5,10 +5,11 @@ from typing import TYPE_CHECKING
from md2gost.handlers.inline import render_inline from md2gost.handlers.inline import render_inline
if TYPE_CHECKING: if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer from md2gost.render import Renderer
def table(r: Renderer) -> str: def table(r: Renderer, tok: Token | None = None) -> 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 +41,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:
+9 -4
View File
@@ -2,13 +2,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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
if TYPE_CHECKING: if TYPE_CHECKING:
from md2gost.render import Renderer from md2gost.render import Renderer
def paragraph(r: Renderer) -> str: def paragraph(r: Renderer, tok: Token | None = None) -> 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 +37,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}}"
+32 -2
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}
@@ -49,9 +49,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 +93,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 = (
+50 -25
View File
@@ -1,34 +1,53 @@
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 from md2gost.parser import Parser
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),
],
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 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 +63,33 @@ 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): final_tex = args.output / tex_name
print("ошибка: xelatex не собрал PDF", file=sys.stderr) shutil.copy2(tex_path, final_tex)
if log_path.exists(): print(f"-> {final_tex}")
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): if not args.no_pdf:
print("ошибка: xelatex (2-й проход) не собрал PDF", file=sys.stderr) if not run_xelatex(tex_path, cache_dir):
sys.exit(1) print("ошибка: xelatex не собрал PDF", file=sys.stderr)
_print_latex_errors(cache_dir / "report.log")
sys.exit(1)
print(f"{pdf_path}") 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_path = cache_dir / pdf_name
final_pdf = args.output / pdf_name
shutil.copy2(pdf_path, final_pdf)
print(f"-> {final_pdf}")
if __name__ == "__main__": if __name__ == "__main__":
+15 -24
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable
from md2gost.handlers import ( from md2gost.handlers import (
bullet_list, bullet_list,
fence, fence,
@@ -15,20 +17,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
from markdown_it.token import Token from markdown_it.token import Token
_BLOCK: dict[str, Callable[..., str]] = {
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, "heading_open": heading,
"paragraph_open": paragraph, "paragraph_open": paragraph,
"bullet_list_open": bullet_list, "bullet_list_open": bullet_list,
@@ -37,10 +26,10 @@ _BLOCK = {
"hr": hr, "hr": hr,
} }
_INLINE = { _INLINE: dict[str, Callable[..., str]] = {
"fence": _fence, "fence": fence,
"image": _image, "image": image,
"inline": _inline, "inline": render_inline,
} }
@@ -49,13 +38,14 @@ 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 = ""
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] tok = self.tokens[self.pos]
@@ -63,12 +53,13 @@ class Renderer:
tt = tok.type tt = tok.type
if tt in _BLOCK: if tt in _BLOCK:
result = _BLOCK[tt](self) result = _BLOCK[tt](self, tok)
parts.append(result) parts.append(result)
if self.current_heading == "СОДЕРЖАНИЕ": if self.current_heading == "СОДЕРЖАНИЕ":
parts.append("\\newpage\n\\tableofcontents\n\\newpage") parts.append("\\newpage\n\\tableofcontents\n\\newpage")
elif tt in _INLINE: elif tt in _INLINE:
parts.append(_INLINE[tt](self, tok)) result = _INLINE[tt](self, tok)
parts.append(result)
else: else:
self.pos += 1 self.pos += 1
continue continue