Compare commits
21 Commits
cd1b8d98c3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d00a1fed | |||
| 24d3cd0728 | |||
| fa440049e1 | |||
| be64bf475f | |||
| beac1d5a7d | |||
| 7c518cd59d | |||
| 95bd42cea4 | |||
| d5b6f4b89e | |||
| 82cde7b94b | |||
| 2a0333c673 | |||
| a3d41af022 | |||
| 7f20bd9831 | |||
| c765892d7d | |||
| 5ebdf8f9db | |||
| f426e865b5 | |||
| 6b1f508825 | |||
| 4f0c2ce16d | |||
| f616dc0cd5 | |||
| fab9e19962 | |||
| 6aed7f186d | |||
| 10528c6688 |
@@ -51,6 +51,7 @@ docker build -t md2gost .
|
||||
- Изображения (см. ниже)
|
||||
- Аббревиатуры (раздел "Перечень принятых сокращений")
|
||||
- Ссылки, жирный, курсив
|
||||
- Пометка правок `==текст==` — жёлтое выделение для рецензента
|
||||
|
||||
### Изображения
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from md2gost.handlers.code import fence
|
||||
from md2gost.handlers.code import captioned_listing, fence, match_listing
|
||||
from md2gost.handlers.heading import heading
|
||||
from md2gost.handlers.hr import hr
|
||||
from md2gost.handlers.image import figure, match_figure
|
||||
@@ -9,6 +9,8 @@ from md2gost.handlers.text import paragraph
|
||||
|
||||
__all__ = [
|
||||
"fence",
|
||||
"captioned_listing",
|
||||
"match_listing",
|
||||
"hr",
|
||||
"heading",
|
||||
"figure",
|
||||
|
||||
+103
-5
@@ -1,14 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
from md2gost.registry import block
|
||||
from md2gost.handlers.inline import escape_latex
|
||||
from md2gost.registry import block, pattern
|
||||
|
||||
_CAPTION_RE = re.compile(r"^\s*Листинг\s+\d+\s*[—–-]+\s*(.+?)\s*$")
|
||||
|
||||
# Идентификаторы языков в Markdown и в пакете listings совпадают не всегда:
|
||||
# ``cpp`` listings не знает, ему нужен ``C++``. Для языков без поддержки
|
||||
# подсветки код набирается моноширинным без выделения ключевых слов.
|
||||
_LANGUAGES = {
|
||||
"c": "C",
|
||||
"c++": "[modern]C++",
|
||||
"cpp": "[modern]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", ""}
|
||||
|
||||
# Сколько строк кода помещается на странице. Листинг длиннее не поместится ни
|
||||
# в один плавающий объект, и LaTeX выбросит его в конец документа, поэтому
|
||||
# такой набирается на месте. Рамка при этом снимается: она всё равно
|
||||
# оборвалась бы на границе страницы и продолжилась на следующей.
|
||||
PAGE_LINES = 34
|
||||
|
||||
APPENDIX = "ПРИЛОЖЕНИЕ"
|
||||
|
||||
|
||||
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, 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:
|
||||
# Значение в фигурных скобках обязательно: имя диалекта задаётся
|
||||
# квадратными скобками (`[modern]C++`), и без обёртки парсер опций
|
||||
# listings принимает их за начало следующего опционального аргумента.
|
||||
opts.append(f"language={{{language}}}")
|
||||
if 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 ""
|
||||
return f"\\begin{{lstlisting}}{suffix}\n{code}\n\\end{{lstlisting}}"
|
||||
|
||||
|
||||
@block("fence")
|
||||
def fence(r, tok: Token) -> str:
|
||||
r.pos += 1
|
||||
lang = tok.info.strip()
|
||||
code = tok.content.rstrip()
|
||||
opts = f"[language={lang}]" if lang else ""
|
||||
return f"\\begin{{lstlisting}}{opts}\n{code}\n\\end{{lstlisting}}"
|
||||
return _lstlisting(tok, None, r.current_heading == APPENDIX)
|
||||
|
||||
|
||||
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(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), r.current_heading == APPENDIX)
|
||||
|
||||
@@ -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
|
||||
@@ -43,7 +48,9 @@ def heading(r, tok: Token) -> str:
|
||||
prefix = "\\clearpage\n"
|
||||
toc_entry = ""
|
||||
if upper in NEWPAGE_BEFORE:
|
||||
toc_entry = f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}"
|
||||
toc_entry = (
|
||||
f"\n\\phantomsection\\addcontentsline{{toc}}{{section}}{{{text}}}"
|
||||
)
|
||||
return (
|
||||
f"{prefix}"
|
||||
f"{{\\fontsize{{14pt}}{{18pt}}\\selectfont \\bfseries \\centering {text}\\par}}"
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
- абзац с картинкой и посторонним текстом не является рисунком: инлайн-
|
||||
картинка внутри текста пропускается с предупреждением;
|
||||
- ширина задаётся суффиксом ``|N`` или ``|N%`` (доля от textwidth),
|
||||
по умолчанию 80 %.
|
||||
по умолчанию 80 %;
|
||||
- размещение ``[ht]``, а не ``[H]``: жёсткая привязка к месту оставляла до
|
||||
половины страницы пустой, когда рисунок не помещался в её остаток. ГОСТ
|
||||
допускает перенос иллюстрации на следующую страницу.
|
||||
|
||||
Паттерн чистый: match() смотрит на срез токенов, render_figure() строит
|
||||
LaTeX из того же среза; renderer.pos двигает рендерер.
|
||||
@@ -27,7 +30,7 @@ from urllib.parse import unquote
|
||||
|
||||
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
|
||||
|
||||
DEFAULT_WIDTH = 0.8
|
||||
@@ -80,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 ""))
|
||||
@@ -90,10 +93,10 @@ def figure(tokens: list[Token]) -> str:
|
||||
if len(tokens) >= 5:
|
||||
m = _CAPTION_RE.match(tokens[4].content)
|
||||
if m:
|
||||
caption = escape_latex(m.group(1))
|
||||
caption = render_marked(m.group(1), escape_latex)
|
||||
|
||||
lines = [
|
||||
"\\begin{figure}[H]",
|
||||
"\\begin{figure}[htb]",
|
||||
" \\centering",
|
||||
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
|
||||
]
|
||||
|
||||
+107
-2
@@ -6,7 +6,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
@@ -32,9 +34,111 @@ def escape_latex(text: str) -> str:
|
||||
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")
|
||||
def _text(tok: Token) -> str:
|
||||
return escape_latex(tok.content)
|
||||
return render_marked(tok.content)
|
||||
|
||||
|
||||
@inline("strong_open")
|
||||
@@ -59,7 +163,8 @@ def _em_close(tok: Token) -> str:
|
||||
|
||||
@inline("code_inline")
|
||||
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")
|
||||
|
||||
@@ -28,11 +28,19 @@ import re
|
||||
|
||||
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
|
||||
|
||||
_CAPTION_RE = re.compile(r"^\s*Таблица\s+\d+\s*[—–-]+\s*(.+?)\s*$")
|
||||
|
||||
_LONG_WORD = 18
|
||||
|
||||
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
|
||||
# отдав ей равную долю ширины, мы отнимаем место у колонок с текстом, и те
|
||||
# начинают переноситься по одному слову.
|
||||
_NARROW_CELL = 16
|
||||
_BREAK_AFTER = ":/_.-"
|
||||
|
||||
_ALIGN_BY_STYLE = {
|
||||
"text-align:left": "l",
|
||||
"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:
|
||||
style = str(tok.attrGet("style") or "").replace(" ", "")
|
||||
return _ALIGN_BY_STYLE.get(style, "l")
|
||||
@@ -69,7 +89,7 @@ def _collect_rows(
|
||||
pos += 1
|
||||
break
|
||||
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))
|
||||
pos += 3
|
||||
else:
|
||||
@@ -80,18 +100,42 @@ def _collect_rows(
|
||||
return pos, rows, aligns
|
||||
|
||||
|
||||
_STRETCH = ">{\\raggedright\\arraybackslash}X"
|
||||
|
||||
|
||||
def _tabular(rows: list[list[str]], aligns: list[str]) -> str:
|
||||
"""Табличное окружение для строк с заданным выравниванием колонок.
|
||||
|
||||
Колонка, выровненная по левому краю, считается текстовой: её содержимое
|
||||
должно переноситься по словам, иначе широкая таблица уезжает за правое
|
||||
поле страницы. Такие колонки верстаются растягиваемым типом ``X``, а вся
|
||||
таблица — окружением ``tabularx`` шириной в текстовое поле. Колонки по
|
||||
центру и по правому краю (обычно числовые) остаются узкими.
|
||||
|
||||
Если текстовых колонок нет, растягивать нечего и используется обычный
|
||||
``tabular``: ``tabularx`` без единой ``X``-колонки не собирается.
|
||||
"""
|
||||
cols = max(len(row) for row in rows)
|
||||
for row in rows:
|
||||
row.extend([""] * (cols - len(row)))
|
||||
|
||||
spec = list(aligns[:cols]) + ["l"] * max(0, cols - len(aligns))
|
||||
col_spec = "|".join(spec)
|
||||
lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"]
|
||||
widths = [max(len(row[i]) for row in rows) for i in range(cols)]
|
||||
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"
|
||||
width = "{\\textwidth}" if stretchable else ""
|
||||
lines = [f"\\begin{{{env}}}{width}{{|{col_spec}|}}", "\\hline"]
|
||||
for row in rows:
|
||||
lines.append(" & ".join(row) + " \\\\")
|
||||
lines.append("\\hline")
|
||||
lines.append("\\end{tabular}")
|
||||
lines.append(f"\\end{{{env}}}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -124,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))
|
||||
@@ -135,7 +179,7 @@ def captioned_table(tokens: list[Token]) -> str:
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"\\begin{table}[H]",
|
||||
"\\begin{table}[htb]",
|
||||
f" \\caption{{{caption}}}",
|
||||
" \\centering",
|
||||
_tabular(rows, aligns),
|
||||
|
||||
+12
-11
@@ -33,15 +33,16 @@ def abbreviations(text: str) -> str:
|
||||
if not rows:
|
||||
return text
|
||||
|
||||
max_abbr = max(len(r[0]) for r in rows)
|
||||
abbr_col = max_abbr + 2
|
||||
tabular_rows = []
|
||||
for abbr, desc in rows:
|
||||
pad = max_abbr - len(abbr) + 2
|
||||
tabular_rows.append(
|
||||
f"{abbr} {' ' * pad}& \\hangafter=1\\hangindent=2em ------ {desc} \\\\"
|
||||
)
|
||||
# Колонка l сама берёт ширину самой длинной аббревиатуры, поэтому тире
|
||||
# выстраиваются в столбец без подсчёта ширин на глаз, а отбивка до тире
|
||||
# остаётся обычным пробелом. Расшифровка целиком лежит в колонке X, и её
|
||||
# перенос встаёт под первым словом расшифровки, а не под тире.
|
||||
body = "\n".join(f"{abbr} & {desc} \\\\" for abbr, desc in rows)
|
||||
|
||||
col_spec = f"@{{\\hspace{{0pt}}}}p{{{abbr_col}em}}@{{\\hspace{{0pt}}}}p{{\\dimexpr\\linewidth-{abbr_col}em\\relax}}"
|
||||
body = "\n".join(tabular_rows)
|
||||
return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
|
||||
# Отбивки колонок убраны с обоих краёв: иначе сумма ширин превышает
|
||||
# \linewidth на 2\tabcolsep и перечень выходит за правое поле.
|
||||
return (
|
||||
"\\noindent\\begin{tabularx}{\\linewidth}{@{}l@{\\ ---\\ }X@{}}\n"
|
||||
f"{body}\n"
|
||||
"\\end{tabularx}"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from md2gost.latex.preamble import END_DOCUMENT, PREAMBLE
|
||||
from md2gost.latex.titlepage import render_titlepage
|
||||
from md2gost.latex.titlepages import TITLEPAGES, render_titlepage
|
||||
|
||||
__all__ = ["END_DOCUMENT", "PREAMBLE", "render_titlepage"]
|
||||
__all__ = ["END_DOCUMENT", "PREAMBLE", "TITLEPAGES", "render_titlepage"]
|
||||
|
||||
@@ -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)
|
||||
+120
-11
@@ -14,8 +14,23 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\usepackage{graphicx}
|
||||
\graphicspath{ {./} {./images/} }
|
||||
\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}
|
||||
\sloppy
|
||||
% \sloppy не используется намеренно: он снимает штраф за разреженные строки
|
||||
% целиком, и абзац с длинным неразрывным словом разгоняется пробелами вдвое
|
||||
% шире нормальных. \emergencystretch включается только там, где иначе строка
|
||||
% вышла бы за поле.
|
||||
\setlength{\emergencystretch}{3em}
|
||||
\clubpenalty=10000
|
||||
\widowpenalty=10000
|
||||
\usepackage{enumitem}
|
||||
@@ -25,20 +40,59 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\usepackage{hyperref}
|
||||
\hypersetup {unicode=true}
|
||||
\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}
|
||||
\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{
|
||||
basicstyle=\ttfamily\footnotesize,
|
||||
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,
|
||||
breaklines=true,
|
||||
captionpos=t,
|
||||
inputencoding=utf8,
|
||||
frame=single,
|
||||
keepspaces=true,
|
||||
keywordstyle=\bf,
|
||||
numbers=left,
|
||||
numbersep=5pt,
|
||||
numbersep=7pt,
|
||||
aboveskip=0.7\baselineskip,
|
||||
belowskip=0.4\baselineskip,
|
||||
postbreak=\mbox{\textcolor{lstlineno}{$\hookrightarrow$}\space},
|
||||
xleftmargin=25pt,
|
||||
xrightmargin=25pt,
|
||||
showspaces=false,
|
||||
@@ -47,6 +101,8 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
stepnumber=1,
|
||||
tabsize=2,
|
||||
}
|
||||
\renewcommand{\lstlistingname}{Листинг}
|
||||
\captionsetup[lstlisting]{labelsep=emdash,font=onehalfspacing,position=top,singlelinecheck=false,justification=raggedright}
|
||||
\usepackage{amsthm,amsfonts,amsmath,amssymb,amscd}
|
||||
\usepackage{mathtools}
|
||||
\usepackage{unicode-math}
|
||||
@@ -60,9 +116,9 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\titleformat{\section}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesection. }
|
||||
\titleformat{\subsection}{\fontsize{14pt}{18pt}\selectfont\bfseries}{}{0em}{\thesubsection. }
|
||||
\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*{\subsection}{1.25cm}{0.5ex plus 0.2ex minus 0.2ex}{0.3ex plus 0.1ex minus 0.1ex}
|
||||
\titlespacing*{\subsubsection}{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}{1ex plus 0.3ex minus 0.2ex}{0.6ex plus 0.2ex minus 0.1ex}
|
||||
\titlespacing*{\subsubsection}{1.25cm}{1ex plus 0.3ex minus 0.2ex}{0.6ex plus 0.2ex minus 0.1ex}
|
||||
\usepackage{tocloft}
|
||||
\addto\captionsrussian{%
|
||||
\renewcommand{\contentsname}{СОДЕРЖАНИЕ}%
|
||||
@@ -81,12 +137,15 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\renewcommand{\cftsecpagefont}{\mdseries}
|
||||
\renewcommand{\cftsubsecfont}{\mdseries}
|
||||
\renewcommand{\cftsubsubsecfont}{\mdseries}
|
||||
% Ширина поля номера в оглавлении измеряется по шрифту, а не задаётся
|
||||
% константой: при фиксированных 1.5em номер 2.10. не помещается и печатается
|
||||
% поверх названия главы, а у остальных пропадает пробел после точки.
|
||||
\setlength{\cftsecindent}{0em}
|
||||
\setlength{\cftsecnumwidth}{1em}
|
||||
\setlength{\cftsubsecindent}{0em}
|
||||
\setlength{\cftsubsecnumwidth}{1.5em}
|
||||
\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]{%
|
||||
\begin{tabular}[t]{@{}c@{}}%
|
||||
@@ -96,6 +155,56 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
||||
\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]{%
|
||||
\par\noindent
|
||||
\makebox[#1][l]{#2}%
|
||||
\begin{tabular}[t]{@{}c@{}}%
|
||||
\makebox[#4][c]{#3} \\[-1.05em]%
|
||||
\rule{#4}{0.4pt}%
|
||||
\end{tabular}\par}
|
||||
|
||||
\newcommand{\ptpfield}[3][4.5cm]{%
|
||||
\ptpfieldw[#1]{#2}{#3}{\dimexpr\textwidth-#1\relax}}
|
||||
|
||||
\newcommand{\ptpfieldnote}[4][4.5cm]{%
|
||||
\ptpfield[#1]{#2}{#3}%
|
||||
\par\vspace{-0.45em}%
|
||||
% \noindent обязателен: иначе к строке подписи прибавляется абзацный отступ,
|
||||
% и она ровно на \parindent выходит за правое поле.
|
||||
\noindent\hspace*{#1}%
|
||||
\makebox[\dimexpr\textwidth-#1\relax][c]{\footnotesize\itshape #4}\par}
|
||||
|
||||
\newcommand{\ptpfieldpar}[3][4.5cm]{%
|
||||
\par\noindent
|
||||
\makebox[#1][l]{#2}%
|
||||
\begin{tabular}[t]{@{}c@{}}%
|
||||
\parbox{\dimexpr\textwidth-#1\relax}{\centering #3} \\[-0.55em]%
|
||||
\rule{\dimexpr\textwidth-#1\relax}{0.4pt}%
|
||||
\end{tabular}\par}
|
||||
|
||||
% Блок подписи: метка слева, две подписи прижаты к правому полю.
|
||||
% Через tabularx с колонкой X это не работало — при длинной метке
|
||||
% («Руководитель практики от кафедры») колонка схлопывалась, и подписи
|
||||
% уезжали за правое поле. Здесь ширина подписей фиксирована, а \hfill
|
||||
% забирает остаток, поэтому правые края блоков всегда совпадают.
|
||||
\newcommand{\ptpsig}[3]{%
|
||||
\par\noindent
|
||||
#1\hfill
|
||||
\sigline[3.8cm]{}{\itshape подпись, дата}%
|
||||
\hspace{0.5cm}%
|
||||
\sigline[3.8cm]{#2}{\itshape #3}\par}
|
||||
|
||||
\begin{document}"""
|
||||
|
||||
END_DOCUMENT = r"\end{document}" + "\n"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Реестр макетов титульного листа.
|
||||
|
||||
Макет выбирается полем front matter ``титульник``; по умолчанию ``default`` —
|
||||
исторический макет, поэтому существующие документы продолжают собираться
|
||||
без изменений.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from md2gost.latex.titlepages import default, ptp_report, ptp_task
|
||||
|
||||
TITLEPAGES: dict[str, Callable[[dict[str, str]], str]] = {
|
||||
"default": default.render,
|
||||
"ptp-report": ptp_report.render,
|
||||
"ptp-task": ptp_task.render,
|
||||
}
|
||||
|
||||
__all__ = ["TITLEPAGES", "render_titlepage"]
|
||||
|
||||
|
||||
def render_titlepage(fm: dict[str, str]) -> str:
|
||||
name = fm.get("титульник", "default")
|
||||
try:
|
||||
layout = TITLEPAGES[name]
|
||||
except KeyError:
|
||||
available = ", ".join(sorted(TITLEPAGES))
|
||||
raise ValueError(
|
||||
f"front matter: неизвестный титульник '{name}'; доступны: {available}"
|
||||
) from None
|
||||
return layout(fm)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Общие части титульных листов: доступ к полям и шапка по бланку кафедры."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from md2gost.handlers.inline import escape_latex
|
||||
|
||||
MINISTRY_DEFAULTS = {
|
||||
"министерство": "Министерство науки и высшего образования Российской Федерации",
|
||||
"вуз_тип": "Федеральное государственное автономное образовательное учреждение",
|
||||
"вуз_тип2": "высшего образования",
|
||||
"вуз": "Московский государственный технический университет",
|
||||
"вуз_имени": "имени Н.Э. Баумана",
|
||||
"вуз_статус": "национальный исследовательский университет",
|
||||
"вуз_аббревиатура": "МГТУ им. Н.Э. Баумана",
|
||||
}
|
||||
|
||||
|
||||
def field(fm: dict[str, str], key: str, default: str = "") -> str:
|
||||
"""Значение поля front matter, экранированное для LaTeX."""
|
||||
return escape_latex(fm.get(key, default))
|
||||
|
||||
|
||||
def ministry_lines(fm: dict[str, str]) -> str:
|
||||
"""Семь центрированных строк шапки в точности по бланку кафедры.
|
||||
|
||||
Отличается от макета ``default`` разбивкой строк: закрывающая кавычка стоит
|
||||
после статуса вуза, а скобки закрыты.
|
||||
"""
|
||||
get = lambda key: fm.get(key, MINISTRY_DEFAULTS[key]) # noqa: E731
|
||||
lines = [
|
||||
get("министерство"),
|
||||
get("вуз_тип"),
|
||||
get("вуз_тип2"),
|
||||
f"<<{get('вуз')}",
|
||||
get("вуз_имени"),
|
||||
f"({get('вуз_статус')})>>",
|
||||
f"({get('вуз_аббревиатура')})",
|
||||
]
|
||||
# Первая строка без отрицательного отступа: иначе она вылезает за верхнюю
|
||||
# границу рамки, в которую шапку заключает бланк отчёта.
|
||||
head = f"\\centerline{{{lines[0]}}}"
|
||||
rest = "\n".join(
|
||||
f"\\vspace*{{-0.7ex}}\n\\centerline{{{line}}}" for line in lines[1:]
|
||||
)
|
||||
return f"{head}\n{rest}"
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Исторический макет титульного листа.
|
||||
|
||||
Перенесён без изменений: вывод обязан совпадать байт в байт с прежним,
|
||||
иначе сломаются уже сданные документы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from md2gost.handlers.inline import escape_latex
|
||||
|
||||
|
||||
def render_titlepage(fm: dict[str, str]) -> str:
|
||||
def render(fm: dict[str, str]) -> str:
|
||||
required = {"название": "заголовок работы", "тип": "тип работы"}
|
||||
for key, desc in required.items():
|
||||
if key not in fm:
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Титульный лист по бланку «ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ» кафедры ИУ9."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from md2gost.latex.titlepages._common import field, ministry_lines
|
||||
|
||||
|
||||
def render(fm: dict[str, str]) -> str:
|
||||
faculty = field(fm, "факультет")
|
||||
department = field(fm, "кафедра")
|
||||
student_short = field(fm, "студент")
|
||||
student_full = field(fm, "студент_полностью") or student_short
|
||||
group = field(fm, "группа")
|
||||
practice = field(fm, "тип_практики")
|
||||
company = field(fm, "предприятие")
|
||||
team = field(fm, "команда")
|
||||
repo = field(fm, "хранилище")
|
||||
advisor = field(fm, "руководитель")
|
||||
year = field(fm, "год", "2026")
|
||||
|
||||
team_line = f"{team} (хранилище {repo})" if repo else team
|
||||
|
||||
return (
|
||||
"\\begin{titlepage}\n"
|
||||
"\\thispagestyle{empty}\n"
|
||||
"\n"
|
||||
"% --- Шапка в рамке с разделителем, как в бланке ---\n"
|
||||
"\\vspace*{-45pt}\n"
|
||||
"\\noindent\\begin{tabular}"
|
||||
"{|@{\\hspace{\\ptpheadpad}}c@{\\hspace{\\ptpheadpad}}"
|
||||
"|@{\\hspace{\\ptpheadpad}}c@{\\hspace{\\ptpheadpad}}|}\n"
|
||||
"\\hline\n"
|
||||
"\\begin{minipage}{\\ptpemblemwidth}\\centering\\vspace{3pt}\n"
|
||||
"\\includegraphics[width=0.92\\textwidth]{emblem}\n"
|
||||
"\\vspace{3pt}\\end{minipage}\n"
|
||||
"&\n"
|
||||
"\\begin{minipage}{\\ptpheadwidth}\\small\\bfseries\n"
|
||||
"\\vspace*{2pt}\n"
|
||||
f"{ministry_lines(fm)}\n"
|
||||
"\\vspace*{0.5ex}\n"
|
||||
"\\end{minipage} \\\\\n"
|
||||
"\\hline\n"
|
||||
"\\end{tabular}\n"
|
||||
"\n"
|
||||
"% --- Полосы ---\n"
|
||||
"\\vspace{6pt}\n"
|
||||
"\\noindent\\rule{\\textwidth}{2.3pt}\n"
|
||||
"\n"
|
||||
"\\vspace*{-18pt}\n"
|
||||
"\\noindent\\rule{\\textwidth}{0.4pt}\n"
|
||||
"\n"
|
||||
"% --- Факультет и кафедра ---\n"
|
||||
"\\vspace{2.5ex}\n"
|
||||
f"\\ptpfield[2.8cm]{{ФАКУЛЬТЕТ}}{{{faculty}}}\n"
|
||||
"\n"
|
||||
"\\vspace{1.5ex}\n"
|
||||
f"\\ptpfield[2.8cm]{{КАФЕДРА}}{{{department}}}\n"
|
||||
"\n"
|
||||
"% --- Заголовок ---\n"
|
||||
"\\vspace{3.5em}\n"
|
||||
"\\begin{center}\n"
|
||||
"{\\Large\\bfseries\\underline{ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ}}\n"
|
||||
"\\end{center}\n"
|
||||
"\n"
|
||||
"% --- Поля ---\n"
|
||||
"\\vspace{2.5em}\n"
|
||||
f"\\ptpfieldnote[2.4cm]{{Студент}}{{{student_full}}}{{фамилия, имя, отчество}}\n"
|
||||
"\n"
|
||||
"\\vspace{1.8ex}\n"
|
||||
f"\\ptpfieldw[2.4cm]{{Группа}}{{{group}}}{{5cm}}\n"
|
||||
"\n"
|
||||
"\\vspace{1.8ex}\n"
|
||||
f"\\ptpfield[4.6cm]{{Тип практики}}{{{practice}}}\n"
|
||||
"\n"
|
||||
"\\vspace{1.8ex}\n"
|
||||
f"\\ptpfield[4.6cm]{{Название предприятия}}{{{company}}}\n"
|
||||
"\n"
|
||||
"\\vspace{1.8ex}\n"
|
||||
f"\\ptpfieldpar[2.4cm]{{Команда}}{{{team_line}}}\n"
|
||||
"\n"
|
||||
"% --- Подписи ---\n"
|
||||
"\\vfill\n"
|
||||
f"\\ptpsig{{Студент}}{{{student_short}}}{{фамилия, и.о.}}\n"
|
||||
"\n"
|
||||
"\\vspace{2em}\n"
|
||||
f"\\ptpsig{{Руководитель практики}}{{{advisor}}}{{фамилия, и.о.}}\n"
|
||||
"\n"
|
||||
"% --- Оценка ---\n"
|
||||
"\\vspace{2em}\n"
|
||||
"\\noindent Оценка \\rule{0.5\\textwidth}{0.4pt}\n"
|
||||
"\n"
|
||||
"\\vfill\n"
|
||||
"\\begin{center}\n"
|
||||
f"\\textsl{{{year} г.}}\n"
|
||||
"\\end{center}\n"
|
||||
"\n"
|
||||
"\\end{titlepage}"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Титульный лист по бланку «ЗАДАНИЕ на прохождение учебной практики».
|
||||
|
||||
Бланк идёт отдельным листом и вкладывается в отчёт, поэтому документ состоит
|
||||
из одного этого листа. Текст пунктов задания в модуле не хранится: у каждого
|
||||
студента он свой и приходит из front matter ключами ``пункт_N``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from md2gost.handlers.inline import escape_latex
|
||||
from md2gost.latex.titlepages._common import field, ministry_lines
|
||||
|
||||
_ITEM_RE = re.compile(r"^пункт_(\d+)$")
|
||||
|
||||
|
||||
def task_items(fm: dict[str, str]) -> list[str]:
|
||||
"""Пункты задания из ключей ``пункт_N``, отсортированные по номеру."""
|
||||
items = [
|
||||
(int(match.group(1)), value)
|
||||
for key, value in fm.items()
|
||||
if (match := _ITEM_RE.match(key)) and value.strip()
|
||||
]
|
||||
if not items:
|
||||
raise ValueError(
|
||||
"front matter: у макета 'ptp-task' нет ни одного пункта задания; "
|
||||
"добавьте поля 'пункт_1', 'пункт_2', ..."
|
||||
)
|
||||
return [value for _, value in sorted(items)]
|
||||
|
||||
|
||||
def _items_tex(fm: dict[str, str], team_line: str) -> str:
|
||||
"""Нумерованный список пунктов; под пунктом с двоеточием — строка команды."""
|
||||
lines: list[str] = []
|
||||
for text in task_items(fm):
|
||||
lines.append(f" \\item {escape_latex(text)}")
|
||||
if text.rstrip().endswith(":"):
|
||||
lines.append(
|
||||
" \\par\\vspace{0.5ex}"
|
||||
f"\\noindent\\makebox[\\textwidth][c]{{\\itshape {team_line}}}"
|
||||
"\\par\\vspace{-0.6em}"
|
||||
"\\noindent\\rule{\\textwidth}{0.4pt}\\par"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render(fm: dict[str, str]) -> str:
|
||||
department = field(fm, "кафедра")
|
||||
dep_index = field(fm, "кафедра_индекс", "ИУ9")
|
||||
student_full = field(fm, "студент_полностью") or field(fm, "студент")
|
||||
group = field(fm, "группа")
|
||||
company = field(fm, "предприятие")
|
||||
team = field(fm, "команда")
|
||||
repo = field(fm, "хранилище")
|
||||
advisor = field(fm, "руководитель")
|
||||
day = field(fm, "дата_выдачи_день")
|
||||
month = field(fm, "дата_выдачи_месяц")
|
||||
year = field(fm, "дата_выдачи_год", "2026")
|
||||
|
||||
team_line = f"{team} (хранилище {repo})" if repo else team
|
||||
student_line = f"{student_full}, {group}" if group else student_full
|
||||
|
||||
return (
|
||||
"\\begin{titlepage}\n"
|
||||
"\\thispagestyle{empty}\n"
|
||||
"\\setstretch{1.0}\n"
|
||||
# center/flushright добавляют \\topsep и \\parskip сверху и снизу;
|
||||
# на бланке из-за этого не остаётся места под подписи.
|
||||
"\\setlength{\\parskip}{0pt}\n"
|
||||
"\\setlength{\\topsep}{0pt}\n"
|
||||
"\\setlength{\\partopsep}{0pt}\n"
|
||||
"\n"
|
||||
"% --- Шапка без эмблемы: в бланке задания её нет ---\n"
|
||||
"\\vspace*{-52pt}\n"
|
||||
"{\\small\\bfseries\n"
|
||||
f"{ministry_lines(fm)}}}\n"
|
||||
"\n"
|
||||
"% --- Полосы ---\n"
|
||||
"\\vspace{1ex}\n"
|
||||
"\\noindent\\rule{\\textwidth}{2.3pt}\n"
|
||||
"\n"
|
||||
"\\vspace*{-18pt}\n"
|
||||
"\\noindent\\rule{\\textwidth}{0.4pt}\n"
|
||||
"\n"
|
||||
"% --- Кафедра ---\n"
|
||||
"\\vspace{1ex}\n"
|
||||
"\\begin{center}\n"
|
||||
f"Кафедра <<\\underline{{{department}}}>> (\\underline{{{dep_index}}})\n"
|
||||
"\\end{center}\n"
|
||||
"\n"
|
||||
"% --- Утверждаю ---\n"
|
||||
"\\vspace{0.5ex}\n"
|
||||
"\\begin{flushright}\n"
|
||||
"УТВЕРЖДАЮ \\\\[0.4ex]\n"
|
||||
"Заведующий кафедрой \\rule{3cm}{0.4pt} \\\\[1ex]\n"
|
||||
"\\rule{3.2cm}{0.4pt} \\quad \\rule{4cm}{0.4pt} \\\\[1ex]\n"
|
||||
"<<\\,\\rule{1cm}{0.4pt}\\,>> \\rule{3cm}{0.4pt} 20\\,\\rule{0.8cm}{0.4pt} г.\n"
|
||||
"\\end{flushright}\n"
|
||||
"\n"
|
||||
"% --- Заголовок ---\n"
|
||||
"\\vspace{0.5ex}\n"
|
||||
"\\begin{center}\n"
|
||||
"{\\large\\bfseries З\\,А\\,Д\\,А\\,Н\\,И\\,Е} \\\\[0.4ex]\n"
|
||||
"{\\bfseries на прохождение учебной практики}\n"
|
||||
"\\end{center}\n"
|
||||
"\n"
|
||||
"% --- Поля ---\n"
|
||||
"\\vspace{1ex}\n"
|
||||
f"\\ptpfield[3.3cm]{{на предприятии}}{{{company}}}\n"
|
||||
"\n"
|
||||
"\\vspace{0.8ex}\n"
|
||||
f"\\ptpfieldnote[2.2cm]{{Студент}}{{{student_line}}}"
|
||||
"{(фамилия, имя, отчество; инициалы; индекс группы)}\n"
|
||||
"\n"
|
||||
"% --- Пункты задания ---\n"
|
||||
"\\vspace{1.2ex}\n"
|
||||
"\\noindent Во время прохождения учебной практики студент должен:\n"
|
||||
# Пункты набираются мельче основного кегля — как в бланке кафедры,
|
||||
# иначе полный список не помещается на лист вместе с подписями.
|
||||
"{\\small\n"
|
||||
"\\begin{enumerate}[wide=0pt, labelindent=0pt, labelwidth=0pt,\n"
|
||||
" label=\\arabic*., itemsep=0.2ex, topsep=0.3ex, parsep=0pt]\n"
|
||||
f"{_items_tex(fm, team_line)}\n"
|
||||
"\\end{enumerate}}\n"
|
||||
"\n"
|
||||
"% --- Дата выдачи ---\n"
|
||||
"\\vspace{1ex}\n"
|
||||
"\\noindent Дата выдачи задания "
|
||||
f"<<\\,\\underline{{~{day}~}}\\,>> \\underline{{~{month}~}} "
|
||||
f"\\underline{{~{year}~}} г.\n"
|
||||
"\n"
|
||||
"% --- Подписи ---\n"
|
||||
"\\vspace{0.6em}\n"
|
||||
f"{{\\setstretch{{1.3}}\\ptpsig{{\\bfseries Руководитель практики от кафедры}}"
|
||||
f"{{{advisor}}}{{(Фамилия И.О.)}}}}\n"
|
||||
"\n"
|
||||
"\\vspace{0.5em}\n"
|
||||
"{\\setstretch{1.3}\\ptpsig{\\bfseries Студент}{}{(Фамилия И.О.)}}\n"
|
||||
"\n"
|
||||
"\\end{titlepage}"
|
||||
)
|
||||
+28
-6
@@ -6,15 +6,25 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import md2gost
|
||||
from md2gost.latex.log import STRICT_KINDS, format_diagnostics, parse_log
|
||||
from md2gost.parser import Parser, iter_image_sources
|
||||
from md2gost.render import Renderer
|
||||
|
||||
|
||||
def _print_latex_errors(log_path: Path) -> None:
|
||||
if log_path.exists():
|
||||
for line in log_path.read_text(errors="replace").splitlines():
|
||||
if line.startswith("!"):
|
||||
print(f" {line}", file=sys.stderr)
|
||||
def _report_latex_log(log_path: Path) -> bool:
|
||||
"""Печатает диагностику сборки. Возвращает True при серьёзных дефектах.
|
||||
|
||||
Журнал живёт во временном каталоге и исчезает вместе с ним, поэтому всё,
|
||||
о чём сообщил 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:
|
||||
@@ -85,6 +95,11 @@ def main() -> None:
|
||||
action="store_true",
|
||||
help="только .tex, без компиляции в PDF",
|
||||
)
|
||||
cli.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="выйти с ошибкой, если xelatex сообщил о предупреждениях",
|
||||
)
|
||||
args = cli.parse_args()
|
||||
|
||||
if not args.input.exists():
|
||||
@@ -118,13 +133,20 @@ def main() -> None:
|
||||
if not run_xelatex(tex_path, cache_dir):
|
||||
stage = "" if attempt == 1 else " (2-й проход)"
|
||||
print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr)
|
||||
_print_latex_errors(log_path)
|
||||
_report_latex_log(log_path)
|
||||
sys.exit(1)
|
||||
|
||||
final_pdf = args.output / pdf_name
|
||||
shutil.copy2(cache_dir / pdf_name, 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__":
|
||||
main()
|
||||
|
||||
+5
-2
@@ -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:
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
|
||||
# Импорт обработчиков регистрирует их в реестрах.
|
||||
from md2gost.handlers import ( # noqa: F401
|
||||
bullet_list,
|
||||
captioned_listing,
|
||||
captioned_table,
|
||||
fence,
|
||||
figure,
|
||||
@@ -74,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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+464
-5
@@ -1,3 +1,8 @@
|
||||
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
|
||||
from md2gost.render import Renderer
|
||||
|
||||
@@ -9,6 +14,17 @@ def render(md: str) -> str:
|
||||
return Renderer(parser.tokens, parser.front_matter).render()
|
||||
|
||||
|
||||
def body(tex: str) -> str:
|
||||
"""Тело документа без преамбулы: в преамбуле свои tabularx-макросы."""
|
||||
return tex.split("\\setcounter{page}{2}", 1)[1]
|
||||
|
||||
|
||||
def render_fm(extra: str, body: str = "Текст.\n") -> str:
|
||||
head = f"---\nназвание: Тестовая работа\nтип: Отчёт\n{extra}---\n\n"
|
||||
parser = Parser(head + body)
|
||||
return Renderer(parser.tokens, parser.front_matter).render()
|
||||
|
||||
|
||||
def test_heading_numbering() -> None:
|
||||
tex = render("# Исследовательская часть\n\n## Обзор\n")
|
||||
assert "\\section{Исследовательская часть}" in tex
|
||||
@@ -22,6 +38,35 @@ def test_unnumbered_heading() -> None:
|
||||
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:
|
||||
tex = render("- один\n- два\n- три\n")
|
||||
assert " \\item один;" in tex
|
||||
@@ -33,7 +78,7 @@ def test_figure_with_caption(tmp_path) -> None:
|
||||
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
|
||||
"Данная антенна используется для ...\n"
|
||||
)
|
||||
assert "\\begin{figure}[H]" in tex
|
||||
assert "\\begin{figure}[htb]" in tex
|
||||
assert "\\centering" in tex
|
||||
assert (
|
||||
"\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}"
|
||||
@@ -45,7 +90,7 @@ def test_figure_with_caption(tmp_path) -> None:
|
||||
|
||||
def test_figure_without_caption() -> None:
|
||||
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 "Обычный абзац после картинки." in tex
|
||||
|
||||
@@ -112,9 +157,9 @@ TABLE_MD = (
|
||||
|
||||
def test_table_with_caption() -> None:
|
||||
tex = render(TABLE_MD)
|
||||
assert "\\begin{table}[H]" in tex
|
||||
assert "\\begin{table}[htb]" in tex
|
||||
assert "\\caption{Сравнение скорости}" in tex
|
||||
assert "\\begin{tabular}{|l|c|r|}" in tex
|
||||
assert "\\begin{tabularx}{\\textwidth}" in tex
|
||||
assert "\\textbf{1} & 0.027852 & 0.000285 \\\\" in tex
|
||||
|
||||
|
||||
@@ -127,7 +172,7 @@ def test_table_caption_number_is_discarded() -> None:
|
||||
def test_table_without_caption_stays_bare() -> None:
|
||||
tex = render("| a | b |\n| --- | --- |\n| 1 | 2 |\n")
|
||||
assert "\\begin{table}" not in tex
|
||||
assert "\\begin{tabular}{|l|l|}" in tex
|
||||
assert "\\begin{tabularx}{\\textwidth}" in tex
|
||||
|
||||
|
||||
def test_table_caption_paragraph_is_not_duplicated() -> None:
|
||||
@@ -139,3 +184,417 @@ def _table_block(tex: str) -> str:
|
||||
start = tex.index("\\begin{table}")
|
||||
end = tex.index("\\end{table}") + len("\\end{table}")
|
||||
return tex[start:end]
|
||||
|
||||
|
||||
def test_titlepage_default_is_used_when_field_absent() -> None:
|
||||
tex = render("Текст.\n")
|
||||
assert "\\begin{titlepage}" in tex
|
||||
assert "ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ" not in tex
|
||||
|
||||
|
||||
def test_titlepage_registry_lists_three_layouts() -> None:
|
||||
assert set(TITLEPAGES) == {"default", "ptp-report", "ptp-task"}
|
||||
|
||||
|
||||
def test_titlepage_unknown_name_raises() -> None:
|
||||
with pytest.raises(ValueError, match="ptp-report"):
|
||||
render_fm("титульник: несуществующий\n")
|
||||
|
||||
|
||||
PTP_REPORT_FM = (
|
||||
"титульник: ptp-report\n"
|
||||
"факультет: Информатика и системы управления (ИУ)\n"
|
||||
"кафедра: Теоретическая информатика и компьютерные технологии (ИУ9)\n"
|
||||
"студент: И. И. Камалетдинов\n"
|
||||
"студент_полностью: Камалетдинов Ильяс Илнарович\n"
|
||||
"группа: ИУ9-22Б\n"
|
||||
"тип_практики: проектно-технологическая практика\n"
|
||||
"предприятие: МГТУ имени Н. Э. Баумана\n"
|
||||
"команда: Система мониторинга радиоэфира\n"
|
||||
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
|
||||
"руководитель: Д. П. Посевин\n"
|
||||
"год: 2026\n"
|
||||
)
|
||||
|
||||
|
||||
def test_ptp_report_has_blank_headings() -> None:
|
||||
tex = render_fm(PTP_REPORT_FM)
|
||||
for heading in (
|
||||
"ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ",
|
||||
"Тип практики",
|
||||
"Название предприятия",
|
||||
"Команда",
|
||||
"Оценка",
|
||||
"Руководитель практики",
|
||||
):
|
||||
assert heading in tex
|
||||
|
||||
|
||||
def test_ptp_report_fills_all_fields() -> None:
|
||||
tex = render_fm(PTP_REPORT_FM)
|
||||
for value in (
|
||||
"Камалетдинов Ильяс Илнарович",
|
||||
"И. И. Камалетдинов",
|
||||
"ИУ9-22Б",
|
||||
"проектно-технологическая практика",
|
||||
"МГТУ имени Н. Э. Баумана",
|
||||
"Д. П. Посевин",
|
||||
"2026",
|
||||
):
|
||||
assert value in tex
|
||||
|
||||
|
||||
def test_ptp_report_team_line_includes_repository() -> None:
|
||||
tex = render_fm(PTP_REPORT_FM)
|
||||
assert "Система мониторинга радиоэфира (хранилище" in tex
|
||||
assert "monitor.yss.su/IU9/radio-serv" in tex
|
||||
|
||||
|
||||
def test_ptp_report_team_line_without_repository() -> None:
|
||||
fm = PTP_REPORT_FM.replace("хранилище: https://monitor.yss.su/IU9/radio-serv\n", "")
|
||||
tex = render_fm(fm)
|
||||
assert "хранилище" not in tex
|
||||
|
||||
|
||||
def test_ptp_report_has_no_hardcoded_names() -> None:
|
||||
"""Имена, группы и предприятия приходят только из front matter."""
|
||||
import inspect
|
||||
|
||||
from md2gost.latex.titlepages import ptp_report
|
||||
|
||||
source = inspect.getsource(ptp_report)
|
||||
for forbidden in ("Посевин", "Коновалов", "Камалетдинов", "ИУ9-2"):
|
||||
assert forbidden not in source
|
||||
|
||||
|
||||
PTP_TASK_FM = (
|
||||
"титульник: ptp-task\n"
|
||||
"кафедра: Теоретическая информатика и компьютерные технологии\n"
|
||||
"кафедра_индекс: ИУ9\n"
|
||||
"студент: Булдаков А. С.\n"
|
||||
"студент_полностью: Булдаков Арсений Сергеевич\n"
|
||||
"группа: ИУ9-22Б\n"
|
||||
"предприятие: МГТУ имени Н. Э. Баумана\n"
|
||||
"команда: Система мониторинга радиоэфира\n"
|
||||
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
|
||||
"руководитель: Посевин Д. П.\n"
|
||||
"дата_выдачи_день: 29\n"
|
||||
"дата_выдачи_месяц: июня\n"
|
||||
"дата_выдачи_год: 2026\n"
|
||||
"пункт_1: Изучить принципы построения систем пеленгации.\n"
|
||||
"пункт_2: Получить задание в составе команды:\n"
|
||||
"пункт_10: Написать отчёт.\n"
|
||||
)
|
||||
|
||||
|
||||
def test_ptp_task_renders_items_in_numeric_order() -> None:
|
||||
"""Пункт 10 идёт после пункта 2, а не между 1 и 2 как при сортировке строк."""
|
||||
tex = render_fm(PTP_TASK_FM)
|
||||
first = tex.index("Изучить принципы построения систем пеленгации.")
|
||||
second = tex.index("Получить задание в составе команды:")
|
||||
third = tex.index("Написать отчёт.")
|
||||
assert first < second < third
|
||||
|
||||
|
||||
def test_ptp_task_requires_items() -> None:
|
||||
with pytest.raises(ValueError, match="пункт_1"):
|
||||
render_fm("титульник: ptp-task\nстудент: Булдаков А. С.\n")
|
||||
|
||||
|
||||
def test_ptp_task_has_blank_furniture() -> None:
|
||||
tex = render_fm(PTP_TASK_FM)
|
||||
for fragment in (
|
||||
"УТВЕРЖДАЮ",
|
||||
"Заведующий кафедрой",
|
||||
"З\\,А\\,Д\\,А\\,Н\\,И\\,Е",
|
||||
"на прохождение учебной практики",
|
||||
"Дата выдачи задания",
|
||||
"Руководитель практики от кафедры",
|
||||
):
|
||||
assert fragment in tex
|
||||
|
||||
|
||||
def test_ptp_task_student_line_carries_group() -> None:
|
||||
tex = render_fm(PTP_TASK_FM)
|
||||
assert "Булдаков Арсений Сергеевич, ИУ9-22Б" in tex
|
||||
|
||||
|
||||
def test_ptp_task_team_line_follows_item_with_colon() -> None:
|
||||
tex = render_fm(PTP_TASK_FM)
|
||||
colon_item = tex.index("Получить задание в составе команды:")
|
||||
team = tex.index("Система мониторинга радиоэфира (хранилище")
|
||||
next_item = tex.index("Написать отчёт.")
|
||||
assert colon_item < team < next_item
|
||||
|
||||
|
||||
def test_ptp_task_has_no_emblem() -> None:
|
||||
"""В бланке задания эмблемы нет."""
|
||||
tex = render_fm(PTP_TASK_FM)
|
||||
titlepage = tex[tex.index("\\begin{titlepage}") : tex.index("\\end{titlepage}")]
|
||||
assert "emblem" not in titlepage
|
||||
|
||||
|
||||
def test_ptp_task_has_no_hardcoded_assignment_text() -> None:
|
||||
"""Пункты задания и имена приходят только из front matter."""
|
||||
import inspect
|
||||
|
||||
from md2gost.latex.titlepages import ptp_task
|
||||
|
||||
source = inspect.getsource(ptp_task)
|
||||
for forbidden in (
|
||||
"Web",
|
||||
"2D-игра",
|
||||
"GitFlic",
|
||||
"Javascript",
|
||||
"Посевин",
|
||||
"Коновалов",
|
||||
):
|
||||
assert forbidden not in source
|
||||
|
||||
|
||||
LISTING_MD = (
|
||||
"Листинг 1 —— Потокобезопасная очередь\n\n"
|
||||
"```cpp\n"
|
||||
"template <typename T>\n"
|
||||
"class ThreadSafeQueue {};\n"
|
||||
"```\n"
|
||||
)
|
||||
|
||||
|
||||
def test_listing_with_caption() -> None:
|
||||
tex = render(LISTING_MD)
|
||||
assert "caption={Потокобезопасная очередь}" in tex
|
||||
assert "language={[modern]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={[modern]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={[modern]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
|
||||
|
||||
|
||||
WIDE_TABLE_MD = (
|
||||
"Таблица 1 —— Сценарии интеграции\n\n"
|
||||
"| Сценарий | Событие | Что делает |\n"
|
||||
"| :--- | :--- | :--- |\n"
|
||||
"| build.yml | отправка изменений | Собирает образ Docker целиком |\n"
|
||||
)
|
||||
|
||||
|
||||
def test_wide_table_uses_tabularx_so_it_fits_the_page() -> None:
|
||||
"""Колонки с текстом должны переноситься, а не вылезать за поле."""
|
||||
tex = render(WIDE_TABLE_MD)
|
||||
assert "\\begin{tabularx}{\\textwidth}" in tex
|
||||
assert "\\begin{tabular}{" not in tex
|
||||
|
||||
|
||||
def test_table_left_columns_become_stretchable() -> None:
|
||||
"""Растягиваются колонки с текстом; «Сценарий» короткая и остаётся узкой."""
|
||||
tex = render(WIDE_TABLE_MD)
|
||||
assert tex.count(">{\\raggedright\\arraybackslash}X") == 2
|
||||
|
||||
|
||||
def test_table_keeps_center_columns_narrow() -> None:
|
||||
"""Все колонки короткие: растягивать нечего, таблица идёт по содержимому."""
|
||||
tex = body(render(TABLE_MD))
|
||||
assert "\\begin{tabular}{|l|c|r|}" in tex
|
||||
assert "\\begin{tabularx}" not in tex
|
||||
|
||||
|
||||
def test_table_without_left_columns_stays_plain_tabular() -> None:
|
||||
tex = body(render("| a | b |\n| :---: | :---: |\n| 1 | 2 |\n"))
|
||||
assert "\\begin{tabular}{|c|c|}" 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
|
||||
|
||||
Reference in New Issue
Block a user