Compare commits

..

4 Commits

Author SHA1 Message Date
Arseny b1d00a1fed [ADD] раздел ПРИЛОЖЕНИЕ и листинги длиннее страницы
ПРИЛОЖЕНИЕ добавлено к ненумерованным разделам с новой страницы и попадает
в содержание наравне с введением и заключением.

Листинг длиннее страницы больше не оборачивается в плавающий объект: LaTeX
не смог бы его разместить и вынес бы в конец документа. Рамка у такого
листинга снимается — она всё равно обрывается на границе страницы.

В приложении не плавает ни один листинг независимо от длины: там они идут
подряд и без текста вокруг, а плавающий объект переставлял короткий листинг
за длинный и ломал порядок. Чтобы обработчик знал раздел, составные
обработчики теперь получают рендерер — как и блочные.

Имя ненумерованного раздела ищется в исходном тексте, а не в готовом LaTeX:
у выделенного «==ПРИЛОЖЕНИЕ==» готовый текст — это \hlw{ПРИЛОЖЕНИЕ}, и
раздел нумеровался как обычный.
2026-09-07 17:48:59 +03:00
Arseny 24d3cd0728 [FIX] перечень сокращений: до тире обычный пробел, разбор пометок в подписи рисунка
Колонка аббревиатур больше не считается на глаз: обычная колонка l берёт
ширину по самой длинной аббревиатуре, тире и пробелы вокруг него ушли в
разделитель колонок. Расшифровка целиком лежит в колонке X, поэтому её
перенос по-прежнему встаёт под первым словом расшифровки.

Подпись рисунка не проходит через инлайн-рендер, и пометка ==текст== в ней
печаталась как есть. Разбор пометок вынесен в render_marked и вызывается
из обработчика рисунка.
2026-09-07 17:24:57 +03:00
Arseny fa440049e1 [ADD] пометка правок ==текст== жёлтым выделением
Рецензент просит показывать в отчёте только исправленные места, чтобы не
перечитывать текст целиком. Разметка разбирается на уровне текстовых узлов,
поэтому «==» в листингах на C++ остаётся сравнением.

Выделяется каждое слово отдельно: одной плашкой на всё выделение строка не
разорвётся по его середине, а выделять приходится и целые предложения.
Составное слово делится по дефису — плашка неразрывна, и шорткат переноса
внутри неё не работает. soul не подошёл: под XeLaTeX его \hl теряет кириллицу.
2026-09-07 17:06:17 +03:00
Arseny be64bf475f [FIX] оформление по замечаниям рецензента: подпись рисунка и перечень сокращений
Подпись под рисунком выравнивается по центру: в две строки caption по
умолчанию выключал текст по ширине, и подпись читалась как обычный абзац.

Колонка аббревиатур сузилась: на прописную букву в Times уходит около
0,75em, а закладывался целый em на символ, из-за чего тире отъезжало от
аббревиатуры почти на треть строки.
2026-09-07 17:06:17 +03:00
11 changed files with 196 additions and 36 deletions
+1
View File
@@ -51,6 +51,7 @@ docker build -t md2gost .
- Изображения (см. ниже) - Изображения (см. ниже)
- Аббревиатуры (раздел "Перечень принятых сокращений") - Аббревиатуры (раздел "Перечень принятых сокращений")
- Ссылки, жирный, курсив - Ссылки, жирный, курсив
- Пометка правок `==текст==` — жёлтое выделение для рецензента
### Изображения ### Изображения
+26 -8
View File
@@ -31,6 +31,14 @@ _LANGUAGES = {
} }
_PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""} _PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""}
# Сколько строк кода помещается на странице. Листинг длиннее не поместится ни
# в один плавающий объект, и LaTeX выбросит его в конец документа, поэтому
# такой набирается на месте. Рамка при этом снимается: она всё равно
# оборвалась бы на границе страницы и продолжилась на следующей.
PAGE_LINES = 34
APPENDIX = "ПРИЛОЖЕНИЕ"
def _language(info: str) -> str | None: def _language(info: str) -> str | None:
"""Имя языка для listings или None, если подсветка не нужна.""" """Имя языка для listings или None, если подсветка не нужна."""
@@ -47,8 +55,15 @@ def _language(info: str) -> str | None:
return language 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) language = _language(tok.info)
code = tok.content.rstrip()
# В приложении листинги идут подряд и без текста вокруг. Плавающий объект
# там только путает порядок: LaTeX волен переставить короткий листинг за
# длинный. Рамка снимается заодно, чтобы соседние листинги выглядели
# одинаково независимо от длины.
fits_on_page = not in_appendix and code.count("\n") + 1 <= PAGE_LINES
opts = [] opts = []
if language: if language:
# Значение в фигурных скобках обязательно: имя диалекта задаётся # Значение в фигурных скобках обязательно: имя диалекта задаётся
@@ -57,17 +72,20 @@ def _lstlisting(tok: Token, caption: str | None) -> str:
opts.append(f"language={{{language}}}") opts.append(f"language={{{language}}}")
if caption: if caption:
opts.append(f"caption={{{escape_latex(caption)}}}") opts.append(f"caption={{{escape_latex(caption)}}}")
# Плавающий листинг не разрывается между страницами: иначе рамка кода if fits_on_page:
# обрывается на границе и продолжается на следующей странице. # Плавающий листинг не разрывается между страницами: иначе рамка
opts.append("float=htbp") # кода обрывается на границе и продолжается на следующей странице.
opts.append("float=htbp")
if not fits_on_page:
opts.append("frame=none")
suffix = f"[{','.join(opts)}]" if opts else "" 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") @block("fence")
def fence(r, tok: Token) -> str: def fence(r, tok: Token) -> str:
r.pos += 1 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: 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) @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) match = _CAPTION_RE.match(tokens[1].content)
assert match is not None # гарантировано match_listing 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)
+7 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from markdown_it.token import Token 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 from md2gost.registry import block
UNNUMBERED = { UNNUMBERED = {
@@ -11,6 +11,7 @@ UNNUMBERED = {
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
"ПРИЛОЖЕНИЕ",
} }
TOC_HEADING = "СОДЕРЖАНИЕ" TOC_HEADING = "СОДЕРЖАНИЕ"
@@ -19,6 +20,7 @@ NEWPAGE_BEFORE = {
"ВВЕДЕНИЕ", "ВВЕДЕНИЕ",
"ЗАКЛЮЧЕНИЕ", "ЗАКЛЮЧЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
"ПРИЛОЖЕНИЕ",
} }
@@ -29,7 +31,10 @@ def heading(r, tok: Token) -> str:
text = render_inline(inline_tok) text = render_inline(inline_tok)
r.pos += 2 r.pos += 2
upper = text.upper() # Имя берётся из исходного текста, а не из готового LaTeX: в выделенном
# заголовке «==ПРИЛОЖЕНИЕ==» готовый текст — это \\hlw{ПРИЛОЖЕНИЕ}, и
# ненумерованный раздел не узнавался бы по имени.
upper = strip_marks(inline_tok.content).strip().upper()
if upper == TOC_HEADING: if upper == TOC_HEADING:
r.pending_toc = True r.pending_toc = True
+3 -3
View File
@@ -30,7 +30,7 @@ from urllib.parse import unquote
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.handlers.inline import escape_latex from md2gost.handlers.inline import escape_latex, render_marked
from md2gost.registry import pattern from md2gost.registry import pattern
DEFAULT_WIDTH = 0.8 DEFAULT_WIDTH = 0.8
@@ -83,7 +83,7 @@ def match_figure(tokens: list[Token], pos: int) -> int | None:
@pattern(match_figure) @pattern(match_figure)
def figure(tokens: list[Token]) -> str: def figure(r, tokens: list[Token]) -> str:
img_tok = _single_image_paragraph(tokens, 0) img_tok = _single_image_paragraph(tokens, 0)
assert img_tok is not None assert img_tok is not None
raw_src = unquote(str(img_tok.attrGet("src") or "")) raw_src = unquote(str(img_tok.attrGet("src") or ""))
@@ -93,7 +93,7 @@ def figure(tokens: list[Token]) -> str:
if len(tokens) >= 5: if len(tokens) >= 5:
m = _CAPTION_RE.match(tokens[4].content) m = _CAPTION_RE.match(tokens[4].content)
if m: if m:
caption = escape_latex(m.group(1)) caption = render_marked(m.group(1), escape_latex)
lines = [ lines = [
"\\begin{figure}[htb]", "\\begin{figure}[htb]",
+56 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import re import re
import sys import sys
from collections.abc import Callable
from markdown_it.token import Token from markdown_it.token import Token
@@ -81,9 +82,63 @@ def bind_initials(text: str) -> str:
return text return text
# ==текст== — пометка правки для рецензента. Разметки такого рода в
# CommonMark нет, поэтому она разбирается здесь, на уровне текстовых узлов:
# внутрь листингов и инлайн-кода регулярное выражение не попадает, и «==»
# в исходниках на C++ остаётся сравнением. Границы выделения примыкают к
# тексту вплотную — как у прочих парных знаков разметки.
_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)))
def highlight_word(word: str) -> str:
"""Слово в жёлтой плашке, составное — по плашке на каждую часть.
Плашка неразрывна, поэтому шорткат переноса «"=» внутри неё не работает
и «аппаратно-программный» целиком вылезает за поле. Дефис получает свою
плашку, а точка переноса ставится после неё.
"""
parts = [part for part in word.split("-") if part]
return "\\hlw{-}\\allowbreak ".join(f"\\hlw{{{part}}}" for part in parts)
def highlight(text: str) -> str:
"""Жёлтая плашка на каждом слове.
Одной плашкой на всё выделение строка не разорвётся по его середине,
а выделять приходится и целые предложения.
"""
escaped = bind_initials(escape_latex(text))
return " ".join(highlight_word(word) for word in escaped.split(" ") if word)
def render_marked(text: str, plain: Callable[[str], str] = render_text) -> str:
"""Разобрать пометки правок, остальное отдать ``plain``.
Подпись рисунка не проходит через инлайн-рендер, но выделять правки
нужно и в ней, поэтому разбор вынесен из обработчика текстового узла.
"""
parts: list[str] = []
end = 0
for match in _MARK_RE.finditer(text):
parts.append(plain(text[end : match.start()]))
parts.append(highlight(match.group(1)))
end = match.end()
parts.append(plain(text[end:]))
return "".join(parts)
@inline("text") @inline("text")
def _text(tok: Token) -> str: def _text(tok: Token) -> str:
return split_compounds(bind_initials(escape_latex(tok.content))) return render_marked(tok.content)
@inline("strong_open") @inline("strong_open")
+1 -1
View File
@@ -168,7 +168,7 @@ def match_table(tokens: list[Token], pos: int) -> int | None:
@pattern(match_table) @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) m = _CAPTION_RE.match(tokens[1].content)
assert m is not None assert m is not None
caption = escape_latex(m.group(1)) caption = escape_latex(m.group(1))
+11 -15
View File
@@ -33,20 +33,16 @@ def abbreviations(text: str) -> str:
if not rows: if not rows:
return text return text
max_abbr = max(len(r[0]) for r in rows) # Колонка l сама берёт ширину самой длинной аббревиатуры, поэтому тире
abbr_col = max_abbr + 2 # выстраиваются в столбец без подсчёта ширин на глаз, а отбивка до тире
tabular_rows = [] # остаётся обычным пробелом. Расшифровка целиком лежит в колонке X, и её
for abbr, desc in rows: # перенос встаёт под первым словом расшифровки, а не под тире.
pad = max_abbr - len(abbr) + 2 body = "\n".join(f"{abbr} & {desc} \\\\" for abbr, desc in rows)
tabular_rows.append(
f"{abbr} {' ' * pad}& \\hangafter=1\\hangindent=2em --- {desc} \\\\"
)
# Отбивки колонок убраны с обеих сторон и между ними: иначе сумма ширин # Отбивки колонок убраны с обоих краёв: иначе сумма ширин превышает
# превышает \linewidth на 2\tabcolsep и перечень выходит за правое поле. # \linewidth на 2\tabcolsep и перечень выходит за правое поле.
col_spec = ( return (
f"@{{\\hspace{{0pt}}}}p{{{abbr_col}em}}" "\\noindent\\begin{tabularx}{\\linewidth}{@{}l@{\\ ---\\ }X@{}}\n"
f"@{{}}p{{\\dimexpr\\linewidth-{abbr_col}em\\relax}}@{{\\hspace{{0pt}}}}" f"{body}\n"
"\\end{tabularx}"
) )
body = "\n".join(tabular_rows)
return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
+7 -1
View File
@@ -40,11 +40,17 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage{hyperref} \usepackage{hyperref}
\hypersetup {unicode=true} \hypersetup {unicode=true}
\DeclareCaptionLabelSeparator*{emdash}{~--- } \DeclareCaptionLabelSeparator*{emdash}{~--- }
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок} \captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок,justification=centering}
\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}
\usepackage{xcolor} \usepackage{xcolor}
% Пометка правок: ==текст== в Markdown. Выделяется каждое слово отдельно,
% иначе строка не разорвётся по границе выделения. soul не подошёл: под
% XeLaTeX его \hl теряет кириллицу. Команда объявлена устойчивой — она
% попадает в заголовки, а оттуда в содержание.
\DeclareRobustCommand{\hlw}[1]{{\setlength{\fboxsep}{1pt}\colorbox{yellow}{#1}}}
% Палитра подсветки. Тона различаются не только цветом, но и светлотой, % Палитра подсветки. Тона различаются не только цветом, но и светлотой,
% поэтому листинг остаётся читаемым и на чёрно-белой печати. % поэтому листинг остаётся читаемым и на чёрно-белой печати.
\definecolor{lstkeyword}{RGB}{0,0,139} \definecolor{lstkeyword}{RGB}{0,0,139}
+5 -2
View File
@@ -8,6 +8,8 @@
- ``PATTERNS`` — упорядоченный список составных условий. Функция сопоставления - ``PATTERNS`` — упорядоченный список составных условий. Функция сопоставления
``match(tokens, pos) -> int | None`` возвращает число потребляемых токенов ``match(tokens, pos) -> int | None`` возвращает число потребляемых токенов
или None; чистая функция без побочных эффектов. Проверяются до BLOCK. или None; чистая функция без побочных эффектов. Проверяются до BLOCK.
Обработчик получает ``(renderer, tokens)``: составному блоку бывает нужен
раздел, в котором он оказался.
- ``INLINE`` — обработчики инлайн-токенов внутри абзацев/заголовков. - ``INLINE`` — обработчики инлайн-токенов внутри абзацев/заголовков.
Обработчик получает только токен и возвращает LaTeX-строку. Обработчик получает только токен и возвращает LaTeX-строку.
@@ -27,7 +29,7 @@ if TYPE_CHECKING:
BlockHandler = Callable[["Renderer", "Token"], str] BlockHandler = Callable[["Renderer", "Token"], str]
InlineHandler = Callable[["Token"], str] InlineHandler = Callable[["Token"], str]
MatchFn = Callable[[list["Token"], int], "int | None"] MatchFn = Callable[[list["Token"], int], "int | None"]
PatternHandler = Callable[[list["Token"]], str] PatternHandler = Callable[["Renderer", list["Token"]], str]
BLOCK: dict[str, BlockHandler] = {} BLOCK: dict[str, BlockHandler] = {}
INLINE: dict[str, InlineHandler] = {} INLINE: dict[str, InlineHandler] = {}
@@ -50,7 +52,8 @@ def pattern(match: MatchFn) -> Callable[[PatternHandler], PatternHandler]:
``match(tokens, pos)`` возвращает количество потребляемых токенов, ``match(tokens, pos)`` возвращает количество потребляемых токенов,
начиная с ``pos``, либо None если условие не выполнено. Обработчик начиная с ``pos``, либо None если условие не выполнено. Обработчик
получает срез ``tokens[pos : pos + n]``; pos двигает сам рендерер. получает рендерер и срез ``tokens[pos : pos + n]``; pos двигает сам
рендерер.
""" """
def deco(fn: PatternHandler) -> PatternHandler: def deco(fn: PatternHandler) -> PatternHandler:
+1 -1
View File
@@ -75,7 +75,7 @@ class Renderer:
span = match_fn(tokens, pos) span = match_fn(tokens, pos)
if span is not None and span > 0: if span is not None and span > 0:
self.pos += span self.pos += span
return handler_fn(tokens[pos : pos + span]) return handler_fn(self, tokens[pos : pos + span])
handler = registry.BLOCK.get(tok.type) handler = registry.BLOCK.get(tok.type)
if handler is None: if handler is None:
+78 -2
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from md2gost.handlers.code import PAGE_LINES
from md2gost.handlers.table import _STRETCH from md2gost.handlers.table import _STRETCH
from md2gost.latex.titlepages import TITLEPAGES from md2gost.latex.titlepages import TITLEPAGES
from md2gost.parser import Parser from md2gost.parser import Parser
@@ -37,6 +38,35 @@ def test_unnumbered_heading() -> None:
assert "ВВЕДЕНИЕ" in tex assert "ВВЕДЕНИЕ" in tex
def test_mark_highlights_every_word() -> None:
tex = render("Обычный ==правленый текст== дальше.\n")
assert "\\hlw{правленый} \\hlw{текст}" in tex
assert "Обычный " in tex
assert " дальше." in tex
def test_mark_splits_compound_word_into_separate_boxes() -> None:
# Плашка неразрывна: составное слово одним боксом вылезает за поле.
tex = render("==аппаратно-программный комплекс==\n")
assert "\\hlw{аппаратно}\\hlw{-}\\allowbreak \\hlw{программный}" in tex
def test_mark_in_figure_caption() -> None:
tex = render("![[images/a.png]]\n\nРисунок 1 — ==Схема== комплекса\n")
assert "\\caption{\\hlw{Схема} комплекса}" in tex
def test_mark_in_heading_reaches_the_contents() -> None:
tex = render("## ==Контейнеризация серверного приложения==\n")
assert "\\subsection{\\hlw{Контейнеризация}" in tex
def test_comparison_in_listing_is_not_a_mark() -> None:
tex = body(render("```cpp\nif (a==b && c==d) return;\n```\n"))
assert "\\hlw" not in tex
assert "a==b" in tex
def test_list_punctuation() -> None: def test_list_punctuation() -> None:
tex = render("- один\n- два\n- три\n") tex = render("- один\n- два\n- три\n")
assert " \\item один;" in tex assert " \\item один;" in tex
@@ -473,13 +503,59 @@ def test_abbreviation_dash_is_single() -> None:
"""Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного.""" """Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного."""
tex = body(render(ABBR_MD)) tex = body(render(ABBR_MD))
assert "------" not in tex assert "------" not in tex
assert "--- Software-Defined Radio" in tex assert "SDR & Software-Defined Radio" in tex
def test_abbreviation_table_has_no_extra_column_padding() -> None: def test_abbreviation_table_has_no_extra_column_padding() -> None:
"""Отбивка между колонками добавляется к \\linewidth и роняет строку за поле.""" """Отбивка между колонками добавляется к \\linewidth и роняет строку за поле."""
tex = body(render(ABBR_MD)) tex = body(render(ABBR_MD))
assert "@{}p{\\dimexpr\\linewidth" in tex assert "{@{}l@{\\ ---\\ }X@{}}" in tex
def test_abbreviation_column_is_as_wide_as_the_abbreviation() -> None:
"""Колонка l берёт ширину по содержимому: до тире остаётся пробел."""
tex = body(render(ABBR_MD))
assert "p{" not in tex
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: def test_cpp_uses_extended_dialect() -> None: