Compare commits
10 Commits
ca3571942f
...
c116d7d9af
| Author | SHA1 | Date | |
|---|---|---|---|
| c116d7d9af | |||
| 59ba0555bb | |||
| 288a5682db | |||
| 74054aa780 | |||
| 08fc5d9aaa | |||
| 3a1acafc24 | |||
| 0c3c3496a8 | |||
| b6f98e529f | |||
| 0b07fd4048 | |||
| 890d21fe9b |
@@ -16,3 +16,5 @@ build/
|
||||
|
||||
# Report
|
||||
report.md
|
||||
output/
|
||||
build/
|
||||
|
||||
+17
-4
@@ -6,11 +6,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
texlive-xetex \
|
||||
texlive-lang-cyrillic \
|
||||
texlive-latex-extra \
|
||||
texlive-fonts-recommended \
|
||||
fonts-liberation \
|
||||
cabextract \
|
||||
wget \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN uv sync --frozen --no-dev
|
||||
RUN cd /tmp && \
|
||||
wget -q https://downloads.sourceforge.net/corefonts/times32.exe && \
|
||||
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"]
|
||||
|
||||
@@ -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
@@ -4,12 +4,67 @@ set -euo pipefail
|
||||
IMAGE="md2gost"
|
||||
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
|
||||
echo " образ $IMAGE не найден, собираю..."
|
||||
echo "образ $IMAGE не найден, собираю..."
|
||||
docker build -t "$IMAGE" "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
echo "-> конвертация $INPUT_FILE -> $OUTPUT_DIR/"
|
||||
|
||||
# Запуск контейнера:
|
||||
# - вход: только .md файл (read-only)
|
||||
# - выход: директория для .tex и .pdf
|
||||
# - user: текущий пользователь (не root)
|
||||
docker run --rm \
|
||||
-e UV_CACHE_DIR=/tmp/uv-cache \
|
||||
-v "$(pwd):/app" \
|
||||
"$IMAGE" "$@"
|
||||
-v "$INPUT_ABS:/input/$INPUT_BASE:ro" \
|
||||
-v "$OUTPUT_ABS:/output" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
"$IMAGE" "/input/$INPUT_BASE" -o /output
|
||||
|
||||
@@ -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.hr import hr
|
||||
from md2gost.handlers.image import image
|
||||
from md2gost.handlers.lists import bullet_list, ordered_list
|
||||
from md2gost.handlers.table import table
|
||||
|
||||
@@ -2,16 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markdown_it.token import Token
|
||||
from md2gost.render import Renderer
|
||||
|
||||
|
||||
def fence(r: Renderer, tok: Token) -> str:
|
||||
lang = tok.info.strip()
|
||||
code = tok.content.rstrip()
|
||||
return f"\\begin{{lstlisting}}[language={lang}]\n{code}\n\\end{{lstlisting}}"
|
||||
|
||||
|
||||
def hr(r: Renderer) -> str:
|
||||
return r"\noindent\rule{\textwidth}{0.4pt}"
|
||||
opts = f"[language={lang}]" if lang else ""
|
||||
return f"\\begin{{lstlisting}}{opts}\n{code}\n\\end{{lstlisting}}"
|
||||
|
||||
+17
-20
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
from md2gost.handlers.inline import render_inline
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -9,13 +11,14 @@ if TYPE_CHECKING:
|
||||
|
||||
UNNUMBERED = {
|
||||
"АННОТАЦИЯ",
|
||||
"СОДЕРЖАНИЕ",
|
||||
"ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ",
|
||||
"ВВЕДЕНИЕ",
|
||||
"ЗАКЛЮЧЕНИЕ",
|
||||
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
|
||||
}
|
||||
|
||||
TOC_HEADING = "СОДЕРЖАНИЕ"
|
||||
|
||||
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]
|
||||
level = int(tok.tag[1])
|
||||
inline_tok = r.tokens[r.pos + 1]
|
||||
text = render_inline(inline_tok)
|
||||
|
||||
r.current_heading = text.upper()
|
||||
upper = text.upper()
|
||||
r.current_heading = upper
|
||||
r.pos += 2
|
||||
|
||||
if text.upper() in UNNUMBERED:
|
||||
if upper == TOC_HEADING:
|
||||
return ""
|
||||
|
||||
if upper in UNNUMBERED:
|
||||
prefix = ""
|
||||
if text.upper() in NEWPAGE_BEFORE:
|
||||
if upper in NEWPAGE_BEFORE:
|
||||
prefix = "\\clearpage\n"
|
||||
return (
|
||||
f"{prefix}"
|
||||
f"\\begin{{center}}\n"
|
||||
f"\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries {text}\n"
|
||||
f"\\end{{center}}"
|
||||
f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}"
|
||||
)
|
||||
|
||||
if level == 1:
|
||||
r.section += 1
|
||||
r.subsection = 0
|
||||
r.subsubsection = 0
|
||||
num = f"{r.section}. "
|
||||
return f"\\section{{{text}}}"
|
||||
elif level == 2:
|
||||
r.subsection += 1
|
||||
r.subsubsection = 0
|
||||
num = f"{r.section}.{r.subsection} "
|
||||
return f"\\subsection{{{text}}}"
|
||||
elif level == 3:
|
||||
r.subsubsection += 1
|
||||
num = f"{r.section}.{r.subsection}.{r.subsubsection} "
|
||||
return f"\\subsubsection{{{text}}}"
|
||||
else:
|
||||
num = ""
|
||||
|
||||
return f"\\textbf{{{num}{text}}}"
|
||||
return f"\\textbf{{{text}}}"
|
||||
|
||||
@@ -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,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
from md2gost.handlers.inline import render_inline
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markdown_it.token import Token
|
||||
from md2gost.render import Renderer
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from markdown_it.token import Token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markdown_it.token import Token
|
||||
_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)
|
||||
|
||||
|
||||
def render_inline(tok: Token) -> str:
|
||||
if not tok.children:
|
||||
return tok.content or ""
|
||||
return escape_latex(tok.content or "")
|
||||
return render_inline_tokens(tok.children)
|
||||
|
||||
|
||||
@@ -16,7 +32,7 @@ def render_inline_tokens(tokens: list[Token]) -> str:
|
||||
parts: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok.type == "text":
|
||||
parts.append(tok.content)
|
||||
parts.append(escape_latex(tok.content))
|
||||
elif tok.type == "strong_open":
|
||||
parts.append(r"\textbf{")
|
||||
elif tok.type == "strong_close":
|
||||
@@ -28,12 +44,12 @@ def render_inline_tokens(tokens: list[Token]) -> str:
|
||||
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}}}{{")
|
||||
href = str(tok.attrGet("href") or "")
|
||||
parts.append(f"\\href{{{escape_latex(href)}}}{{")
|
||||
elif tok.type == "link_close":
|
||||
parts.append("}")
|
||||
elif tok.type in ("softbreak", "hardbreak"):
|
||||
parts.append("~\\\\")
|
||||
else:
|
||||
parts.append(tok.content if tok.content else "")
|
||||
parts.append(escape_latex(tok.content) if tok.content else "")
|
||||
return "".join(parts)
|
||||
|
||||
@@ -2,14 +2,15 @@ from __future__ import annotations
|
||||
|
||||
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:
|
||||
from markdown_it.token import Token
|
||||
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)
|
||||
lines = []
|
||||
for item in items:
|
||||
@@ -22,7 +23,7 @@ def bullet_list(r: Renderer) -> str:
|
||||
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)
|
||||
lines = []
|
||||
for item in items:
|
||||
@@ -31,7 +32,8 @@ def ordered_list(r: Renderer) -> str:
|
||||
text = text.rstrip(".")
|
||||
lines.append(f" \\item {text}.")
|
||||
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)
|
||||
return f"\\begin{{enumerate}}\n{body}\n\\end{{enumerate}}"
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ from typing import TYPE_CHECKING
|
||||
from md2gost.handlers.inline import render_inline
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markdown_it.token import Token
|
||||
from md2gost.render import Renderer
|
||||
|
||||
|
||||
def table(r: Renderer) -> str:
|
||||
def table(r: Renderer, tok: Token | None = None) -> str:
|
||||
rows: list[list[str]] = []
|
||||
r.pos += 1
|
||||
while r.pos < len(r.tokens):
|
||||
@@ -40,7 +41,10 @@ def table(r: Renderer) -> str:
|
||||
if not rows:
|
||||
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)
|
||||
lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"]
|
||||
for row in rows:
|
||||
|
||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
from md2gost.handlers.inline import render_inline
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from md2gost.render import Renderer
|
||||
|
||||
|
||||
def paragraph(r: Renderer) -> str:
|
||||
def paragraph(r: Renderer, tok: Token | None = None) -> str:
|
||||
inline_tok = r.tokens[r.pos + 1]
|
||||
text = render_inline(inline_tok)
|
||||
r.pos += 2
|
||||
@@ -35,11 +37,14 @@ def abbreviations(text: str) -> str:
|
||||
return text
|
||||
|
||||
max_abbr = max(len(r[0]) for r in rows)
|
||||
abbr_col = max_abbr + 2
|
||||
tabular_rows = []
|
||||
for abbr, desc in rows:
|
||||
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)
|
||||
return f"\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
|
||||
return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
|
||||
|
||||
@@ -2,7 +2,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\usepackage{geometry}
|
||||
\geometry{a4paper,tmargin=2cm,bmargin=2cm,lmargin=3cm,rmargin=1.5cm}
|
||||
\usepackage{fontspec}
|
||||
\setmainfont{Liberation Serif}
|
||||
\setmainfont{Times New Roman}
|
||||
\setsansfont{Liberation Sans}
|
||||
\setmonofont{Liberation Mono}
|
||||
\usepackage[english, main=russian]{babel}
|
||||
@@ -49,9 +49,39 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\usepackage[perpage]{footmisc}
|
||||
\usepackage{tabularx}
|
||||
\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*{\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}
|
||||
\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]{%
|
||||
\begin{tabular}[t]{@{}c@{}}%
|
||||
@@ -63,4 +93,4 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
|
||||
\begin{document}"""
|
||||
|
||||
END_DOCUMENT = r"\end{document}"
|
||||
END_DOCUMENT = r"\end{document}" + "\n"
|
||||
|
||||
+22
-15
@@ -1,31 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from md2gost.handlers.inline import escape_latex
|
||||
|
||||
|
||||
def render_titlepage(fm: dict[str, str]) -> str:
|
||||
faculty = fm.get("факультет", "")
|
||||
department = fm.get("кафедра", "")
|
||||
report_type = fm.get("тип", "")
|
||||
title = fm.get("название", "")
|
||||
student = fm.get("студент", "")
|
||||
advisor = fm.get("преподаватель", "")
|
||||
group = fm.get("группа", "")
|
||||
year = fm.get("год", "2026")
|
||||
required = {"название": "заголовок работы", "тип": "тип работы"}
|
||||
for key, desc in required.items():
|
||||
if key not in fm:
|
||||
raise ValueError(f"front matter: отсутствует поле '{key}' ({desc})")
|
||||
|
||||
faculty = escape_latex(fm.get("факультет", ""))
|
||||
department = escape_latex(fm.get("кафедра", ""))
|
||||
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 = (
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\hspace*{-6pt}\\centerline{Министерство науки и высшего образования Российской Федерации}\n"
|
||||
f"\\hspace*{{-6pt}}\\centerline{{{fm.get('министерство', 'Министерство науки и высшего образования Российской Федерации')}}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{Федеральное государственное бюджетное образовательное учреждение }\n"
|
||||
f"\\centerline{{{fm.get('вуз_тип', 'Федеральное государственное бюджетное образовательное учреждение ')}}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{высшего образования}\n"
|
||||
f"\\centerline{{{fm.get('вуз_тип2', 'высшего образования')}}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{<<Московский государственный технический университет}\n"
|
||||
f"\\centerline{{<<{fm.get('вуз', 'Московский государственный технический университет')}}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{имени Н.Э. Баумана>>}\n"
|
||||
f"\\centerline{{имени Н.Э. Баумана>>}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{(национальный исследовательский университет)}\n"
|
||||
f"\\centerline{{({fm.get('вуз_статус', 'национальный исследовательский университет')}}}\n"
|
||||
"\\vspace*{-0.7ex}\n"
|
||||
"\\centerline{(МГТУ им. Н.Э. Баумана)}"
|
||||
f"\\centerline{{({fm.get('вуз_аббревиатура', 'МГТУ им. Н.Э. Баумана')}}}"
|
||||
)
|
||||
|
||||
titlepage = (
|
||||
|
||||
+45
-20
@@ -1,34 +1,53 @@
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from md2gost.parser import Parser
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["xelatex", "-interaction=nonstopmode",
|
||||
"-output-directory", str(output_dir), str(tex_path)],
|
||||
capture_output=True, text=True,
|
||||
[
|
||||
"xelatex",
|
||||
"-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
|
||||
return pdf_path.exists()
|
||||
return pdf_path.exists() and result.returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
cli = argparse.ArgumentParser(
|
||||
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(
|
||||
"-o", "--output", type=Path, default=Path("."),
|
||||
"-o",
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("."),
|
||||
help="выходная директория (по умолчанию текущая)",
|
||||
)
|
||||
cli.add_argument(
|
||||
"--no-pdf", action="store_true",
|
||||
"--no-pdf",
|
||||
action="store_true",
|
||||
help="только .tex, без компиляции в PDF",
|
||||
)
|
||||
args = cli.parse_args()
|
||||
@@ -44,27 +63,33 @@ def main():
|
||||
renderer = Renderer(parser.tokens, parser.front_matter)
|
||||
latex = renderer.render()
|
||||
|
||||
tex_path = args.output / args.input.with_suffix(".tex").name
|
||||
with tempfile.TemporaryDirectory(prefix="md2gost-") as cache:
|
||||
cache_dir = Path(cache)
|
||||
|
||||
tex_name = args.input.with_suffix(".tex").name
|
||||
tex_path = cache_dir / tex_name
|
||||
tex_path.write_text(latex, encoding="utf-8")
|
||||
print(f"→ {tex_path}")
|
||||
|
||||
final_tex = args.output / tex_name
|
||||
shutil.copy2(tex_path, final_tex)
|
||||
print(f"-> {final_tex}")
|
||||
|
||||
if not args.no_pdf:
|
||||
pdf_path = args.output / args.input.with_suffix(".pdf").name
|
||||
log_path = args.output / "report.log"
|
||||
|
||||
if not run_xelatex(tex_path, args.output):
|
||||
if not run_xelatex(tex_path, 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)
|
||||
_print_latex_errors(cache_dir / "report.log")
|
||||
sys.exit(1)
|
||||
|
||||
if not run_xelatex(tex_path, args.output):
|
||||
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)
|
||||
|
||||
print(f"→ {pdf_path}")
|
||||
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__":
|
||||
|
||||
+15
-24
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from md2gost.handlers import (
|
||||
bullet_list,
|
||||
fence,
|
||||
@@ -15,20 +17,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
|
||||
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 = {
|
||||
_BLOCK: dict[str, Callable[..., str]] = {
|
||||
"heading_open": heading,
|
||||
"paragraph_open": paragraph,
|
||||
"bullet_list_open": bullet_list,
|
||||
@@ -37,10 +26,10 @@ _BLOCK = {
|
||||
"hr": hr,
|
||||
}
|
||||
|
||||
_INLINE = {
|
||||
"fence": _fence,
|
||||
"image": _image,
|
||||
"inline": _inline,
|
||||
_INLINE: dict[str, Callable[..., str]] = {
|
||||
"fence": fence,
|
||||
"image": image,
|
||||
"inline": render_inline,
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +38,14 @@ class Renderer:
|
||||
self.tokens = tokens
|
||||
self.fm = front_matter
|
||||
self.pos = 0
|
||||
self.section = 1
|
||||
self.subsection = 0
|
||||
self.subsubsection = 0
|
||||
self.current_heading = ""
|
||||
|
||||
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):
|
||||
tok = self.tokens[self.pos]
|
||||
@@ -63,12 +53,13 @@ class Renderer:
|
||||
tt = tok.type
|
||||
|
||||
if tt in _BLOCK:
|
||||
result = _BLOCK[tt](self)
|
||||
result = _BLOCK[tt](self, tok)
|
||||
parts.append(result)
|
||||
if self.current_heading == "СОДЕРЖАНИЕ":
|
||||
parts.append("\\newpage\n\\tableofcontents\n\\newpage")
|
||||
elif tt in _INLINE:
|
||||
parts.append(_INLINE[tt](self, tok))
|
||||
result = _INLINE[tt](self, tok)
|
||||
parts.append(result)
|
||||
else:
|
||||
self.pos += 1
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user