diff --git a/Dockerfile b/Dockerfile index d06e4c8..70ccd87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ 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 \ diff --git a/md2gost/handlers/__init__.py b/md2gost/handlers/__init__.py index d7c466f..724d20a 100644 --- a/md2gost/handlers/__init__.py +++ b/md2gost/handlers/__init__.py @@ -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 diff --git a/md2gost/handlers/code.py b/md2gost/handlers/code.py index 73f879d..cbe4843 100644 --- a/md2gost/handlers/code.py +++ b/md2gost/handlers/code.py @@ -10,8 +10,5 @@ if TYPE_CHECKING: 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}}" diff --git a/md2gost/handlers/heading.py b/md2gost/handlers/heading.py index ace926c..4c00178 100644 --- a/md2gost/handlers/heading.py +++ b/md2gost/handlers/heading.py @@ -5,6 +5,7 @@ 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 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] 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() == TOC_HEADING: + if upper == TOC_HEADING: return "" - if text.upper() in UNNUMBERED: + if upper in UNNUMBERED: prefix = "" - if text.upper() in NEWPAGE_BEFORE: + if upper in NEWPAGE_BEFORE: prefix = "\\clearpage\n" return ( f"{prefix}" @@ -46,16 +48,10 @@ def heading(r: Renderer) -> str: ) if level == 1: - r.section += 1 - r.subsection = 0 - r.subsubsection = 0 return f"\\section{{{text}}}" elif level == 2: - r.subsection += 1 - r.subsubsection = 0 return f"\\subsection{{{text}}}" elif level == 3: - r.subsubsection += 1 return f"\\subsubsection{{{text}}}" else: return f"\\textbf{{{text}}}" diff --git a/md2gost/handlers/hr.py b/md2gost/handlers/hr.py new file mode 100644 index 0000000..0807014 --- /dev/null +++ b/md2gost/handlers/hr.py @@ -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}" diff --git a/md2gost/handlers/inline.py b/md2gost/handlers/inline.py index 1da0a96..734dd2a 100644 --- a/md2gost/handlers/inline.py +++ b/md2gost/handlers/inline.py @@ -5,10 +5,27 @@ from typing import TYPE_CHECKING 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 +33,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": @@ -29,11 +46,11 @@ def render_inline_tokens(tokens: list[Token]) -> str: parts.append(f"\\verb|{tok.content}|") elif tok.type == "link_open": href = tok.attrGet("href") or "" - parts.append(f"\\href{{{href}}}{{") + 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) diff --git a/md2gost/handlers/lists.py b/md2gost/handlers/lists.py index 4585a70..796eb0e 100644 --- a/md2gost/handlers/lists.py +++ b/md2gost/handlers/lists.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: 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 +22,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 +31,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}}" diff --git a/md2gost/handlers/table.py b/md2gost/handlers/table.py index 0ce2111..f8e94db 100644 --- a/md2gost/handlers/table.py +++ b/md2gost/handlers/table.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from md2gost.render import Renderer -def table(r: Renderer) -> str: +def table(r: Renderer, tok: None = None) -> str: rows: list[list[str]] = [] r.pos += 1 while r.pos < len(r.tokens): @@ -40,7 +40,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: diff --git a/md2gost/handlers/text.py b/md2gost/handlers/text.py index def5ce0..f3c8808 100644 --- a/md2gost/handlers/text.py +++ b/md2gost/handlers/text.py @@ -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 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 diff --git a/md2gost/latex/preamble.py b/md2gost/latex/preamble.py index 69e1c4e..6de3ca9 100644 --- a/md2gost/latex/preamble.py +++ b/md2gost/latex/preamble.py @@ -93,4 +93,4 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle} \begin{document}""" -END_DOCUMENT = r"\end{document}" +END_DOCUMENT = r"\end{document}" + "\n" diff --git a/md2gost/latex/titlepage.py b/md2gost/latex/titlepage.py index 10e0f14..0493727 100644 --- a/md2gost/latex/titlepage.py +++ b/md2gost/latex/titlepage.py @@ -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 = ( diff --git a/md2gost/main.py b/md2gost/main.py index 968c193..36516db 100644 --- a/md2gost/main.py +++ b/md2gost/main.py @@ -9,6 +9,13 @@ 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", @@ -16,10 +23,10 @@ def run_xelatex(tex_path: Path, output_dir: Path) -> bool: 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", @@ -46,36 +53,28 @@ def main(): renderer = Renderer(parser.tokens, parser.front_matter) latex = renderer.render() - # Собираем во временную директорию, копируем только .tex и .pdf with tempfile.TemporaryDirectory(prefix="md2gost-") as cache: cache_dir = Path(cache) - # Генерируем .tex в кэш tex_name = args.input.with_suffix(".tex").name tex_path = cache_dir / tex_name tex_path.write_text(latex, encoding="utf-8") - # Копируем .tex в выходную директорию final_tex = args.output / tex_name shutil.copy2(tex_path, final_tex) print(f"→ {final_tex}") if not args.no_pdf: - # Компилируем PDF в кэше (два прохода для TOC) if not run_xelatex(tex_path, cache_dir): print("ошибка: xelatex не собрал PDF", file=sys.stderr) - log_path = 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) + _print_latex_errors(cache_dir / "report.log") sys.exit(1) 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 в выходную директорию pdf_name = args.input.with_suffix(".pdf").name pdf_path = cache_dir / pdf_name final_pdf = args.output / pdf_name diff --git a/md2gost/render.py b/md2gost/render.py index 0d4e390..05764f9 100644 --- a/md2gost/render.py +++ b/md2gost/render.py @@ -16,18 +16,6 @@ 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 = { "heading_open": heading, "paragraph_open": paragraph, @@ -38,9 +26,9 @@ _BLOCK = { } _INLINE = { - "fence": _fence, - "image": _image, - "inline": _inline, + "fence": fence, + "image": image, + "inline": render_inline, } @@ -49,9 +37,6 @@ class Renderer: self.tokens = tokens self.fm = front_matter self.pos = 0 - self.section = 0 - self.subsection = 0 - self.subsubsection = 0 self.current_heading = "" def render(self) -> str: @@ -63,12 +48,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