From f616dc0cd59235b85a1e1256cd34188bc0bfd4f7 Mon Sep 17 00:00:00 2001 From: Arseny <20096ninja@gmail.com> Date: Wed, 2 Sep 2026 03:31:27 +0300 Subject: [PATCH] =?UTF-8?q?[ADD]=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=93=D0=9E=D0=A1=D0=A2=20=D0=B8=20=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20=D0=BA=D0=B5=D0=B3?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- md2gost/handlers/__init__.py | 4 +- md2gost/handlers/code.py | 84 +++++++++++++++++++++++++++++++++--- md2gost/handlers/heading.py | 4 +- md2gost/latex/preamble.py | 4 +- md2gost/render.py | 1 + tests/test_render.py | 56 ++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 8 deletions(-) diff --git a/md2gost/handlers/__init__.py b/md2gost/handlers/__init__.py index b8f634a..7ddf812 100644 --- a/md2gost/handlers/__init__.py +++ b/md2gost/handlers/__init__.py @@ -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.hr import hr from md2gost.handlers.image import figure, match_figure @@ -9,6 +9,8 @@ from md2gost.handlers.text import paragraph __all__ = [ "fence", + "captioned_listing", + "match_listing", "hr", "heading", "figure", diff --git a/md2gost/handlers/code.py b/md2gost/handlers/code.py index 3daa88d..c30e17e 100644 --- a/md2gost/handlers/code.py +++ b/md2gost/handlers/code.py @@ -1,14 +1,88 @@ from __future__ import annotations +import re +import sys + 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") def fence(r, tok: Token) -> str: r.pos += 1 - lang = tok.info.strip() - code = tok.content.rstrip() - opts = f"[language={lang}]" if lang else "" - return f"\\begin{{lstlisting}}{opts}\n{code}\n\\end{{lstlisting}}" + return _lstlisting(tok, None) + + +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)) diff --git a/md2gost/handlers/heading.py b/md2gost/handlers/heading.py index 62aff4a..182ee24 100644 --- a/md2gost/handlers/heading.py +++ b/md2gost/handlers/heading.py @@ -43,7 +43,9 @@ def heading(r, tok: Token) -> str: prefix = "\\clearpage\n" toc_entry = "" if upper in NEWPAGE_BEFORE: - toc_entry = f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}" + toc_entry = ( + f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}" + ) return ( f"{prefix}" f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}" diff --git a/md2gost/latex/preamble.py b/md2gost/latex/preamble.py index 503c348..c8c78c7 100644 --- a/md2gost/latex/preamble.py +++ b/md2gost/latex/preamble.py @@ -29,7 +29,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle} \captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright} \usepackage{listings} \lstset{ - basicstyle=\ttfamily\footnotesize, + basicstyle=\ttfamily\small\setstretch{1.0}, breakatwhitespace=false, breaklines=true, captionpos=t, @@ -47,6 +47,8 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle} stepnumber=1, tabsize=2, } +\renewcommand{\lstlistingname}{Листинг} +\captionsetup[lstlisting]{labelsep=emdash,font=onehalfspacing,position=top,singlelinecheck=false,justification=raggedright} \usepackage{amsthm,amsfonts,amsmath,amssymb,amscd} \usepackage{mathtools} \usepackage{unicode-math} diff --git a/md2gost/render.py b/md2gost/render.py index b25c6c3..8b43f14 100644 --- a/md2gost/render.py +++ b/md2gost/render.py @@ -22,6 +22,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage # Импорт обработчиков регистрирует их в реестрах. from md2gost.handlers import ( # noqa: F401 bullet_list, + captioned_listing, captioned_table, fence, figure, diff --git a/tests/test_render.py b/tests/test_render.py index 0a298ee..16587b7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -309,3 +309,59 @@ def test_ptp_task_has_no_hardcoded_assignment_text() -> None: 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 \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