From b1d00a1fed8f81c1190aad3933c2a019d7cf8a3b Mon Sep 17 00:00:00 2001 From: Arseny <20096ninja@gmail.com> Date: Mon, 7 Sep 2026 17:48:59 +0300 Subject: [PATCH] =?UTF-8?q?[ADD]=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=D0=9F=D0=A0=D0=98=D0=9B=D0=9E=D0=96=D0=95=D0=9D=D0=98=D0=95=20?= =?UTF-8?q?=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BD=D0=B3=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D0=B5=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ПРИЛОЖЕНИЕ добавлено к ненумерованным разделам с новой страницы и попадает в содержание наравне с введением и заключением. Листинг длиннее страницы больше не оборачивается в плавающий объект: LaTeX не смог бы его разместить и вынес бы в конец документа. Рамка у такого листинга снимается — она всё равно обрывается на границе страницы. В приложении не плавает ни один листинг независимо от длины: там они идут подряд и без текста вокруг, а плавающий объект переставлял короткий листинг за длинный и ломал порядок. Чтобы обработчик знал раздел, составные обработчики теперь получают рендерер — как и блочные. Имя ненумерованного раздела ищется в исходном тексте, а не в готовом LaTeX: у выделенного «==ПРИЛОЖЕНИЕ==» готовый текст — это \hlw{ПРИЛОЖЕНИЕ}, и раздел нумеровался как обычный. --- md2gost/handlers/code.py | 34 +++++++++++++++++++++++-------- md2gost/handlers/heading.py | 9 +++++++-- md2gost/handlers/image.py | 2 +- md2gost/handlers/inline.py | 5 +++++ md2gost/handlers/table.py | 2 +- md2gost/registry.py | 7 +++++-- md2gost/render.py | 2 +- tests/test_render.py | 40 +++++++++++++++++++++++++++++++++++++ 8 files changed, 86 insertions(+), 15 deletions(-) diff --git a/md2gost/handlers/code.py b/md2gost/handlers/code.py index 4187fed..5a0ee4c 100644 --- a/md2gost/handlers/code.py +++ b/md2gost/handlers/code.py @@ -31,6 +31,14 @@ _LANGUAGES = { } _PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""} +# Сколько строк кода помещается на странице. Листинг длиннее не поместится ни +# в один плавающий объект, и LaTeX выбросит его в конец документа, поэтому +# такой набирается на месте. Рамка при этом снимается: она всё равно +# оборвалась бы на границе страницы и продолжилась на следующей. +PAGE_LINES = 34 + +APPENDIX = "ПРИЛОЖЕНИЕ" + def _language(info: str) -> str | None: """Имя языка для listings или None, если подсветка не нужна.""" @@ -47,8 +55,15 @@ def _language(info: str) -> str | None: return language -def _lstlisting(tok: Token, caption: str | None) -> str: +def _lstlisting(tok: Token, caption: str | None, in_appendix: bool = False) -> str: language = _language(tok.info) + code = tok.content.rstrip() + # В приложении листинги идут подряд и без текста вокруг. Плавающий объект + # там только путает порядок: LaTeX волен переставить короткий листинг за + # длинный. Рамка снимается заодно, чтобы соседние листинги выглядели + # одинаково независимо от длины. + fits_on_page = not in_appendix and code.count("\n") + 1 <= PAGE_LINES + opts = [] if language: # Значение в фигурных скобках обязательно: имя диалекта задаётся @@ -57,17 +72,20 @@ def _lstlisting(tok: Token, caption: str | None) -> str: opts.append(f"language={{{language}}}") if caption: opts.append(f"caption={{{escape_latex(caption)}}}") - # Плавающий листинг не разрывается между страницами: иначе рамка кода - # обрывается на границе и продолжается на следующей странице. - opts.append("float=htbp") + if fits_on_page: + # Плавающий листинг не разрывается между страницами: иначе рамка + # кода обрывается на границе и продолжается на следующей странице. + opts.append("float=htbp") + if not fits_on_page: + opts.append("frame=none") suffix = f"[{','.join(opts)}]" if opts else "" - return f"\\begin{{lstlisting}}{suffix}\n{tok.content.rstrip()}\n\\end{{lstlisting}}" + return f"\\begin{{lstlisting}}{suffix}\n{code}\n\\end{{lstlisting}}" @block("fence") def fence(r, tok: Token) -> str: r.pos += 1 - return _lstlisting(tok, None) + return _lstlisting(tok, None, r.current_heading == APPENDIX) def match_listing(tokens: list[Token], pos: int) -> int | None: @@ -88,7 +106,7 @@ def match_listing(tokens: list[Token], pos: int) -> int | None: @pattern(match_listing) -def captioned_listing(tokens: list[Token]) -> str: +def captioned_listing(r, 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)) + return _lstlisting(tokens[3], match.group(1), r.current_heading == APPENDIX) diff --git a/md2gost/handlers/heading.py b/md2gost/handlers/heading.py index 182ee24..0778970 100644 --- a/md2gost/handlers/heading.py +++ b/md2gost/handlers/heading.py @@ -2,7 +2,7 @@ from __future__ import annotations from markdown_it.token import Token -from md2gost.handlers.inline import render_inline +from md2gost.handlers.inline import render_inline, strip_marks from md2gost.registry import block UNNUMBERED = { @@ -11,6 +11,7 @@ UNNUMBERED = { "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", + "ПРИЛОЖЕНИЕ", } TOC_HEADING = "СОДЕРЖАНИЕ" @@ -19,6 +20,7 @@ NEWPAGE_BEFORE = { "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", + "ПРИЛОЖЕНИЕ", } @@ -29,7 +31,10 @@ def heading(r, tok: Token) -> str: text = render_inline(inline_tok) r.pos += 2 - upper = text.upper() + # Имя берётся из исходного текста, а не из готового LaTeX: в выделенном + # заголовке «==ПРИЛОЖЕНИЕ==» готовый текст — это \\hlw{ПРИЛОЖЕНИЕ}, и + # ненумерованный раздел не узнавался бы по имени. + upper = strip_marks(inline_tok.content).strip().upper() if upper == TOC_HEADING: r.pending_toc = True diff --git a/md2gost/handlers/image.py b/md2gost/handlers/image.py index 090cff8..60986c8 100644 --- a/md2gost/handlers/image.py +++ b/md2gost/handlers/image.py @@ -83,7 +83,7 @@ def match_figure(tokens: list[Token], pos: int) -> int | None: @pattern(match_figure) -def figure(tokens: list[Token]) -> str: +def figure(r, tokens: list[Token]) -> str: img_tok = _single_image_paragraph(tokens, 0) assert img_tok is not None raw_src = unquote(str(img_tok.attrGet("src") or "")) diff --git a/md2gost/handlers/inline.py b/md2gost/handlers/inline.py index 70c9729..43f2eb5 100644 --- a/md2gost/handlers/inline.py +++ b/md2gost/handlers/inline.py @@ -90,6 +90,11 @@ def bind_initials(text: str) -> str: _MARK_RE = re.compile(r"==(?=\S)(.+?)(?<=\S)==") +def strip_marks(text: str) -> str: + """Снять пометки правок, оставив сам текст.""" + return _MARK_RE.sub(lambda match: match.group(1), text) + + def render_text(text: str) -> str: return split_compounds(bind_initials(escape_latex(text))) diff --git a/md2gost/handlers/table.py b/md2gost/handlers/table.py index a227c3a..0d895b8 100644 --- a/md2gost/handlers/table.py +++ b/md2gost/handlers/table.py @@ -168,7 +168,7 @@ def match_table(tokens: list[Token], pos: int) -> int | None: @pattern(match_table) -def captioned_table(tokens: list[Token]) -> str: +def captioned_table(r, tokens: list[Token]) -> str: m = _CAPTION_RE.match(tokens[1].content) assert m is not None caption = escape_latex(m.group(1)) diff --git a/md2gost/registry.py b/md2gost/registry.py index 9f275e0..06cf34d 100644 --- a/md2gost/registry.py +++ b/md2gost/registry.py @@ -8,6 +8,8 @@ - ``PATTERNS`` — упорядоченный список составных условий. Функция сопоставления ``match(tokens, pos) -> int | None`` возвращает число потребляемых токенов или None; чистая функция без побочных эффектов. Проверяются до BLOCK. + Обработчик получает ``(renderer, tokens)``: составному блоку бывает нужен + раздел, в котором он оказался. - ``INLINE`` — обработчики инлайн-токенов внутри абзацев/заголовков. Обработчик получает только токен и возвращает LaTeX-строку. @@ -27,7 +29,7 @@ if TYPE_CHECKING: BlockHandler = Callable[["Renderer", "Token"], str] InlineHandler = Callable[["Token"], str] MatchFn = Callable[[list["Token"], int], "int | None"] -PatternHandler = Callable[[list["Token"]], str] +PatternHandler = Callable[["Renderer", list["Token"]], str] BLOCK: dict[str, BlockHandler] = {} INLINE: dict[str, InlineHandler] = {} @@ -50,7 +52,8 @@ def pattern(match: MatchFn) -> Callable[[PatternHandler], PatternHandler]: ``match(tokens, pos)`` возвращает количество потребляемых токенов, начиная с ``pos``, либо None если условие не выполнено. Обработчик - получает срез ``tokens[pos : pos + n]``; pos двигает сам рендерер. + получает рендерер и срез ``tokens[pos : pos + n]``; pos двигает сам + рендерер. """ def deco(fn: PatternHandler) -> PatternHandler: diff --git a/md2gost/render.py b/md2gost/render.py index 8b43f14..50258ad 100644 --- a/md2gost/render.py +++ b/md2gost/render.py @@ -75,7 +75,7 @@ class Renderer: span = match_fn(tokens, pos) if span is not None and span > 0: self.pos += span - return handler_fn(tokens[pos : pos + span]) + return handler_fn(self, tokens[pos : pos + span]) handler = registry.BLOCK.get(tok.type) if handler is None: diff --git a/tests/test_render.py b/tests/test_render.py index 02fc5d0..0488a70 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,5 +1,6 @@ import pytest +from md2gost.handlers.code import PAGE_LINES from md2gost.handlers.table import _STRETCH from md2gost.latex.titlepages import TITLEPAGES from md2gost.parser import Parser @@ -518,6 +519,45 @@ def test_abbreviation_column_is_as_wide_as_the_abbreviation() -> None: assert "\\begin{tabularx}{\\linewidth}" in tex +def test_appendix_starts_a_new_page_and_reaches_the_contents() -> None: + tex = render("# ПРИЛОЖЕНИЕ\n\nЛистинг 1 — Код\n\n```cpp\nint x;\n```\n") + assert "\\section{" not in tex + assert "\\clearpage" in tex + assert "\\addcontentsline{toc}{section}{ПРИЛОЖЕНИЕ}" in tex + + +def test_marked_unnumbered_heading_is_still_recognised() -> None: + """В «==ПРИЛОЖЕНИЕ==» имя раздела не должно теряться за разметкой.""" + tex = render("# ==ПРИЛОЖЕНИЕ==\n\nТекст.\n") + assert "\\section{" not in tex + assert "\\clearpage" in tex + assert "\\hlw{ПРИЛОЖЕНИЕ}" in tex + + +def test_long_listing_does_not_float() -> None: + """Листинг длиннее страницы не помещается в плавающий объект.""" + code = "\n".join(f"int x{i};" for i in range(PAGE_LINES + 1)) + tex = body(render(f"Листинг 1 — Длинный\n\n```cpp\n{code}\n```\n")) + assert "caption={Длинный}" in tex + assert "float=" not in tex + assert "frame=none" in tex + + +def test_listing_in_appendix_does_not_float() -> None: + """В приложении плавающий объект переставил бы листинги местами.""" + tex = body(render( + "# ПРИЛОЖЕНИЕ\n\nЛистинг 1 — Короткий\n\n```cpp\nint x;\n```\n" + )) + assert "float=" not in tex + assert "frame=none" in tex + + +def test_short_listing_keeps_the_frame() -> None: + tex = body(render("Листинг 1 — Короткий\n\n```cpp\nint x;\n```\n")) + assert "float=htbp" in tex + assert "frame=none" not in tex + + def test_cpp_uses_extended_dialect() -> None: """Встроенный C++ в listings не знает auto, nullptr и override.""" tex = body(render("```cpp\nauto x = nullptr;\n```\n"))