[ADD] подписи листингов по ГОСТ и читаемый кегль кода
This commit is contained in:
@@ -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}}"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -309,3 +309,59 @@ def test_ptp_task_has_no_hardcoded_assignment_text() -> None:
|
|||||||
source = inspect.getsource(ptp_task)
|
source = inspect.getsource(ptp_task)
|
||||||
for forbidden in ("Web", "2D-игра", "GitFlic", "Javascript", "Посевин", "Коновалов"):
|
for forbidden in ("Web", "2D-игра", "GitFlic", "Javascript", "Посевин", "Коновалов"):
|
||||||
assert forbidden not in source
|
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