[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
This commit is contained in:
Arseny
2026-07-16 08:52:58 +03:00
parent 288a5682db
commit 59ba0555bb
13 changed files with 95 additions and 75 deletions
+1
View File
@@ -6,6 +6,7 @@ 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 \ cabextract \
wget \ wget \
+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
+2 -5
View File
@@ -10,8 +10,5 @@ if TYPE_CHECKING:
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}"
+7 -11
View File
@@ -5,6 +5,7 @@ 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
UNNUMBERED = { UNNUMBERED = {
@@ -24,21 +25,22 @@ 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() == TOC_HEADING: if upper == TOC_HEADING:
return "" return ""
if text.upper() in UNNUMBERED: 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}"
@@ -46,16 +48,10 @@ def heading(r: Renderer) -> str:
) )
if level == 1: if level == 1:
r.section += 1
r.subsection = 0
r.subsubsection = 0
return f"\\section{{{text}}}" return f"\\section{{{text}}}"
elif level == 2: elif level == 2:
r.subsection += 1
r.subsubsection = 0
return f"\\subsection{{{text}}}" return f"\\subsection{{{text}}}"
elif level == 3: elif level == 3:
r.subsubsection += 1
return f"\\subsubsection{{{text}}}" return f"\\subsubsection{{{text}}}"
else: else:
return f"\\textbf{{{text}}}" return f"\\textbf{{{text}}}"
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from markdown_it.token import Token
from md2gost.render import Renderer
def hr(r: Renderer, tok: Token | None = None) -> str:
return r"\noindent\rule{\textwidth}{0.4pt}"
+21 -4
View File
@@ -5,10 +5,27 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from markdown_it.token import Token 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: 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 +33,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":
@@ -29,11 +46,11 @@ def render_inline_tokens(tokens: list[Token]) -> str:
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 = 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)
+4 -3
View File
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
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 +22,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 +31,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}}"
+5 -2
View File
@@ -8,7 +8,7 @@ if TYPE_CHECKING:
from md2gost.render import Renderer from md2gost.render import Renderer
def table(r: Renderer) -> str: def table(r: Renderer, tok: 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 +40,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:
+2 -1
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 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
+1 -1
View File
@@ -93,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 = (
+11 -12
View File
@@ -9,6 +9,13 @@ 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", ["xelatex", "-interaction=nonstopmode",
@@ -16,10 +23,10 @@ def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
capture_output=True, text=True, 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",
@@ -46,36 +53,28 @@ def main():
renderer = Renderer(parser.tokens, parser.front_matter) renderer = Renderer(parser.tokens, parser.front_matter)
latex = renderer.render() latex = renderer.render()
# Собираем во временную директорию, копируем только .tex и .pdf
with tempfile.TemporaryDirectory(prefix="md2gost-") as cache: with tempfile.TemporaryDirectory(prefix="md2gost-") as cache:
cache_dir = Path(cache) cache_dir = Path(cache)
# Генерируем .tex в кэш
tex_name = args.input.with_suffix(".tex").name tex_name = args.input.with_suffix(".tex").name
tex_path = cache_dir / tex_name tex_path = cache_dir / tex_name
tex_path.write_text(latex, encoding="utf-8") tex_path.write_text(latex, encoding="utf-8")
# Копируем .tex в выходную директорию
final_tex = args.output / tex_name final_tex = args.output / tex_name
shutil.copy2(tex_path, final_tex) shutil.copy2(tex_path, final_tex)
print(f"{final_tex}") print(f"{final_tex}")
if not args.no_pdf: if not args.no_pdf:
# Компилируем PDF в кэше (два прохода для TOC)
if not run_xelatex(tex_path, cache_dir): if not run_xelatex(tex_path, cache_dir):
print("ошибка: xelatex не собрал PDF", file=sys.stderr) print("ошибка: xelatex не собрал PDF", file=sys.stderr)
log_path = cache_dir / "report.log" _print_latex_errors(cache_dir / "report.log")
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) sys.exit(1)
if not run_xelatex(tex_path, cache_dir): if not run_xelatex(tex_path, cache_dir):
print("ошибка: xelatex (2-й проход) не собрал PDF", file=sys.stderr) print("ошибка: xelatex (2-й проход) не собрал PDF", file=sys.stderr)
_print_latex_errors(cache_dir / "report.log")
sys.exit(1) sys.exit(1)
# Копируем .pdf в выходную директорию
pdf_name = args.input.with_suffix(".pdf").name pdf_name = args.input.with_suffix(".pdf").name
pdf_path = cache_dir / pdf_name pdf_path = cache_dir / pdf_name
final_pdf = args.output / pdf_name final_pdf = args.output / pdf_name
+6 -20
View File
@@ -16,18 +16,6 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
from markdown_it.token import Token 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 = {
"heading_open": heading, "heading_open": heading,
"paragraph_open": paragraph, "paragraph_open": paragraph,
@@ -38,9 +26,9 @@ _BLOCK = {
} }
_INLINE = { _INLINE = {
"fence": _fence, "fence": fence,
"image": _image, "image": image,
"inline": _inline, "inline": render_inline,
} }
@@ -49,9 +37,6 @@ class Renderer:
self.tokens = tokens self.tokens = tokens
self.fm = front_matter self.fm = front_matter
self.pos = 0 self.pos = 0
self.section = 0
self.subsection = 0
self.subsubsection = 0
self.current_heading = "" self.current_heading = ""
def render(self) -> str: def render(self) -> str:
@@ -63,12 +48,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