Compare commits
4 Commits
cd1b8d98c3
...
f616dc0cd5
| Author | SHA1 | Date | |
|---|---|---|---|
| f616dc0cd5 | |||
| fab9e19962 | |||
| 6aed7f186d | |||
| 10528c6688 |
@@ -1,4 +1,4 @@
|
|||||||
from md2gost.handlers.code import fence
|
from md2gost.handlers.code import captioned_listing, fence, match_listing
|
||||||
from md2gost.handlers.heading import heading
|
from md2gost.handlers.heading import heading
|
||||||
from md2gost.handlers.hr import hr
|
from md2gost.handlers.hr import hr
|
||||||
from md2gost.handlers.image import figure, match_figure
|
from md2gost.handlers.image import figure, match_figure
|
||||||
@@ -9,6 +9,8 @@ from md2gost.handlers.text import paragraph
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"fence",
|
"fence",
|
||||||
|
"captioned_listing",
|
||||||
|
"match_listing",
|
||||||
"hr",
|
"hr",
|
||||||
"heading",
|
"heading",
|
||||||
"figure",
|
"figure",
|
||||||
|
|||||||
@@ -1,14 +1,88 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token
|
||||||
|
|
||||||
from md2gost.registry import block
|
from md2gost.handlers.inline import escape_latex
|
||||||
|
from md2gost.registry import block, pattern
|
||||||
|
|
||||||
|
_CAPTION_RE = re.compile(r"^\s*Листинг\s+\d+\s*[—–-]+\s*(.+?)\s*$")
|
||||||
|
|
||||||
|
# Идентификаторы языков в Markdown и в пакете listings совпадают не всегда:
|
||||||
|
# ``cpp`` listings не знает, ему нужен ``C++``. Для языков без поддержки
|
||||||
|
# подсветки код набирается моноширинным без выделения ключевых слов.
|
||||||
|
_LANGUAGES = {
|
||||||
|
"c": "C",
|
||||||
|
"c++": "C++",
|
||||||
|
"cpp": "C++",
|
||||||
|
"bash": "bash",
|
||||||
|
"sh": "bash",
|
||||||
|
"shell": "bash",
|
||||||
|
"python": "Python",
|
||||||
|
"py": "Python",
|
||||||
|
"make": "make",
|
||||||
|
"makefile": "make",
|
||||||
|
"tex": "TeX",
|
||||||
|
"latex": "TeX",
|
||||||
|
"sql": "SQL",
|
||||||
|
"java": "Java",
|
||||||
|
}
|
||||||
|
_PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _language(info: str) -> str | None:
|
||||||
|
"""Имя языка для listings или None, если подсветка не нужна."""
|
||||||
|
key = info.strip().lower()
|
||||||
|
if key in _PLAIN:
|
||||||
|
return None
|
||||||
|
language = _LANGUAGES.get(key)
|
||||||
|
if language is None:
|
||||||
|
print(
|
||||||
|
f"предупреждение: listings не знает язык '{info.strip()}' — "
|
||||||
|
"листинг без подсветки",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return language
|
||||||
|
|
||||||
|
|
||||||
|
def _lstlisting(tok: Token, caption: str | None) -> str:
|
||||||
|
language = _language(tok.info)
|
||||||
|
opts = []
|
||||||
|
if language:
|
||||||
|
opts.append(f"language={language}")
|
||||||
|
if caption:
|
||||||
|
opts.append(f"caption={{{escape_latex(caption)}}}")
|
||||||
|
suffix = f"[{','.join(opts)}]" if opts else ""
|
||||||
|
return f"\\begin{{lstlisting}}{suffix}\n{tok.content.rstrip()}\n\\end{{lstlisting}}"
|
||||||
|
|
||||||
|
|
||||||
@block("fence")
|
@block("fence")
|
||||||
def fence(r, tok: Token) -> str:
|
def fence(r, tok: Token) -> str:
|
||||||
r.pos += 1
|
r.pos += 1
|
||||||
lang = tok.info.strip()
|
return _lstlisting(tok, None)
|
||||||
code = tok.content.rstrip()
|
|
||||||
opts = f"[language={lang}]" if lang else ""
|
|
||||||
return f"\\begin{{lstlisting}}{opts}\n{code}\n\\end{{lstlisting}}"
|
def match_listing(tokens: list[Token], pos: int) -> int | None:
|
||||||
|
"""Абзац «Листинг N —— Название», сразу за ним блок кода."""
|
||||||
|
if pos + 3 >= len(tokens):
|
||||||
|
return None
|
||||||
|
if tokens[pos].type != "paragraph_open":
|
||||||
|
return None
|
||||||
|
if tokens[pos + 1].type != "inline":
|
||||||
|
return None
|
||||||
|
if not _CAPTION_RE.match(tokens[pos + 1].content):
|
||||||
|
return None
|
||||||
|
if tokens[pos + 2].type != "paragraph_close":
|
||||||
|
return None
|
||||||
|
if tokens[pos + 3].type != "fence":
|
||||||
|
return None
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
@pattern(match_listing)
|
||||||
|
def captioned_listing(tokens: list[Token]) -> str:
|
||||||
|
match = _CAPTION_RE.match(tokens[1].content)
|
||||||
|
assert match is not None # гарантировано match_listing
|
||||||
|
return _lstlisting(tokens[3], match.group(1))
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ def heading(r, tok: Token) -> str:
|
|||||||
prefix = "\\clearpage\n"
|
prefix = "\\clearpage\n"
|
||||||
toc_entry = ""
|
toc_entry = ""
|
||||||
if upper in NEWPAGE_BEFORE:
|
if upper in NEWPAGE_BEFORE:
|
||||||
toc_entry = f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}"
|
toc_entry = (
|
||||||
|
f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"{prefix}"
|
f"{prefix}"
|
||||||
f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}"
|
f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from md2gost.latex.preamble import END_DOCUMENT, PREAMBLE
|
from md2gost.latex.preamble import END_DOCUMENT, PREAMBLE
|
||||||
from md2gost.latex.titlepage import render_titlepage
|
from md2gost.latex.titlepages import TITLEPAGES, render_titlepage
|
||||||
|
|
||||||
__all__ = ["END_DOCUMENT", "PREAMBLE", "render_titlepage"]
|
__all__ = ["END_DOCUMENT", "PREAMBLE", "TITLEPAGES", "render_titlepage"]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright}
|
\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright}
|
||||||
\usepackage{listings}
|
\usepackage{listings}
|
||||||
\lstset{
|
\lstset{
|
||||||
basicstyle=\ttfamily\footnotesize,
|
basicstyle=\ttfamily\small\setstretch{1.0},
|
||||||
breakatwhitespace=false,
|
breakatwhitespace=false,
|
||||||
breaklines=true,
|
breaklines=true,
|
||||||
captionpos=t,
|
captionpos=t,
|
||||||
@@ -47,6 +47,8 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
stepnumber=1,
|
stepnumber=1,
|
||||||
tabsize=2,
|
tabsize=2,
|
||||||
}
|
}
|
||||||
|
\renewcommand{\lstlistingname}{Листинг}
|
||||||
|
\captionsetup[lstlisting]{labelsep=emdash,font=onehalfspacing,position=top,singlelinecheck=false,justification=raggedright}
|
||||||
\usepackage{amsthm,amsfonts,amsmath,amssymb,amscd}
|
\usepackage{amsthm,amsfonts,amsmath,amssymb,amscd}
|
||||||
\usepackage{mathtools}
|
\usepackage{mathtools}
|
||||||
\usepackage{unicode-math}
|
\usepackage{unicode-math}
|
||||||
@@ -96,6 +98,37 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\end{tabular}%
|
\end{tabular}%
|
||||||
}
|
}
|
||||||
|
|
||||||
|
\newcommand{\ptpfieldw}[4][4.5cm]{%
|
||||||
|
\par\noindent
|
||||||
|
\makebox[#1][l]{#2}%
|
||||||
|
\begin{tabular}[t]{@{}c@{}}%
|
||||||
|
\makebox[#4][c]{#3} \\[-1.05em]%
|
||||||
|
\rule{#4}{0.4pt}%
|
||||||
|
\end{tabular}\par}
|
||||||
|
|
||||||
|
\newcommand{\ptpfield}[3][4.5cm]{%
|
||||||
|
\ptpfieldw[#1]{#2}{#3}{\dimexpr\textwidth-#1\relax}}
|
||||||
|
|
||||||
|
\newcommand{\ptpfieldnote}[4][4.5cm]{%
|
||||||
|
\ptpfield[#1]{#2}{#3}%
|
||||||
|
\par\vspace{-0.45em}%
|
||||||
|
\hspace*{#1}\makebox[\dimexpr\textwidth-#1\relax][c]{\footnotesize\itshape #4}\par}
|
||||||
|
|
||||||
|
\newcommand{\ptpfieldpar}[3][4.5cm]{%
|
||||||
|
\par\noindent
|
||||||
|
\makebox[#1][l]{#2}%
|
||||||
|
\begin{tabular}[t]{@{}c@{}}%
|
||||||
|
\parbox{\dimexpr\textwidth-#1\relax}{\centering #3} \\[-0.55em]%
|
||||||
|
\rule{\dimexpr\textwidth-#1\relax}{0.4pt}%
|
||||||
|
\end{tabular}\par}
|
||||||
|
|
||||||
|
\newcommand{\ptpsig}[3]{%
|
||||||
|
\par\noindent
|
||||||
|
\begin{tabularx}{\textwidth}{@{}l X c c@{}}%
|
||||||
|
#1 & & \sigline[4.2cm]{}{\itshape подпись, дата}
|
||||||
|
& \sigline[4.2cm]{#2}{\itshape #3} \\%
|
||||||
|
\end{tabularx}\par}
|
||||||
|
|
||||||
\begin{document}"""
|
\begin{document}"""
|
||||||
|
|
||||||
END_DOCUMENT = r"\end{document}" + "\n"
|
END_DOCUMENT = r"\end{document}" + "\n"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Реестр макетов титульного листа.
|
||||||
|
|
||||||
|
Макет выбирается полем front matter ``титульник``; по умолчанию ``default`` —
|
||||||
|
исторический макет, поэтому существующие документы продолжают собираться
|
||||||
|
без изменений.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from md2gost.latex.titlepages import default, ptp_report, ptp_task
|
||||||
|
|
||||||
|
TITLEPAGES: dict[str, Callable[[dict[str, str]], str]] = {
|
||||||
|
"default": default.render,
|
||||||
|
"ptp-report": ptp_report.render,
|
||||||
|
"ptp-task": ptp_task.render,
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__ = ["TITLEPAGES", "render_titlepage"]
|
||||||
|
|
||||||
|
|
||||||
|
def render_titlepage(fm: dict[str, str]) -> str:
|
||||||
|
name = fm.get("титульник", "default")
|
||||||
|
try:
|
||||||
|
layout = TITLEPAGES[name]
|
||||||
|
except KeyError:
|
||||||
|
available = ", ".join(sorted(TITLEPAGES))
|
||||||
|
raise ValueError(
|
||||||
|
f"front matter: неизвестный титульник '{name}'; доступны: {available}"
|
||||||
|
) from None
|
||||||
|
return layout(fm)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Общие части титульных листов: доступ к полям и шапка по бланку кафедры."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from md2gost.handlers.inline import escape_latex
|
||||||
|
|
||||||
|
MINISTRY_DEFAULTS = {
|
||||||
|
"министерство": "Министерство науки и высшего образования Российской Федерации",
|
||||||
|
"вуз_тип": "Федеральное государственное автономное образовательное учреждение",
|
||||||
|
"вуз_тип2": "высшего образования",
|
||||||
|
"вуз": "Московский государственный технический университет",
|
||||||
|
"вуз_имени": "имени Н.Э. Баумана",
|
||||||
|
"вуз_статус": "национальный исследовательский университет",
|
||||||
|
"вуз_аббревиатура": "МГТУ им. Н.Э. Баумана",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def field(fm: dict[str, str], key: str, default: str = "") -> str:
|
||||||
|
"""Значение поля front matter, экранированное для LaTeX."""
|
||||||
|
return escape_latex(fm.get(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def ministry_lines(fm: dict[str, str]) -> str:
|
||||||
|
"""Семь центрированных строк шапки в точности по бланку кафедры.
|
||||||
|
|
||||||
|
Отличается от макета ``default`` разбивкой строк: закрывающая кавычка стоит
|
||||||
|
после статуса вуза, а скобки закрыты.
|
||||||
|
"""
|
||||||
|
get = lambda key: fm.get(key, MINISTRY_DEFAULTS[key]) # noqa: E731
|
||||||
|
lines = [
|
||||||
|
get("министерство"),
|
||||||
|
get("вуз_тип"),
|
||||||
|
get("вуз_тип2"),
|
||||||
|
f"<<{get('вуз')}",
|
||||||
|
get("вуз_имени"),
|
||||||
|
f"({get('вуз_статус')})>>",
|
||||||
|
f"({get('вуз_аббревиатура')})",
|
||||||
|
]
|
||||||
|
# Первая строка без отрицательного отступа: иначе она вылезает за верхнюю
|
||||||
|
# границу рамки, в которую шапку заключает бланк отчёта.
|
||||||
|
head = f"\\centerline{{{lines[0]}}}"
|
||||||
|
rest = "\n".join(
|
||||||
|
f"\\vspace*{{-0.7ex}}\n\\centerline{{{line}}}" for line in lines[1:]
|
||||||
|
)
|
||||||
|
return f"{head}\n{rest}"
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
"""Исторический макет титульного листа.
|
||||||
|
|
||||||
|
Перенесён без изменений: вывод обязан совпадать байт в байт с прежним,
|
||||||
|
иначе сломаются уже сданные документы.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from md2gost.handlers.inline import escape_latex
|
from md2gost.handlers.inline import escape_latex
|
||||||
|
|
||||||
|
|
||||||
def render_titlepage(fm: dict[str, str]) -> str:
|
def render(fm: dict[str, str]) -> str:
|
||||||
required = {"название": "заголовок работы", "тип": "тип работы"}
|
required = {"название": "заголовок работы", "тип": "тип работы"}
|
||||||
for key, desc in required.items():
|
for key, desc in required.items():
|
||||||
if key not in fm:
|
if key not in fm:
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Титульный лист по бланку «ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ» кафедры ИУ9."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from md2gost.latex.titlepages._common import field, ministry_lines
|
||||||
|
|
||||||
|
|
||||||
|
def render(fm: dict[str, str]) -> str:
|
||||||
|
faculty = field(fm, "факультет")
|
||||||
|
department = field(fm, "кафедра")
|
||||||
|
student_short = field(fm, "студент")
|
||||||
|
student_full = field(fm, "студент_полностью") or student_short
|
||||||
|
group = field(fm, "группа")
|
||||||
|
practice = field(fm, "тип_практики")
|
||||||
|
company = field(fm, "предприятие")
|
||||||
|
team = field(fm, "команда")
|
||||||
|
repo = field(fm, "хранилище")
|
||||||
|
advisor = field(fm, "руководитель")
|
||||||
|
year = field(fm, "год", "2026")
|
||||||
|
|
||||||
|
team_line = f"{team} (хранилище {repo})" if repo else team
|
||||||
|
|
||||||
|
return (
|
||||||
|
"\\begin{titlepage}\n"
|
||||||
|
"\\thispagestyle{empty}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Шапка в рамке с разделителем, как в бланке ---\n"
|
||||||
|
"\\vspace*{-45pt}\n"
|
||||||
|
"\\noindent\\begin{tabular}"
|
||||||
|
"{|@{\\hspace{4pt}}c@{\\hspace{4pt}}|@{\\hspace{4pt}}c@{\\hspace{4pt}}|}\n"
|
||||||
|
"\\hline\n"
|
||||||
|
"\\begin{minipage}{0.16\\textwidth}\\centering\\vspace{3pt}\n"
|
||||||
|
"\\includegraphics[width=0.92\\textwidth]{emblem}\n"
|
||||||
|
"\\vspace{3pt}\\end{minipage}\n"
|
||||||
|
"&\n"
|
||||||
|
"\\begin{minipage}{0.775\\textwidth}\\small\\bfseries\n"
|
||||||
|
"\\vspace*{2pt}\n"
|
||||||
|
f"{ministry_lines(fm)}\n"
|
||||||
|
"\\vspace*{0.5ex}\n"
|
||||||
|
"\\end{minipage} \\\\\n"
|
||||||
|
"\\hline\n"
|
||||||
|
"\\end{tabular}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Полосы ---\n"
|
||||||
|
"\\vspace{6pt}\n"
|
||||||
|
"\\noindent\\rule{\\textwidth}{2.3pt}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace*{-18pt}\n"
|
||||||
|
"\\noindent\\rule{\\textwidth}{0.4pt}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Факультет и кафедра ---\n"
|
||||||
|
"\\vspace{2.5ex}\n"
|
||||||
|
f"\\ptpfield[2.8cm]{{ФАКУЛЬТЕТ}}{{{faculty}}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{1.5ex}\n"
|
||||||
|
f"\\ptpfield[2.8cm]{{КАФЕДРА}}{{{department}}}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Заголовок ---\n"
|
||||||
|
"\\vspace{3.5em}\n"
|
||||||
|
"\\begin{center}\n"
|
||||||
|
"{\\Large\\bfseries\\underline{ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ}}\n"
|
||||||
|
"\\end{center}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Поля ---\n"
|
||||||
|
"\\vspace{2.5em}\n"
|
||||||
|
f"\\ptpfieldnote[2.4cm]{{Студент}}{{{student_full}}}{{фамилия, имя, отчество}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{1.8ex}\n"
|
||||||
|
f"\\ptpfieldw[2.4cm]{{Группа}}{{{group}}}{{5cm}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{1.8ex}\n"
|
||||||
|
f"\\ptpfield[4.6cm]{{Тип практики}}{{{practice}}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{1.8ex}\n"
|
||||||
|
f"\\ptpfield[4.6cm]{{Название предприятия}}{{{company}}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{1.8ex}\n"
|
||||||
|
f"\\ptpfieldpar[2.4cm]{{Команда}}{{{team_line}}}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Подписи ---\n"
|
||||||
|
"\\vfill\n"
|
||||||
|
f"\\ptpsig{{Студент}}{{{student_short}}}{{фамилия, и.о.}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{2em}\n"
|
||||||
|
f"\\ptpsig{{Руководитель практики}}{{{advisor}}}{{фамилия, и.о.}}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Оценка ---\n"
|
||||||
|
"\\vspace{2em}\n"
|
||||||
|
"\\noindent Оценка \\rule{0.5\\textwidth}{0.4pt}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vfill\n"
|
||||||
|
"\\begin{center}\n"
|
||||||
|
f"\\textsl{{{year} г.}}\n"
|
||||||
|
"\\end{center}\n"
|
||||||
|
"\n"
|
||||||
|
"\\end{titlepage}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Титульный лист по бланку «ЗАДАНИЕ на прохождение учебной практики».
|
||||||
|
|
||||||
|
Бланк идёт отдельным листом и вкладывается в отчёт, поэтому документ состоит
|
||||||
|
из одного этого листа. Текст пунктов задания в модуле не хранится: у каждого
|
||||||
|
студента он свой и приходит из front matter ключами ``пункт_N``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from md2gost.handlers.inline import escape_latex
|
||||||
|
from md2gost.latex.titlepages._common import field, ministry_lines
|
||||||
|
|
||||||
|
_ITEM_RE = re.compile(r"^пункт_(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def task_items(fm: dict[str, str]) -> list[str]:
|
||||||
|
"""Пункты задания из ключей ``пункт_N``, отсортированные по номеру."""
|
||||||
|
items = [
|
||||||
|
(int(match.group(1)), value)
|
||||||
|
for key, value in fm.items()
|
||||||
|
if (match := _ITEM_RE.match(key)) and value.strip()
|
||||||
|
]
|
||||||
|
if not items:
|
||||||
|
raise ValueError(
|
||||||
|
"front matter: у макета 'ptp-task' нет ни одного пункта задания; "
|
||||||
|
"добавьте поля 'пункт_1', 'пункт_2', ..."
|
||||||
|
)
|
||||||
|
return [value for _, value in sorted(items)]
|
||||||
|
|
||||||
|
|
||||||
|
def _items_tex(fm: dict[str, str], team_line: str) -> str:
|
||||||
|
"""Нумерованный список пунктов; под пунктом с двоеточием — строка команды."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for text in task_items(fm):
|
||||||
|
lines.append(f" \\item {escape_latex(text)}")
|
||||||
|
if text.rstrip().endswith(":"):
|
||||||
|
lines.append(
|
||||||
|
" \\par\\vspace{0.5ex}"
|
||||||
|
f"\\noindent\\makebox[\\textwidth][c]{{\\itshape {team_line}}}"
|
||||||
|
"\\par\\vspace{-0.6em}"
|
||||||
|
"\\noindent\\rule{\\textwidth}{0.4pt}\\par"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render(fm: dict[str, str]) -> str:
|
||||||
|
department = field(fm, "кафедра")
|
||||||
|
dep_index = field(fm, "кафедра_индекс", "ИУ9")
|
||||||
|
student_full = field(fm, "студент_полностью") or field(fm, "студент")
|
||||||
|
group = field(fm, "группа")
|
||||||
|
company = field(fm, "предприятие")
|
||||||
|
team = field(fm, "команда")
|
||||||
|
repo = field(fm, "хранилище")
|
||||||
|
advisor = field(fm, "руководитель")
|
||||||
|
day = field(fm, "дата_выдачи_день")
|
||||||
|
month = field(fm, "дата_выдачи_месяц")
|
||||||
|
year = field(fm, "дата_выдачи_год", "2026")
|
||||||
|
|
||||||
|
team_line = f"{team} (хранилище {repo})" if repo else team
|
||||||
|
student_line = f"{student_full}, {group}" if group else student_full
|
||||||
|
|
||||||
|
return (
|
||||||
|
"\\begin{titlepage}\n"
|
||||||
|
"\\thispagestyle{empty}\n"
|
||||||
|
"\\setstretch{1.0}\n"
|
||||||
|
# center/flushright добавляют \\topsep и \\parskip сверху и снизу;
|
||||||
|
# на бланке из-за этого не остаётся места под подписи.
|
||||||
|
"\\setlength{\\parskip}{0pt}\n"
|
||||||
|
"\\setlength{\\topsep}{0pt}\n"
|
||||||
|
"\\setlength{\\partopsep}{0pt}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Шапка без эмблемы: в бланке задания её нет ---\n"
|
||||||
|
"\\vspace*{-52pt}\n"
|
||||||
|
"{\\small\\bfseries\n"
|
||||||
|
f"{ministry_lines(fm)}}}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Полосы ---\n"
|
||||||
|
"\\vspace{1ex}\n"
|
||||||
|
"\\noindent\\rule{\\textwidth}{2.3pt}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace*{-18pt}\n"
|
||||||
|
"\\noindent\\rule{\\textwidth}{0.4pt}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Кафедра ---\n"
|
||||||
|
"\\vspace{1ex}\n"
|
||||||
|
"\\begin{center}\n"
|
||||||
|
f"Кафедра <<\\underline{{{department}}}>> (\\underline{{{dep_index}}})\n"
|
||||||
|
"\\end{center}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Утверждаю ---\n"
|
||||||
|
"\\vspace{0.5ex}\n"
|
||||||
|
"\\begin{flushright}\n"
|
||||||
|
"УТВЕРЖДАЮ \\\\[0.4ex]\n"
|
||||||
|
"Заведующий кафедрой \\rule{3cm}{0.4pt} \\\\[1ex]\n"
|
||||||
|
"\\rule{3.2cm}{0.4pt} \\quad \\rule{4cm}{0.4pt} \\\\[1ex]\n"
|
||||||
|
"<<\\,\\rule{1cm}{0.4pt}\\,>> \\rule{3cm}{0.4pt} 20\\,\\rule{0.8cm}{0.4pt} г.\n"
|
||||||
|
"\\end{flushright}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Заголовок ---\n"
|
||||||
|
"\\vspace{0.5ex}\n"
|
||||||
|
"\\begin{center}\n"
|
||||||
|
"{\\large\\bfseries З\\,А\\,Д\\,А\\,Н\\,И\\,Е} \\\\[0.4ex]\n"
|
||||||
|
"{\\bfseries на прохождение учебной практики}\n"
|
||||||
|
"\\end{center}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Поля ---\n"
|
||||||
|
"\\vspace{1ex}\n"
|
||||||
|
f"\\ptpfield[3.3cm]{{на предприятии}}{{{company}}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{0.8ex}\n"
|
||||||
|
f"\\ptpfieldnote[2.2cm]{{Студент}}{{{student_line}}}"
|
||||||
|
"{(фамилия, имя, отчество; инициалы; индекс группы)}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Пункты задания ---\n"
|
||||||
|
"\\vspace{1.2ex}\n"
|
||||||
|
"\\noindent Во время прохождения учебной практики студент должен:\n"
|
||||||
|
# Пункты набираются мельче основного кегля — как в бланке кафедры,
|
||||||
|
# иначе полный список не помещается на лист вместе с подписями.
|
||||||
|
"{\\small\n"
|
||||||
|
"\\begin{enumerate}[wide=0pt, labelindent=0pt, labelwidth=0pt,\n"
|
||||||
|
" label=\\arabic*., itemsep=0.2ex, topsep=0.3ex, parsep=0pt]\n"
|
||||||
|
f"{_items_tex(fm, team_line)}\n"
|
||||||
|
"\\end{enumerate}}\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Дата выдачи ---\n"
|
||||||
|
"\\vspace{1ex}\n"
|
||||||
|
"\\noindent Дата выдачи задания "
|
||||||
|
f"<<\\,\\underline{{~{day}~}}\\,>> \\underline{{~{month}~}} "
|
||||||
|
f"\\underline{{~{year}~}} г.\n"
|
||||||
|
"\n"
|
||||||
|
"% --- Подписи ---\n"
|
||||||
|
"\\vspace{0.6em}\n"
|
||||||
|
f"{{\\setstretch{{1.3}}\\ptpsig{{\\bfseries Руководитель практики от кафедры}}"
|
||||||
|
f"{{{advisor}}}{{(Фамилия И.О.)}}}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\vspace{0.5em}\n"
|
||||||
|
"{\\setstretch{1.3}\\ptpsig{\\bfseries Студент}{}{(Фамилия И.О.)}}\n"
|
||||||
|
"\n"
|
||||||
|
"\\end{titlepage}"
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
|
|||||||
# Импорт обработчиков регистрирует их в реестрах.
|
# Импорт обработчиков регистрирует их в реестрах.
|
||||||
from md2gost.handlers import ( # noqa: F401
|
from md2gost.handlers import ( # noqa: F401
|
||||||
bullet_list,
|
bullet_list,
|
||||||
|
captioned_listing,
|
||||||
captioned_table,
|
captioned_table,
|
||||||
fence,
|
fence,
|
||||||
figure,
|
figure,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from md2gost.latex.titlepages import TITLEPAGES
|
||||||
from md2gost.parser import Parser
|
from md2gost.parser import Parser
|
||||||
from md2gost.render import Renderer
|
from md2gost.render import Renderer
|
||||||
|
|
||||||
@@ -9,6 +12,12 @@ def render(md: str) -> str:
|
|||||||
return Renderer(parser.tokens, parser.front_matter).render()
|
return Renderer(parser.tokens, parser.front_matter).render()
|
||||||
|
|
||||||
|
|
||||||
|
def render_fm(extra: str, body: str = "Текст.\n") -> str:
|
||||||
|
head = f"---\nназвание: Тестовая работа\nтип: Отчёт\n{extra}---\n\n"
|
||||||
|
parser = Parser(head + body)
|
||||||
|
return Renderer(parser.tokens, parser.front_matter).render()
|
||||||
|
|
||||||
|
|
||||||
def test_heading_numbering() -> None:
|
def test_heading_numbering() -> None:
|
||||||
tex = render("# Исследовательская часть\n\n## Обзор\n")
|
tex = render("# Исследовательская часть\n\n## Обзор\n")
|
||||||
assert "\\section{Исследовательская часть}" in tex
|
assert "\\section{Исследовательская часть}" in tex
|
||||||
@@ -139,3 +148,220 @@ def _table_block(tex: str) -> str:
|
|||||||
start = tex.index("\\begin{table}")
|
start = tex.index("\\begin{table}")
|
||||||
end = tex.index("\\end{table}") + len("\\end{table}")
|
end = tex.index("\\end{table}") + len("\\end{table}")
|
||||||
return tex[start:end]
|
return tex[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titlepage_default_is_used_when_field_absent() -> None:
|
||||||
|
tex = render("Текст.\n")
|
||||||
|
assert "\\begin{titlepage}" in tex
|
||||||
|
assert "ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ" not in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_titlepage_registry_lists_three_layouts() -> None:
|
||||||
|
assert set(TITLEPAGES) == {"default", "ptp-report", "ptp-task"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_titlepage_unknown_name_raises() -> None:
|
||||||
|
with pytest.raises(ValueError, match="ptp-report"):
|
||||||
|
render_fm("титульник: несуществующий\n")
|
||||||
|
|
||||||
|
|
||||||
|
PTP_REPORT_FM = (
|
||||||
|
"титульник: ptp-report\n"
|
||||||
|
"факультет: Информатика и системы управления (ИУ)\n"
|
||||||
|
"кафедра: Теоретическая информатика и компьютерные технологии (ИУ9)\n"
|
||||||
|
"студент: И. И. Камалетдинов\n"
|
||||||
|
"студент_полностью: Камалетдинов Ильяс Илнарович\n"
|
||||||
|
"группа: ИУ9-22Б\n"
|
||||||
|
"тип_практики: проектно-технологическая практика\n"
|
||||||
|
"предприятие: МГТУ имени Н. Э. Баумана\n"
|
||||||
|
"команда: Система мониторинга радиоэфира\n"
|
||||||
|
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
|
||||||
|
"руководитель: Д. П. Посевин\n"
|
||||||
|
"год: 2026\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_report_has_blank_headings() -> None:
|
||||||
|
tex = render_fm(PTP_REPORT_FM)
|
||||||
|
for heading in (
|
||||||
|
"ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ",
|
||||||
|
"Тип практики",
|
||||||
|
"Название предприятия",
|
||||||
|
"Команда",
|
||||||
|
"Оценка",
|
||||||
|
"Руководитель практики",
|
||||||
|
):
|
||||||
|
assert heading in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_report_fills_all_fields() -> None:
|
||||||
|
tex = render_fm(PTP_REPORT_FM)
|
||||||
|
for value in (
|
||||||
|
"Камалетдинов Ильяс Илнарович",
|
||||||
|
"И. И. Камалетдинов",
|
||||||
|
"ИУ9-22Б",
|
||||||
|
"проектно-технологическая практика",
|
||||||
|
"МГТУ имени Н. Э. Баумана",
|
||||||
|
"Д. П. Посевин",
|
||||||
|
"2026",
|
||||||
|
):
|
||||||
|
assert value in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_report_team_line_includes_repository() -> None:
|
||||||
|
tex = render_fm(PTP_REPORT_FM)
|
||||||
|
assert "Система мониторинга радиоэфира (хранилище" in tex
|
||||||
|
assert "monitor.yss.su/IU9/radio-serv" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_report_team_line_without_repository() -> None:
|
||||||
|
fm = PTP_REPORT_FM.replace(
|
||||||
|
"хранилище: https://monitor.yss.su/IU9/radio-serv\n", ""
|
||||||
|
)
|
||||||
|
tex = render_fm(fm)
|
||||||
|
assert "хранилище" not in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_report_has_no_hardcoded_names() -> None:
|
||||||
|
"""Имена, группы и предприятия приходят только из front matter."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from md2gost.latex.titlepages import ptp_report
|
||||||
|
|
||||||
|
source = inspect.getsource(ptp_report)
|
||||||
|
for forbidden in ("Посевин", "Коновалов", "Камалетдинов", "ИУ9-2"):
|
||||||
|
assert forbidden not in source
|
||||||
|
|
||||||
|
|
||||||
|
PTP_TASK_FM = (
|
||||||
|
"титульник: ptp-task\n"
|
||||||
|
"кафедра: Теоретическая информатика и компьютерные технологии\n"
|
||||||
|
"кафедра_индекс: ИУ9\n"
|
||||||
|
"студент: Булдаков А. С.\n"
|
||||||
|
"студент_полностью: Булдаков Арсений Сергеевич\n"
|
||||||
|
"группа: ИУ9-22Б\n"
|
||||||
|
"предприятие: МГТУ имени Н. Э. Баумана\n"
|
||||||
|
"команда: Система мониторинга радиоэфира\n"
|
||||||
|
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
|
||||||
|
"руководитель: Посевин Д. П.\n"
|
||||||
|
"дата_выдачи_день: 29\n"
|
||||||
|
"дата_выдачи_месяц: июня\n"
|
||||||
|
"дата_выдачи_год: 2026\n"
|
||||||
|
"пункт_1: Изучить принципы построения систем пеленгации.\n"
|
||||||
|
"пункт_2: Получить задание в составе команды:\n"
|
||||||
|
"пункт_10: Написать отчёт.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_renders_items_in_numeric_order() -> None:
|
||||||
|
"""Пункт 10 идёт после пункта 2, а не между 1 и 2 как при сортировке строк."""
|
||||||
|
tex = render_fm(PTP_TASK_FM)
|
||||||
|
first = tex.index("Изучить принципы построения систем пеленгации.")
|
||||||
|
second = tex.index("Получить задание в составе команды:")
|
||||||
|
third = tex.index("Написать отчёт.")
|
||||||
|
assert first < second < third
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_requires_items() -> None:
|
||||||
|
with pytest.raises(ValueError, match="пункт_1"):
|
||||||
|
render_fm("титульник: ptp-task\nстудент: Булдаков А. С.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_has_blank_furniture() -> None:
|
||||||
|
tex = render_fm(PTP_TASK_FM)
|
||||||
|
for fragment in (
|
||||||
|
"УТВЕРЖДАЮ",
|
||||||
|
"Заведующий кафедрой",
|
||||||
|
"З\\,А\\,Д\\,А\\,Н\\,И\\,Е",
|
||||||
|
"на прохождение учебной практики",
|
||||||
|
"Дата выдачи задания",
|
||||||
|
"Руководитель практики от кафедры",
|
||||||
|
):
|
||||||
|
assert fragment in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_student_line_carries_group() -> None:
|
||||||
|
tex = render_fm(PTP_TASK_FM)
|
||||||
|
assert "Булдаков Арсений Сергеевич, ИУ9-22Б" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_team_line_follows_item_with_colon() -> None:
|
||||||
|
tex = render_fm(PTP_TASK_FM)
|
||||||
|
colon_item = tex.index("Получить задание в составе команды:")
|
||||||
|
team = tex.index("Система мониторинга радиоэфира (хранилище")
|
||||||
|
next_item = tex.index("Написать отчёт.")
|
||||||
|
assert colon_item < team < next_item
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_has_no_emblem() -> None:
|
||||||
|
"""В бланке задания эмблемы нет."""
|
||||||
|
tex = render_fm(PTP_TASK_FM)
|
||||||
|
titlepage = tex[tex.index("\\begin{titlepage}") : tex.index("\\end{titlepage}")]
|
||||||
|
assert "emblem" not in titlepage
|
||||||
|
|
||||||
|
|
||||||
|
def test_ptp_task_has_no_hardcoded_assignment_text() -> None:
|
||||||
|
"""Пункты задания и имена приходят только из front matter."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from md2gost.latex.titlepages import ptp_task
|
||||||
|
|
||||||
|
source = inspect.getsource(ptp_task)
|
||||||
|
for forbidden in ("Web", "2D-игра", "GitFlic", "Javascript", "Посевин", "Коновалов"):
|
||||||
|
assert forbidden not in source
|
||||||
|
|
||||||
|
|
||||||
|
LISTING_MD = (
|
||||||
|
"Листинг 1 —— Потокобезопасная очередь\n\n"
|
||||||
|
"```cpp\n"
|
||||||
|
"template <typename T>\n"
|
||||||
|
"class ThreadSafeQueue {};\n"
|
||||||
|
"```\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_with_caption() -> None:
|
||||||
|
tex = render(LISTING_MD)
|
||||||
|
assert "caption={Потокобезопасная очередь}" in tex
|
||||||
|
assert "language=C++" in tex
|
||||||
|
assert "class ThreadSafeQueue {};" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_caption_number_is_discarded() -> None:
|
||||||
|
tex = render(LISTING_MD)
|
||||||
|
assert "Листинг 1" not in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_caption_paragraph_is_not_duplicated() -> None:
|
||||||
|
tex = render(LISTING_MD)
|
||||||
|
assert tex.count("Потокобезопасная очередь") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_without_caption_stays_bare() -> None:
|
||||||
|
tex = render("```cpp\nint main() {}\n```\n")
|
||||||
|
assert "\\begin{lstlisting}[language=C++]" in tex
|
||||||
|
assert "caption=" not in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_caption_without_language() -> None:
|
||||||
|
tex = render("Листинг 2 —— Формат строки\n\n```\nid,freq,power\n```\n")
|
||||||
|
assert "caption={Формат строки}" in tex
|
||||||
|
assert "language=" not in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraph_starting_with_listing_word_is_not_a_caption() -> None:
|
||||||
|
tex = render("Листинг приведён ниже.\n\n```cpp\nint x;\n```\n")
|
||||||
|
assert "caption=" not in tex
|
||||||
|
assert "Листинг приведён ниже." in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_language_is_mapped_to_listings_name() -> None:
|
||||||
|
"""listings не знает 'cpp', только 'C++'."""
|
||||||
|
tex = render("```cpp\nint x;\n```\n")
|
||||||
|
assert "language=C++" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_unsupported_language_falls_back_to_plain() -> None:
|
||||||
|
tex = render("```yaml\nkey: value\n```\n")
|
||||||
|
assert "language=" not in tex
|
||||||
|
assert "key: value" in tex
|
||||||
|
|||||||
Reference in New Issue
Block a user