Compare commits

..

16 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
Arseny beac1d5a7d [FIX] разреженные строки: переносы в составных словах и в инлайн-коде 2026-09-02 13:45:00 +03:00
Arseny 7c518cd59d [FIX] комментарии в листингах не рябят двумя цветами 2026-09-02 13:21:24 +03:00
Arseny 95bd42cea4 [FIX] параметры размещения плавающих объектов: крупный рисунок остаётся на своей странице 2026-09-02 13:16:18 +03:00
Arseny d5b6f4b89e [ADD] тест вёрстки: эталонный документ собирается без выходов за поле 2026-09-02 13:12:36 +03:00
Arseny 82cde7b94b [FIX] отбивки заголовков, маркер переноса строк кода, неразрывные инициалы 2026-09-02 13:11:56 +03:00
Arseny 2a0333c673 [FIX] рисунки, таблицы и листинги плавают: нет пустых страниц и разрывов рамки 2026-09-02 13:10:18 +03:00
Arseny a3d41af022 [ADD] подсветка синтаксиса C и C++ в листингах 2026-09-02 13:05:05 +03:00
Arseny 7f20bd9831 [FIX] блок подписей не выходит за поле и выровнен по правому краю 2026-09-02 13:04:09 +03:00
Arseny c765892d7d [FIX] шапка титульника, ширина номера в оглавлении и перечень сокращений в пределах поля 2026-09-02 13:03:05 +03:00
Arseny 5ebdf8f9db [FIX] узкие колонки таблиц не растягиваются на всю ширину 2026-09-02 12:58:19 +03:00
Arseny f426e865b5 [FIX] длинные токены в таблицах переносятся, а не налезают на соседнюю колонку 2026-09-02 12:53:35 +03:00
Arseny 6b1f508825 [ADD] вывод предупреждений и ошибок LaTeX наружу из контейнера 2026-09-02 12:52:05 +03:00
16 changed files with 903 additions and 79 deletions
+1
View File
@@ -51,6 +51,7 @@ docker build -t md2gost .
- Изображения (см. ниже) - Изображения (см. ниже)
- Аббревиатуры (раздел "Перечень принятых сокращений") - Аббревиатуры (раздел "Перечень принятых сокращений")
- Ссылки, жирный, курсив - Ссылки, жирный, курсив
- Пометка правок `==текст==` — жёлтое выделение для рецензента
### Изображения ### Изображения
+32 -8
View File
@@ -15,8 +15,8 @@ _CAPTION_RE = re.compile(r"^\s*Листинг\s+\d+\s*[—–-]+\s*(.+?)\s*$")
# подсветки код набирается моноширинным без выделения ключевых слов. # подсветки код набирается моноширинным без выделения ключевых слов.
_LANGUAGES = { _LANGUAGES = {
"c": "C", "c": "C",
"c++": "C++", "c++": "[modern]C++",
"cpp": "C++", "cpp": "[modern]C++",
"bash": "bash", "bash": "bash",
"sh": "bash", "sh": "bash",
"shell": "bash", "shell": "bash",
@@ -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,21 +55,37 @@ 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:
opts.append(f"language={language}") # Значение в фигурных скобках обязательно: имя диалекта задаётся
# квадратными скобками (`[modern]C++`), и без обёртки парсер опций
# listings принимает их за начало следующего опционального аргумента.
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")
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:
@@ -82,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
+8 -5
View File
@@ -14,7 +14,10 @@
- абзац с картинкой и посторонним текстом не является рисунком: инлайн- - абзац с картинкой и посторонним текстом не является рисунком: инлайн-
картинка внутри текста пропускается с предупреждением; картинка внутри текста пропускается с предупреждением;
- ширина задаётся суффиксом ``|N`` или ``|N%`` (доля от textwidth), - ширина задаётся суффиксом ``|N`` или ``|N%`` (доля от textwidth),
по умолчанию 80 %. по умолчанию 80 %;
- размещение ``[ht]``, а не ``[H]``: жёсткая привязка к месту оставляла до
половины страницы пустой, когда рисунок не помещался в её остаток. ГОСТ
допускает перенос иллюстрации на следующую страницу.
Паттерн чистый: match() смотрит на срез токенов, render_figure() строит Паттерн чистый: match() смотрит на срез токенов, render_figure() строит
LaTeX из того же среза; renderer.pos двигает рендерер. LaTeX из того же среза; renderer.pos двигает рендерер.
@@ -27,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
@@ -80,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 ""))
@@ -90,10 +93,10 @@ 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}[H]", "\\begin{figure}[htb]",
" \\centering", " \\centering",
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}", rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
] ]
+107 -2
View File
@@ -6,7 +6,9 @@
from __future__ import annotations from __future__ import annotations
import re
import sys import sys
from collections.abc import Callable
from markdown_it.token import Token from markdown_it.token import Token
@@ -32,9 +34,111 @@ def escape_latex(text: str) -> str:
return text.translate(_LATEX_SPECIAL) return text.translate(_LATEX_SPECIAL)
# Длина слова, начиная с которой ему нужны точки переноса, и символы, после
# которых переносить допустимо. Неразрывный идентификатор вроде
# QT_QPA_PLATFORM=offscreen не помещается в остаток строки целиком, и абзац
# приходится разгонять пробелами.
LONG_TOKEN = 12
BREAK_AFTER = ":/_.-="
def allow_breaks(word: str) -> str:
"""Вставить \\allowbreak после разделителей внутри длинного слова."""
if len(word) < LONG_TOKEN:
return word
out = []
for ch in word:
out.append(ch)
if ch in BREAK_AFTER:
out.append("\\allowbreak ")
return "".join(out)
# Инициалы не должны разрываться переносом строки: «/ И.» на одной строке и
# «В.» на следующей — ошибка оформления списка источников. Связываются только
# инициалы между собой: следующее слово с заглавной буквы может оказаться не
# фамилией, а началом названия работы, и тогда неразрывная цепочка получается
# такой длинной, что распирает строку.
_INITIALS_RE = re.compile(r"(?<![\w.])([A-ZА-ЯЁ]\.)\s+(?=[A-ZА-ЯЁ]\.)")
# TeX не переносит слово, содержащее дефис, — «программно-аппаратного» целиком
# не влезает в строку, и абзац приходится разгонять пробелами вдвое шире нормы.
# Шорткат babel «"=» ставит тот же дефис, но разрешает переносы в обеих частях.
_COMPOUND_RE = re.compile(r"(?<=[а-яёА-ЯЁ])-(?=[а-яёА-ЯЁ])")
def split_compounds(text: str) -> str:
"""Разрешить переносы в составных словах через дефис."""
return _COMPOUND_RE.sub('"=', text)
def bind_initials(text: str) -> str:
"""Заменить пробел после инициала на неразрывный."""
previous = None
while previous != text:
previous = text
text = _INITIALS_RE.sub(r"\1~", 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 escape_latex(tok.content) return render_marked(tok.content)
@inline("strong_open") @inline("strong_open")
@@ -59,7 +163,8 @@ def _em_close(tok: Token) -> str:
@inline("code_inline") @inline("code_inline")
def _code_inline(tok: Token) -> str: def _code_inline(tok: Token) -> str:
return f"\\texttt{{\\small {escape_latex(tok.content)}}}" content = " ".join(allow_breaks(w) for w in escape_latex(tok.content).split(" "))
return f"\\texttt{{\\small {content}}}"
@inline("link_open") @inline("link_open")
+32 -6
View File
@@ -28,11 +28,19 @@ import re
from markdown_it.token import Token from markdown_it.token import Token
from md2gost.handlers.inline import escape_latex, render_inline from md2gost.handlers.inline import allow_breaks, escape_latex, render_inline
from md2gost.registry import block, pattern from md2gost.registry import block, pattern
_CAPTION_RE = re.compile(r"^\s*Таблица\s+\d+\s*[—–-]+\s*(.+?)\s*$") _CAPTION_RE = re.compile(r"^\s*Таблица\s+\d+\s*[—–-]+\s*(.+?)\s*$")
_LONG_WORD = 18
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
# отдав ей равную долю ширины, мы отнимаем место у колонок с текстом, и те
# начинают переноситься по одному слову.
_NARROW_CELL = 16
_BREAK_AFTER = ":/_.-"
_ALIGN_BY_STYLE = { _ALIGN_BY_STYLE = {
"text-align:left": "l", "text-align:left": "l",
"text-align:center": "c", "text-align:center": "c",
@@ -40,6 +48,18 @@ _ALIGN_BY_STYLE = {
} }
def _breakable(cell: str) -> str:
"""Расставить точки переноса внутри длинных слов ячейки.
Ячейка уже отрендерена в LaTeX, поэтому слова с обратной косой чертой или
фигурными скобками пропускаются: вставка внутрь имени команды сломала бы её.
"""
return " ".join(
word if any(ch in word for ch in "\\{}") else allow_breaks(word)
for word in cell.split(" ")
)
def _cell_align(tok: Token) -> str: def _cell_align(tok: Token) -> str:
style = str(tok.attrGet("style") or "").replace(" ", "") style = str(tok.attrGet("style") or "").replace(" ", "")
return _ALIGN_BY_STYLE.get(style, "l") return _ALIGN_BY_STYLE.get(style, "l")
@@ -69,7 +89,7 @@ def _collect_rows(
pos += 1 pos += 1
break break
if ctok.type in ("th_open", "td_open"): if ctok.type in ("th_open", "td_open"):
row.append(render_inline(tokens[pos + 1])) row.append(_breakable(render_inline(tokens[pos + 1])))
row_aligns.append(_cell_align(ctok)) row_aligns.append(_cell_align(ctok))
pos += 3 pos += 3
else: else:
@@ -100,8 +120,14 @@ def _tabular(rows: list[list[str]], aligns: list[str]) -> str:
row.extend([""] * (cols - len(row))) row.extend([""] * (cols - len(row)))
spec = list(aligns[:cols]) + ["l"] * max(0, cols - len(aligns)) spec = list(aligns[:cols]) + ["l"] * max(0, cols - len(aligns))
stretchable = "l" in spec widths = [max(len(row[i]) for row in rows) for i in range(cols)]
col_spec = "|".join(_STRETCH if col == "l" else col for col in spec) stretch = [
align == "l" and width >= _NARROW_CELL for align, width in zip(spec, widths)
]
stretchable = any(stretch)
col_spec = "|".join(
_STRETCH if is_wide else col for col, is_wide in zip(spec, stretch)
)
env = "tabularx" if stretchable else "tabular" env = "tabularx" if stretchable else "tabular"
width = "{\\textwidth}" if stretchable else "" width = "{\\textwidth}" if stretchable else ""
@@ -142,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))
@@ -153,7 +179,7 @@ def captioned_table(tokens: list[Token]) -> str:
return "\n".join( return "\n".join(
[ [
"\\begin{table}[H]", "\\begin{table}[htb]",
f" \\caption{{{caption}}}", f" \\caption{{{caption}}}",
" \\centering", " \\centering",
_tabular(rows, aligns), _tabular(rows, aligns),
+12 -11
View File
@@ -33,15 +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} \\\\"
)
col_spec = f"@{{\\hspace{{0pt}}}}p{{{abbr_col}em}}@{{\\hspace{{0pt}}}}p{{\\dimexpr\\linewidth-{abbr_col}em\\relax}}" # Отбивки колонок убраны с обоих краёв: иначе сумма ширин превышает
body = "\n".join(tabular_rows) # \linewidth на 2\tabcolsep и перечень выходит за правое поле.
return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}" return (
"\\noindent\\begin{tabularx}{\\linewidth}{@{}l@{\\ ---\\ }X@{}}\n"
f"{body}\n"
"\\end{tabularx}"
)
+130
View File
@@ -0,0 +1,130 @@
"""Разбор журнала xelatex: ошибки, предупреждения и дефекты вёрстки.
Без этого разбора журнал остаётся во временном каталоге сборки и исчезает
вместе с ним — при запуске в контейнере узнать о выходе текста за поле
можно было только открыв PDF и заметив это глазом.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# «Overfull \hbox (56.7pt too wide) in alignment at lines 220--220»
# «Underfull \hbox (badness 7000) in paragraph at lines 10--11»
# «Overfull \hbox (7.2pt too wide) detected at line 14»
_BOX_RE = re.compile(
r"^(?P<kind>Overfull|Underfull)\s+\\hbox\s+"
r"\((?:(?P<pt>[\d.]+)pt too wide|badness (?P<badness>\d+))\)"
r"(?:.*?(?:at lines (?P<lines>\d+)--\d+|detected at line (?P<line>\d+)))?",
re.MULTILINE,
)
_ERROR_RE = re.compile(r"^! (?P<message>.+)$", re.MULTILINE)
_WARNING_RE = re.compile(
r"^(?P<message>(?:LaTeX|Package|Class)[^\n]*Warning:[^\n]*"
r"(?:\n(?!\s*$)(?!(?:LaTeX|Package|Class)[^\n]*Warning:)[^\n]*)*)",
re.MULTILINE,
)
# Пакет unicode-math на каждой сборке сообщает, что перекрывает команды
# mathtools. Это ожидаемо и ничего не требует, но два многострочных сообщения
# в каждом документе заглушают собой те предупреждения, на которые надо смотреть.
_IGNORED_WARNINGS = (
"unicode-math Warning: Using \\overbracket",
"unicode-math Warning: I'm going to overwrite",
# Оформление подписей объявлено в преамбуле для всех видов объектов сразу;
# в документе без таблиц или листингов часть настроек закономерно не нужна.
"caption Warning: Unused \\captionsetup",
)
# Виды, при которых --strict считает сборку неудачной. Разреженные строки и
# предупреждения пакетов бывают следствием намеренной вёрстки (растяжки на
# титульном листе), а выход текста за поле — всегда видимый брак.
STRICT_KINDS = ("error", "overfull")
_TITLES = {
"error": "ошибки LaTeX",
"warning": "предупреждения LaTeX",
"overfull": "текст выходит за правое поле",
"underfull": "разреженные строки",
}
_MAX_PER_KIND = 20
@dataclass(frozen=True)
class Diagnostic:
kind: str
message: str
line: int | None = None
amount: float | None = None
def parse_log(
text: str,
*,
overfull_threshold_pt: float = 1.0,
underfull_threshold: int = 5000,
) -> list[Diagnostic]:
"""Диагностика из журнала сборки.
Пороги отсекают шум: вылет в доли пункта глазом не виден, а строки с
небольшой разреженностью встречаются в любом нормально свёрстанном тексте.
"""
diags: list[Diagnostic] = []
for match in _ERROR_RE.finditer(text):
diags.append(Diagnostic("error", match.group("message").strip()))
for match in _WARNING_RE.finditer(text):
message = " ".join(match.group("message").split())
if any(noise in message for noise in _IGNORED_WARNINGS):
continue
diags.append(Diagnostic("warning", message))
for match in _BOX_RE.finditer(text):
line_group = match.group("lines") or match.group("line")
line = int(line_group) if line_group else None
if match.group("pt") is not None:
amount = float(match.group("pt"))
if amount < overfull_threshold_pt:
continue
diags.append(Diagnostic("overfull", match.group(0).strip(), line, amount))
else:
badness = float(match.group("badness"))
if badness < underfull_threshold:
continue
diags.append(Diagnostic("underfull", match.group(0).strip(), line, badness))
return diags
def _describe(diag: Diagnostic) -> str:
where = (
f"абзац в строке {diag.line}" if diag.line is not None else "место не указано"
)
if diag.kind == "overfull":
return f" {where}: на {diag.amount:.1f} pt шире поля"
if diag.kind == "underfull":
return f" {where}: разреженность {diag.amount:.0f}"
return f" {diag.message}"
def format_diagnostics(diags: list[Diagnostic]) -> str:
"""Человекочитаемая сводка; пустая строка, если сообщать не о чем."""
if not diags:
return ""
blocks: list[str] = []
for kind in ("error", "warning", "overfull", "underfull"):
group = [d for d in diags if d.kind == kind]
if not group:
continue
if kind in ("overfull", "underfull"):
group.sort(key=lambda d: d.amount or 0.0, reverse=True)
lines = [f"{_TITLES[kind]}: {len(group)}"]
lines += [_describe(d) for d in group[:_MAX_PER_KIND]]
if len(group) > _MAX_PER_KIND:
lines.append(f" и ещё {len(group) - _MAX_PER_KIND}")
blocks.append("\n".join(lines))
return "\n".join(blocks)
+91 -15
View File
@@ -14,8 +14,23 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\usepackage{graphicx} \usepackage{graphicx}
\graphicspath{ {./} {./images/} } \graphicspath{ {./} {./images/} }
\usepackage{float} \usepackage{float}
\usepackage[section]{placeins}
% Значения по умолчанию требуют, чтобы текст занимал не меньше 80 % страницы,
% а иллюстрация внизу — не больше 30 %. Из-за этого крупный рисунок не
% помещался на своей странице и уезжал, оставляя её наполовину пустой.
\renewcommand{\textfraction}{0.05}
\renewcommand{\topfraction}{0.95}
\renewcommand{\bottomfraction}{0.95}
\renewcommand{\floatpagefraction}{0.75}
\setcounter{topnumber}{3}
\setcounter{bottomnumber}{2}
\setcounter{totalnumber}{5}
\usepackage{wrapfig} \usepackage{wrapfig}
\sloppy % \sloppy не используется намеренно: он снимает штраф за разреженные строки
% целиком, и абзац с длинным неразрывным словом разгоняется пробелами вдвое
% шире нормальных. \emergencystretch включается только там, где иначе строка
% вышла бы за поле.
\setlength{\emergencystretch}{3em}
\clubpenalty=10000 \clubpenalty=10000
\widowpenalty=10000 \widowpenalty=10000
\usepackage{enumitem} \usepackage{enumitem}
@@ -25,20 +40,59 @@ 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}
% Пометка правок: ==текст== в Markdown. Выделяется каждое слово отдельно,
% иначе строка не разорвётся по границе выделения. soul не подошёл: под
% XeLaTeX его \hl теряет кириллицу. Команда объявлена устойчивой — она
% попадает в заголовки, а оттуда в содержание.
\DeclareRobustCommand{\hlw}[1]{{\setlength{\fboxsep}{1pt}\colorbox{yellow}{#1}}}
% Палитра подсветки. Тона различаются не только цветом, но и светлотой,
% поэтому листинг остаётся читаемым и на чёрно-белой печати.
\definecolor{lstkeyword}{RGB}{0,0,139}
\definecolor{lststring}{RGB}{139,0,0}
\definecolor{lstlineno}{RGB}{130,130,130}
% Встроенный диалект C++ в listings старше стандарта: auto, nullptr, override
% и прочее он не знает. Расширяем его, а не подменяем, чтобы сохранить разбор
% строк, комментариев и препроцессора.
\lstdefinelanguage[modern]{C++}[ISO]{C++}{%
morekeywords={%
auto,nullptr,constexpr,consteval,constinit,noexcept,override,final,%
decltype,static_assert,thread_local,alignas,alignof,concept,requires,%
co_await,co_yield,co_return,import,module,%
Q_OBJECT,Q_SLOTS,Q_SIGNALS,Q_PROPERTY,signals,slots,emit%
}%
}
\lstset{ \lstset{
basicstyle=\ttfamily\small\setstretch{1.0}, basicstyle=\ttfamily\small\setstretch{1.0},
keywordstyle=\bfseries\color{lstkeyword},
% Комментарии не выделяются ни цветом, ни начертанием. listings применяет
% стиль только к символам своего алфавита, кириллица в него не входит:
% цветной commentstyle красит в смешанном комментарии одну латиницу, и текст
% рябит двумя цветами. extendedchars=true включает кириллицу в алфавит, но
% при этом теряет пробелы между русскими словами, а alsoletter с кириллицей
% не собирается вовсе. Однородный комментарий лучше пёстрого.
commentstyle=,
stringstyle=\color{lststring},
numberstyle=\scriptsize\color{lstlineno},
identifierstyle=,
breakatwhitespace=false, breakatwhitespace=false,
breaklines=true, breaklines=true,
captionpos=t, captionpos=t,
inputencoding=utf8, inputencoding=utf8,
frame=single, frame=single,
keepspaces=true, keepspaces=true,
keywordstyle=\bf,
numbers=left, numbers=left,
numbersep=5pt, numbersep=7pt,
aboveskip=0.7\baselineskip,
belowskip=0.4\baselineskip,
postbreak=\mbox{\textcolor{lstlineno}{$\hookrightarrow$}\space},
xleftmargin=25pt, xleftmargin=25pt,
xrightmargin=25pt, xrightmargin=25pt,
showspaces=false, showspaces=false,
@@ -62,9 +116,9 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\titleformat{\section}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesection. } \titleformat{\section}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesection. }
\titleformat{\subsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsection. } \titleformat{\subsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsection. }
\titleformat{\subsubsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsubsection. } \titleformat{\subsubsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsubsection. }
\titlespacing*{\section}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\section}{1.25cm}{1ex plus 0.3ex minus 0.2ex}{0.6ex plus 0.2ex minus 0.1ex}
\titlespacing*{\subsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\subsection}{1.25cm}{1ex plus 0.3ex minus 0.2ex}{0.6ex plus 0.2ex minus 0.1ex}
\titlespacing*{\subsubsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex} \titlespacing*{\subsubsection}{1.25cm}{1ex plus 0.3ex minus 0.2ex}{0.6ex plus 0.2ex minus 0.1ex}
\usepackage{tocloft} \usepackage{tocloft}
\addto\captionsrussian{% \addto\captionsrussian{%
\renewcommand{\contentsname}{СОДЕРЖАНИЕ}% \renewcommand{\contentsname}{СОДЕРЖАНИЕ}%
@@ -83,12 +137,15 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\renewcommand{\cftsecpagefont}{\mdseries} \renewcommand{\cftsecpagefont}{\mdseries}
\renewcommand{\cftsubsecfont}{\mdseries} \renewcommand{\cftsubsecfont}{\mdseries}
\renewcommand{\cftsubsubsecfont}{\mdseries} \renewcommand{\cftsubsubsecfont}{\mdseries}
% Ширина поля номера в оглавлении измеряется по шрифту, а не задаётся
% константой: при фиксированных 1.5em номер 2.10. не помещается и печатается
% поверх названия главы, а у остальных пропадает пробел после точки.
\setlength{\cftsecindent}{0em} \setlength{\cftsecindent}{0em}
\setlength{\cftsecnumwidth}{1em}
\setlength{\cftsubsecindent}{0em} \setlength{\cftsubsecindent}{0em}
\setlength{\cftsubsecnumwidth}{1.5em}
\setlength{\cftsubsubsecindent}{0em} \setlength{\cftsubsubsecindent}{0em}
\setlength{\cftsubsubsecnumwidth}{2.1em} \settowidth{\cftsecnumwidth}{\cftsecfont 9.\enspace}
\settowidth{\cftsubsecnumwidth}{\cftsubsecfont 9.99.\enspace}
\settowidth{\cftsubsubsecnumwidth}{\cftsubsubsecfont 9.99.99.\enspace}
\newcommand{\sigline}[3][4cm]{% \newcommand{\sigline}[3][4cm]{%
\begin{tabular}[t]{@{}c@{}}% \begin{tabular}[t]{@{}c@{}}%
@@ -98,6 +155,17 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\end{tabular}% \end{tabular}%
} }
% Ширины шапки титульного листа. Колонка эмблемы задаётся явно, ширина
% текстовой части — остаток от текстового поля за вычетом рамки и отбивок,
% поэтому шапка не может вылезти за рамку при смене полей страницы.
\newlength{\ptpemblemwidth}
\newlength{\ptpheadwidth}
\newlength{\ptpheadpad}
\setlength{\ptpemblemwidth}{0.14\textwidth}
\setlength{\ptpheadpad}{4pt}
\setlength{\ptpheadwidth}{%
\dimexpr\textwidth-\ptpemblemwidth-4\ptpheadpad-3\arrayrulewidth\relax}
\newcommand{\ptpfieldw}[4][4.5cm]{% \newcommand{\ptpfieldw}[4][4.5cm]{%
\par\noindent \par\noindent
\makebox[#1][l]{#2}% \makebox[#1][l]{#2}%
@@ -112,7 +180,10 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\newcommand{\ptpfieldnote}[4][4.5cm]{% \newcommand{\ptpfieldnote}[4][4.5cm]{%
\ptpfield[#1]{#2}{#3}% \ptpfield[#1]{#2}{#3}%
\par\vspace{-0.45em}% \par\vspace{-0.45em}%
\hspace*{#1}\makebox[\dimexpr\textwidth-#1\relax][c]{\footnotesize\itshape #4}\par} % \noindent обязателен: иначе к строке подписи прибавляется абзацный отступ,
% и она ровно на \parindent выходит за правое поле.
\noindent\hspace*{#1}%
\makebox[\dimexpr\textwidth-#1\relax][c]{\footnotesize\itshape #4}\par}
\newcommand{\ptpfieldpar}[3][4.5cm]{% \newcommand{\ptpfieldpar}[3][4.5cm]{%
\par\noindent \par\noindent
@@ -122,12 +193,17 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\rule{\dimexpr\textwidth-#1\relax}{0.4pt}% \rule{\dimexpr\textwidth-#1\relax}{0.4pt}%
\end{tabular}\par} \end{tabular}\par}
% Блок подписи: метка слева, две подписи прижаты к правому полю.
% Через tabularx с колонкой X это не работало — при длинной метке
% («Руководитель практики от кафедры») колонка схлопывалась, и подписи
% уезжали за правое поле. Здесь ширина подписей фиксирована, а \hfill
% забирает остаток, поэтому правые края блоков всегда совпадают.
\newcommand{\ptpsig}[3]{% \newcommand{\ptpsig}[3]{%
\par\noindent \par\noindent
\begin{tabularx}{\textwidth}{@{}l X c c@{}}% #1\hfill
#1 & & \sigline[4.2cm]{}{\itshape подпись, дата} \sigline[3.8cm]{}{\itshape подпись, дата}%
& \sigline[4.2cm]{#2}{\itshape #3} \\% \hspace{0.5cm}%
\end{tabularx}\par} \sigline[3.8cm]{#2}{\itshape #3}\par}
\begin{document}""" \begin{document}"""
+4 -3
View File
@@ -27,13 +27,14 @@ def render(fm: dict[str, str]) -> str:
"% --- Шапка в рамке с разделителем, как в бланке ---\n" "% --- Шапка в рамке с разделителем, как в бланке ---\n"
"\\vspace*{-45pt}\n" "\\vspace*{-45pt}\n"
"\\noindent\\begin{tabular}" "\\noindent\\begin{tabular}"
"{|@{\\hspace{4pt}}c@{\\hspace{4pt}}|@{\\hspace{4pt}}c@{\\hspace{4pt}}|}\n" "{|@{\\hspace{\\ptpheadpad}}c@{\\hspace{\\ptpheadpad}}"
"|@{\\hspace{\\ptpheadpad}}c@{\\hspace{\\ptpheadpad}}|}\n"
"\\hline\n" "\\hline\n"
"\\begin{minipage}{0.16\\textwidth}\\centering\\vspace{3pt}\n" "\\begin{minipage}{\\ptpemblemwidth}\\centering\\vspace{3pt}\n"
"\\includegraphics[width=0.92\\textwidth]{emblem}\n" "\\includegraphics[width=0.92\\textwidth]{emblem}\n"
"\\vspace{3pt}\\end{minipage}\n" "\\vspace{3pt}\\end{minipage}\n"
"&\n" "&\n"
"\\begin{minipage}{0.775\\textwidth}\\small\\bfseries\n" "\\begin{minipage}{\\ptpheadwidth}\\small\\bfseries\n"
"\\vspace*{2pt}\n" "\\vspace*{2pt}\n"
f"{ministry_lines(fm)}\n" f"{ministry_lines(fm)}\n"
"\\vspace*{0.5ex}\n" "\\vspace*{0.5ex}\n"
+28 -6
View File
@@ -6,15 +6,25 @@ import tempfile
from pathlib import Path from pathlib import Path
import md2gost import md2gost
from md2gost.latex.log import STRICT_KINDS, format_diagnostics, parse_log
from md2gost.parser import Parser, iter_image_sources from md2gost.parser import Parser, iter_image_sources
from md2gost.render import Renderer from md2gost.render import Renderer
def _print_latex_errors(log_path: Path) -> None: def _report_latex_log(log_path: Path) -> bool:
if log_path.exists(): """Печатает диагностику сборки. Возвращает True при серьёзных дефектах.
for line in log_path.read_text(errors="replace").splitlines():
if line.startswith("!"): Журнал живёт во временном каталоге и исчезает вместе с ним, поэтому всё,
print(f" {line}", file=sys.stderr) о чём сообщил xelatex, нужно вывести наружу здесь — иначе при запуске
в контейнере узнать о дефектах вёрстки неоткуда.
"""
if not log_path.exists():
return False
diags = parse_log(log_path.read_text(errors="replace"))
summary = format_diagnostics(diags)
if summary:
print(summary, file=sys.stderr)
return any(d.kind in STRICT_KINDS for d in diags)
def run_xelatex(tex_path: Path, output_dir: Path) -> bool: def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
@@ -85,6 +95,11 @@ def main() -> None:
action="store_true", action="store_true",
help="только .tex, без компиляции в PDF", help="только .tex, без компиляции в PDF",
) )
cli.add_argument(
"--strict",
action="store_true",
help="выйти с ошибкой, если xelatex сообщил о предупреждениях",
)
args = cli.parse_args() args = cli.parse_args()
if not args.input.exists(): if not args.input.exists():
@@ -118,13 +133,20 @@ def main() -> None:
if not run_xelatex(tex_path, cache_dir): if not run_xelatex(tex_path, cache_dir):
stage = "" if attempt == 1 else " (2-й проход)" stage = "" if attempt == 1 else " (2-й проход)"
print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr) print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr)
_print_latex_errors(log_path) _report_latex_log(log_path)
sys.exit(1) sys.exit(1)
final_pdf = args.output / pdf_name final_pdf = args.output / pdf_name
shutil.copy2(cache_dir / pdf_name, final_pdf) shutil.copy2(cache_dir / pdf_name, final_pdf)
print(f"-> {final_pdf}") print(f"-> {final_pdf}")
# Разбирается журнал второго прохода: после первого ссылки и номера
# страниц ещё не разложены, и часть предупреждений ложная.
has_defects = _report_latex_log(log_path)
if has_defects and args.strict:
print("ошибка: --strict, а вёрстка содержит дефекты", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+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:
+122
View File
@@ -0,0 +1,122 @@
"""Сквозная проверка вёрстки: документ должен собираться без дефектов.
Отдельные обработчики проверяются модульными тестами, но выход текста за поле
проявляется только после сборки: он зависит от шрифта, ширины колонок и
взаимодействия пакетов. Этот тест ловит регресс, который иначе видно лишь
глазами в готовом PDF.
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
import md2gost
from md2gost.latex.log import parse_log
from md2gost.parser import Parser
from md2gost.render import Renderer
pytestmark = pytest.mark.skipif(
shutil.which("xelatex") is None, reason="xelatex не установлен"
)
# Документ собран из конструкций, на которых вёрстка ломалась:
# длинный токен в узкой колонке, подглава с двузначным номером в оглавлении,
# перечень сокращений, листинг с подписью и длинными строками.
FRONT_MATTER = """---
титульник: ptp-report
факультет: Информатика и системы управления (ИУ)
кафедра: Теоретическая информатика и компьютерные технологии (ИУ9)
тип: Отчёт
название: Проверка вёрстки
студент: Иванов И. И.
студент_полностью: Иванов Иван Иванович
группа: ИУ9-22Б
тип_практики: проектно-технологическая практика
предприятие: МГТУ имени Н. Э. Баумана
команда: Проверка вёрстки
хранилище: https://example.invalid/very/long/repository/path
руководитель: Петров П. П.
год: 2026
---
"""
SUBSECTIONS = "\n".join(
f"## Подглава номер {i}\n\nТекст подглавы номер {i}.\n" for i in range(1, 12)
)
BODY = f"""
# АННОТАЦИЯ
Проверка вёрстки.
# СОДЕРЖАНИЕ
# ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ
SDR — Software-Defined Radio — программно-определяемая радиосистема
PSO — Particle Swarm Optimisation — метод роевой оптимизации
# ВВЕДЕНИЕ
Введение.
# Раздел
{SUBSECTIONS}
Таблица 1 —— Таблица с длинным токеном
| Направление | Формат | Назначение |
| :--- | :--- | :--- |
| сервер | HackRFTestingServer:идентификатор | Приветствие, подтверждает подключение |
Листинг 1 —— Листинг с длинными строками
```cpp
template <typename Rep, typename Period>
std::vector<T> drain_with_timeout(const std::atomic<bool>& running,
const std::chrono::duration<Rep, Period>& timeout);
```
# ЗАКЛЮЧЕНИЕ
Заключение.
# СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
1. Гилев, И. В. Использование технологии SDR / И. В. Гилев. – Воронеж, 2025. – С. 324–328.
"""
def _build(md_text: str) -> str:
"""Собрать документ и вернуть журнал xelatex."""
parser = Parser(md_text)
# Картинок в эталонном документе нет — копировать нужно только эмблему.
latex = Renderer(parser.tokens, parser.front_matter).render()
emblem = Path(md2gost.__file__).parent.parent / "emblem.png"
with tempfile.TemporaryDirectory(prefix="md2gost-layout-") as cache:
cache_dir = Path(cache)
tex = cache_dir / "doc.tex"
tex.write_text(latex, encoding="utf-8")
if emblem.is_file():
shutil.copy2(emblem, cache_dir / "emblem.png")
for _ in range(2):
subprocess.run(
["xelatex", "-interaction=nonstopmode", str(tex)],
cwd=cache_dir,
capture_output=True,
)
assert (cache_dir / "doc.pdf").is_file(), "xelatex не собрал PDF"
return (cache_dir / "doc.log").read_text(errors="replace")
def test_reference_document_has_no_layout_defects() -> None:
diags = parse_log(_build(FRONT_MATTER + BODY))
defects = [d for d in diags if d.kind in ("error", "overfull")]
assert not defects, "\n".join(f"{d.kind}: {d.message}" for d in defects)
+112
View File
@@ -0,0 +1,112 @@
from md2gost.latex.log import (
STRICT_KINDS,
Diagnostic,
format_diagnostics,
parse_log,
)
ERROR_LOG = """
This is XeTeX
! LaTeX Error: File `emblem' not found.
See the LaTeX manual or LaTeX Companion for explanation.
"""
OVERFULL_LOG = r"""
Overfull \hbox (56.70982pt too wide) in alignment at lines 220--220
[]
Overfull \hbox (7.2pt too wide) detected at line 14
[]
Overfull \hbox (0.4pt too wide) in paragraph at lines 30--31
"""
UNDERFULL_LOG = r"""
Underfull \hbox (badness 3000) in paragraph at lines 10--11
Underfull \hbox (badness 7000) in paragraph at lines 20--21
"""
WARNING_LOG = """
LaTeX Warning: Reference `fig:1' on page 3 undefined on input line 42.
LaTeX Warning: There were undefined references.
"""
def test_error_is_parsed() -> None:
diags = parse_log(ERROR_LOG)
assert len(diags) == 1
assert diags[0].kind == "error"
assert "File `emblem' not found" in diags[0].message
def test_overfull_amount_and_line() -> None:
diags = [d for d in parse_log(OVERFULL_LOG) if d.kind == "overfull"]
assert [d.amount for d in diags] == [56.70982, 7.2]
assert [d.line for d in diags] == [220, 14]
def test_overfull_below_threshold_is_dropped() -> None:
"""Вылеты в доли пункта не видны глазом и только зашумляют вывод."""
diags = parse_log(OVERFULL_LOG, overfull_threshold_pt=1.0)
assert all(d.amount != 0.4 for d in diags if d.amount is not None)
def test_underfull_threshold() -> None:
diags = [d for d in parse_log(UNDERFULL_LOG, underfull_threshold=5000)]
assert len(diags) == 1
assert diags[0].kind == "underfull"
assert diags[0].amount == 7000
assert diags[0].line == 20
def test_warning_is_parsed() -> None:
diags = parse_log(WARNING_LOG)
assert [d.kind for d in diags] == ["warning", "warning"]
assert "undefined" in diags[0].message
def test_clean_log_gives_nothing() -> None:
assert parse_log("This is XeTeX\nOutput written on a.pdf (3 pages).\n") == []
def test_format_sorts_overfull_by_amount() -> None:
text = format_diagnostics(parse_log(OVERFULL_LOG))
first = text.index("56.7")
second = text.index("7.2")
assert first < second
def test_format_is_empty_for_no_diagnostics() -> None:
assert format_diagnostics([]) == ""
def test_format_mentions_every_kind() -> None:
diags = parse_log(ERROR_LOG + OVERFULL_LOG + UNDERFULL_LOG + WARNING_LOG)
text = format_diagnostics(diags)
for word in ("ошибк", "предупрежд", "за правое поле", "разрежен"):
assert word in text
def test_diagnostic_is_hashable() -> None:
assert Diagnostic("error", "x", None, None) == Diagnostic("error", "x", None, None)
NOISE_LOG = r"""
Package unicode-math Warning: Using \overbracket and \underbracket from
(unicode-math) `mathtools' package.
LaTeX Warning: There were undefined references.
"""
def test_known_package_noise_is_suppressed() -> None:
"""Постоянный шум unicode-math заглушает собой полезные предупреждения."""
diags = parse_log(NOISE_LOG)
assert len(diags) == 1
assert "undefined references" in diags[0].message
def test_strict_ignores_underfull_and_warnings() -> None:
"""Разреженная строка бывает намеренной, выход за поле — никогда."""
assert "overfull" in STRICT_KINDS
assert "error" in STRICT_KINDS
assert "underfull" not in STRICT_KINDS
assert "warning" not in STRICT_KINDS
+211 -18
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
@@ -48,7 +78,7 @@ def test_figure_with_caption(tmp_path) -> None:
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n" "![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
"Данная антенна используется для ...\n" "Данная антенна используется для ...\n"
) )
assert "\\begin{figure}[H]" in tex assert "\\begin{figure}[htb]" in tex
assert "\\centering" in tex assert "\\centering" in tex
assert ( assert (
"\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}" "\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}"
@@ -60,7 +90,7 @@ def test_figure_with_caption(tmp_path) -> None:
def test_figure_without_caption() -> None: def test_figure_without_caption() -> None:
tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n") tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n")
assert "\\begin{figure}[H]" in tex assert "\\begin{figure}[htb]" in tex
assert "\\caption{" not in tex assert "\\caption{" not in tex
assert "Обычный абзац после картинки." in tex assert "Обычный абзац после картинки." in tex
@@ -127,7 +157,7 @@ TABLE_MD = (
def test_table_with_caption() -> None: def test_table_with_caption() -> None:
tex = render(TABLE_MD) tex = render(TABLE_MD)
assert "\\begin{table}[H]" in tex assert "\\begin{table}[htb]" in tex
assert "\\caption{Сравнение скорости}" in tex assert "\\caption{Сравнение скорости}" in tex
assert "\\begin{tabularx}{\\textwidth}" in tex assert "\\begin{tabularx}{\\textwidth}" in tex
assert "\\textbf{1} & 0.027852 & 0.000285 \\\\" in tex assert "\\textbf{1} & 0.027852 & 0.000285 \\\\" in tex
@@ -221,9 +251,7 @@ def test_ptp_report_team_line_includes_repository() -> None:
def test_ptp_report_team_line_without_repository() -> None: def test_ptp_report_team_line_without_repository() -> None:
fm = PTP_REPORT_FM.replace( fm = PTP_REPORT_FM.replace("хранилище: https://monitor.yss.su/IU9/radio-serv\n", "")
"хранилище: https://monitor.yss.su/IU9/radio-serv\n", ""
)
tex = render_fm(fm) tex = render_fm(fm)
assert "хранилище" not in tex assert "хранилище" not in tex
@@ -313,7 +341,14 @@ def test_ptp_task_has_no_hardcoded_assignment_text() -> None:
from md2gost.latex.titlepages import ptp_task from md2gost.latex.titlepages import ptp_task
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
@@ -329,7 +364,7 @@ LISTING_MD = (
def test_listing_with_caption() -> None: def test_listing_with_caption() -> None:
tex = render(LISTING_MD) tex = render(LISTING_MD)
assert "caption={Потокобезопасная очередь}" in tex assert "caption={Потокобезопасная очередь}" in tex
assert "language=C++" in tex assert "language={[modern]C++}" in tex
assert "class ThreadSafeQueue {};" in tex assert "class ThreadSafeQueue {};" in tex
@@ -345,14 +380,14 @@ def test_listing_caption_paragraph_is_not_duplicated() -> None:
def test_listing_without_caption_stays_bare() -> None: def test_listing_without_caption_stays_bare() -> None:
tex = render("```cpp\nint main() {}\n```\n") tex = render("```cpp\nint main() {}\n```\n")
assert "\\begin{lstlisting}[language=C++]" in tex assert "\\begin{lstlisting}[language={[modern]C++}]" in tex
assert "caption=" not in tex assert "caption=" not in tex
def test_listing_caption_without_language() -> None: def test_listing_caption_without_language() -> None:
tex = render("Листинг 2 —— Формат строки\n\n```\nid,freq,power\n```\n") tex = render("Листинг 2 —— Формат строки\n\n```\nid,freq,power\n```\n")
assert "caption={Формат строки}" in tex assert "caption={Формат строки}" in tex
assert "language=" not in tex assert "language={" not in tex
def test_paragraph_starting_with_listing_word_is_not_a_caption() -> None: def test_paragraph_starting_with_listing_word_is_not_a_caption() -> None:
@@ -364,12 +399,12 @@ def test_paragraph_starting_with_listing_word_is_not_a_caption() -> None:
def test_listing_language_is_mapped_to_listings_name() -> None: def test_listing_language_is_mapped_to_listings_name() -> None:
"""listings не знает 'cpp', только 'C++'.""" """listings не знает 'cpp', только 'C++'."""
tex = render("```cpp\nint x;\n```\n") tex = render("```cpp\nint x;\n```\n")
assert "language=C++" in tex assert "language={[modern]C++}" in tex
def test_listing_unsupported_language_falls_back_to_plain() -> None: def test_listing_unsupported_language_falls_back_to_plain() -> None:
tex = render("```yaml\nkey: value\n```\n") tex = render("```yaml\nkey: value\n```\n")
assert "language=" not in tex assert "language={" not in tex
assert "key: value" in tex assert "key: value" in tex
@@ -389,19 +424,177 @@ def test_wide_table_uses_tabularx_so_it_fits_the_page() -> None:
def test_table_left_columns_become_stretchable() -> None: def test_table_left_columns_become_stretchable() -> None:
"""Растягиваются колонки с текстом; «Сценарий» короткая и остаётся узкой."""
tex = render(WIDE_TABLE_MD) tex = render(WIDE_TABLE_MD)
assert tex.count(">{\\raggedright\\arraybackslash}X") == 3 assert tex.count(">{\\raggedright\\arraybackslash}X") == 2
def test_table_keeps_center_columns_narrow() -> None: def test_table_keeps_center_columns_narrow() -> None:
"""Числовые колонки не должны растягиваться — растягивается только текст.""" """Все колонки короткие: растягивать нечего, таблица идёт по содержимому."""
tex = render(TABLE_MD) tex = body(render(TABLE_MD))
assert "\\begin{tabularx}{\\textwidth}" in tex assert "\\begin{tabular}{|l|c|r|}" in tex
assert tex.count(">{\\raggedright\\arraybackslash}X") == 1 assert "\\begin{tabularx}" not in tex
assert f"{{|{_STRETCH}|c|r|}}" in tex
def test_table_without_left_columns_stays_plain_tabular() -> None: def test_table_without_left_columns_stays_plain_tabular() -> None:
tex = body(render("| a | b |\n| :---: | :---: |\n| 1 | 2 |\n")) tex = body(render("| a | b |\n| :---: | :---: |\n| 1 | 2 |\n"))
assert "\\begin{tabular}{|c|c|}" in tex assert "\\begin{tabular}{|c|c|}" in tex
assert "\\begin{tabularx}" not in tex assert "\\begin{tabularx}" not in tex
LONG_TOKEN_TABLE_MD = (
"| Направление | Формат | Назначение |\n"
"| :--- | :--- | :--- |\n"
"| сервер | HackRFTestingServer:идентификатор | Приветствие, подтверждает |\n"
)
def test_long_token_in_cell_gets_break_points() -> None:
"""Иначе токен не переносится и печатается поверх соседней колонки."""
tex = body(render(LONG_TOKEN_TABLE_MD))
assert "HackRFTestingServer:\\allowbreak" in tex
def test_short_cells_are_left_alone() -> None:
tex = body(render("| a | b |\n| :--- | :--- |\n| ping | сервер |\n"))
assert "\\allowbreak" not in tex
def test_break_points_do_not_touch_latex_commands() -> None:
"""В ячейке с разметкой есть команды; ломать их вставками нельзя."""
tex = body(
render(
"| Поле | Значение |\n| :--- | :--- |\n"
"| **оченьдлинноежирноеслово_подчёркивание** | x |\n"
)
)
assert "\\textbf{" in tex
assert "\\textbf\\allowbreak" not in tex
MIXED_WIDTH_TABLE_MD = (
"| Сценарий | Что делает |\n"
"| :--- | :--- |\n"
"| build.yml | Собирает образ Docker целиком и публикует его в реестр |\n"
"| test.yml | Собирает проект через CMake и прогоняет набор тестов |\n"
)
def test_narrow_column_is_not_stretched() -> None:
"""Колонка из коротких значений не должна занимать половину ширины."""
tex = body(render(MIXED_WIDTH_TABLE_MD))
assert f"{{|l|{_STRETCH}|}}" in tex
def test_all_short_columns_stay_plain_tabular() -> None:
tex = body(render("| a | b |\n| :--- | :--- |\n| ping | сервер |\n"))
assert "\\begin{tabular}{|l|l|}" in tex
assert "\\begin{tabularx}" not in tex
ABBR_MD = (
"# ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ\n\n"
"SDR — Software-Defined Radio — программно-определяемая радиосистема\n"
"PSO — Particle Swarm Optimisation — метод роевой оптимизации\n"
)
def test_abbreviation_dash_is_single() -> None:
"""Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного."""
tex = body(render(ABBR_MD))
assert "------" not in tex
assert "SDR & Software-Defined Radio" in tex
def test_abbreviation_table_has_no_extra_column_padding() -> None:
"""Отбивка между колонками добавляется к \\linewidth и роняет строку за поле."""
tex = body(render(ABBR_MD))
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:
"""Встроенный C++ в listings не знает auto, nullptr и override."""
tex = body(render("```cpp\nauto x = nullptr;\n```\n"))
assert "language={[modern]C++}" in tex
def test_captioned_listing_floats() -> None:
"""Неплавающий листинг рвётся между страницами с незамкнутой рамкой."""
tex = body(render(LISTING_MD))
assert "float=htbp" in tex
def test_bare_listing_does_not_float() -> None:
"""Фрагмент без подписи — часть абзаца, ему плавать незачем."""
tex = body(render("```cpp\nint x;\n```\n"))
assert "float=" not in tex
def test_initials_are_not_split_across_lines() -> None:
"""«/ И.» на одной строке и «В.» на следующей — ошибка оформления."""
tex = body(render("Гилев, И. В. Использование технологии SDR.\n"))
assert "И.~В. Использование" in tex
def test_lowercase_abbreviation_is_left_alone() -> None:
tex = body(render("В 3 т., Воронеж, 2025.\n"))
assert "т.~Воронеж" not in tex
def test_compound_word_allows_hyphenation() -> None:
"""TeX не переносит слово с дефисом, и абзац разгоняется пробелами."""
tex = body(render("Разработка программно-аппаратного комплекса.\n"))
assert 'программно"=аппаратного' in tex
def test_latin_hyphen_is_left_alone() -> None:
"""Шорткат babel рассчитан на русский; латинские дефисы не трогаем."""
tex = body(render("Инструмент clang-format и Wayland-сессия.\n"))
assert "clang-format" in tex
assert 'clang"=format' not in tex