Compare commits
7 Commits
d5b6f4b89e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d00a1fed | |||
| 24d3cd0728 | |||
| fa440049e1 | |||
| be64bf475f | |||
| beac1d5a7d | |||
| 7c518cd59d | |||
| 95bd42cea4 |
@@ -51,6 +51,7 @@ docker build -t md2gost .
|
|||||||
- Изображения (см. ниже)
|
- Изображения (см. ниже)
|
||||||
- Аббревиатуры (раздел "Перечень принятых сокращений")
|
- Аббревиатуры (раздел "Перечень принятых сокращений")
|
||||||
- Ссылки, жирный, курсив
|
- Ссылки, жирный, курсив
|
||||||
|
- Пометка правок `==текст==` — жёлтое выделение для рецензента
|
||||||
|
|
||||||
### Изображения
|
### Изображения
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ _LANGUAGES = {
|
|||||||
}
|
}
|
||||||
_PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""}
|
_PLAIN = {"yaml", "yml", "json", "dockerfile", "cmake", "toml", "ini", "text", ""}
|
||||||
|
|
||||||
|
# Сколько строк кода помещается на странице. Листинг длиннее не поместится ни
|
||||||
|
# в один плавающий объект, и LaTeX выбросит его в конец документа, поэтому
|
||||||
|
# такой набирается на месте. Рамка при этом снимается: она всё равно
|
||||||
|
# оборвалась бы на границе страницы и продолжилась на следующей.
|
||||||
|
PAGE_LINES = 34
|
||||||
|
|
||||||
|
APPENDIX = "ПРИЛОЖЕНИЕ"
|
||||||
|
|
||||||
|
|
||||||
def _language(info: str) -> str | None:
|
def _language(info: str) -> str | None:
|
||||||
"""Имя языка для listings или None, если подсветка не нужна."""
|
"""Имя языка для listings или None, если подсветка не нужна."""
|
||||||
@@ -47,8 +55,15 @@ def _language(info: str) -> str | None:
|
|||||||
return language
|
return language
|
||||||
|
|
||||||
|
|
||||||
def _lstlisting(tok: Token, caption: str | None) -> str:
|
def _lstlisting(tok: Token, caption: str | None, in_appendix: bool = False) -> str:
|
||||||
language = _language(tok.info)
|
language = _language(tok.info)
|
||||||
|
code = tok.content.rstrip()
|
||||||
|
# В приложении листинги идут подряд и без текста вокруг. Плавающий объект
|
||||||
|
# там только путает порядок: LaTeX волен переставить короткий листинг за
|
||||||
|
# длинный. Рамка снимается заодно, чтобы соседние листинги выглядели
|
||||||
|
# одинаково независимо от длины.
|
||||||
|
fits_on_page = not in_appendix and code.count("\n") + 1 <= PAGE_LINES
|
||||||
|
|
||||||
opts = []
|
opts = []
|
||||||
if language:
|
if language:
|
||||||
# Значение в фигурных скобках обязательно: имя диалекта задаётся
|
# Значение в фигурных скобках обязательно: имя диалекта задаётся
|
||||||
@@ -57,17 +72,20 @@ def _lstlisting(tok: Token, caption: str | None) -> str:
|
|||||||
opts.append(f"language={{{language}}}")
|
opts.append(f"language={{{language}}}")
|
||||||
if caption:
|
if caption:
|
||||||
opts.append(f"caption={{{escape_latex(caption)}}}")
|
opts.append(f"caption={{{escape_latex(caption)}}}")
|
||||||
# Плавающий листинг не разрывается между страницами: иначе рамка кода
|
if fits_on_page:
|
||||||
# обрывается на границе и продолжается на следующей странице.
|
# Плавающий листинг не разрывается между страницами: иначе рамка
|
||||||
opts.append("float=htbp")
|
# кода обрывается на границе и продолжается на следующей странице.
|
||||||
|
opts.append("float=htbp")
|
||||||
|
if not fits_on_page:
|
||||||
|
opts.append("frame=none")
|
||||||
suffix = f"[{','.join(opts)}]" if opts else ""
|
suffix = f"[{','.join(opts)}]" if opts else ""
|
||||||
return f"\\begin{{lstlisting}}{suffix}\n{tok.content.rstrip()}\n\\end{{lstlisting}}"
|
return f"\\begin{{lstlisting}}{suffix}\n{code}\n\\end{{lstlisting}}"
|
||||||
|
|
||||||
|
|
||||||
@block("fence")
|
@block("fence")
|
||||||
def fence(r, tok: Token) -> str:
|
def fence(r, tok: Token) -> str:
|
||||||
r.pos += 1
|
r.pos += 1
|
||||||
return _lstlisting(tok, None)
|
return _lstlisting(tok, None, r.current_heading == APPENDIX)
|
||||||
|
|
||||||
|
|
||||||
def match_listing(tokens: list[Token], pos: int) -> int | None:
|
def match_listing(tokens: list[Token], pos: int) -> int | None:
|
||||||
@@ -88,7 +106,7 @@ def match_listing(tokens: list[Token], pos: int) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
@pattern(match_listing)
|
@pattern(match_listing)
|
||||||
def captioned_listing(tokens: list[Token]) -> str:
|
def captioned_listing(r, tokens: list[Token]) -> str:
|
||||||
match = _CAPTION_RE.match(tokens[1].content)
|
match = _CAPTION_RE.match(tokens[1].content)
|
||||||
assert match is not None # гарантировано match_listing
|
assert match is not None # гарантировано match_listing
|
||||||
return _lstlisting(tokens[3], match.group(1))
|
return _lstlisting(tokens[3], match.group(1), r.current_heading == APPENDIX)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from urllib.parse import unquote
|
|||||||
|
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token
|
||||||
|
|
||||||
from md2gost.handlers.inline import escape_latex
|
from md2gost.handlers.inline import escape_latex, render_marked
|
||||||
from md2gost.registry import pattern
|
from md2gost.registry import pattern
|
||||||
|
|
||||||
DEFAULT_WIDTH = 0.8
|
DEFAULT_WIDTH = 0.8
|
||||||
@@ -83,7 +83,7 @@ def match_figure(tokens: list[Token], pos: int) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
@pattern(match_figure)
|
@pattern(match_figure)
|
||||||
def figure(tokens: list[Token]) -> str:
|
def figure(r, tokens: list[Token]) -> str:
|
||||||
img_tok = _single_image_paragraph(tokens, 0)
|
img_tok = _single_image_paragraph(tokens, 0)
|
||||||
assert img_tok is not None
|
assert img_tok is not None
|
||||||
raw_src = unquote(str(img_tok.attrGet("src") or ""))
|
raw_src = unquote(str(img_tok.attrGet("src") or ""))
|
||||||
@@ -93,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}[ht]",
|
"\\begin{figure}[htb]",
|
||||||
" \\centering",
|
" \\centering",
|
||||||
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
|
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token
|
||||||
|
|
||||||
@@ -33,11 +34,43 @@ def escape_latex(text: str) -> str:
|
|||||||
return text.translate(_LATEX_SPECIAL)
|
return text.translate(_LATEX_SPECIAL)
|
||||||
|
|
||||||
|
|
||||||
# Инициалы не должны отрываться друг от друга и от фамилии переносом строки:
|
# Длина слова, начиная с которой ему нужны точки переноса, и символы, после
|
||||||
# «/ И.» на одной строке и «В. Гилев» на следующей — ошибка оформления списка
|
# которых переносить допустимо. Неразрывный идентификатор вроде
|
||||||
# источников. Связывается одиночная заглавная буква с точкой и следующее слово,
|
# QT_QPA_PLATFORM=offscreen не помещается в остаток строки целиком, и абзац
|
||||||
# начинающееся с заглавной.
|
# приходится разгонять пробелами.
|
||||||
_INITIALS_RE = re.compile(r"(?<![\w.])([A-ZА-ЯЁ]\.)\s+(?=[A-ZА-ЯЁ])")
|
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:
|
def bind_initials(text: str) -> str:
|
||||||
@@ -49,9 +82,63 @@ def bind_initials(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ==текст== — пометка правки для рецензента. Разметки такого рода в
|
||||||
|
# CommonMark нет, поэтому она разбирается здесь, на уровне текстовых узлов:
|
||||||
|
# внутрь листингов и инлайн-кода регулярное выражение не попадает, и «==»
|
||||||
|
# в исходниках на C++ остаётся сравнением. Границы выделения примыкают к
|
||||||
|
# тексту вплотную — как у прочих парных знаков разметки.
|
||||||
|
_MARK_RE = re.compile(r"==(?=\S)(.+?)(?<=\S)==")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_marks(text: str) -> str:
|
||||||
|
"""Снять пометки правок, оставив сам текст."""
|
||||||
|
return _MARK_RE.sub(lambda match: match.group(1), text)
|
||||||
|
|
||||||
|
|
||||||
|
def render_text(text: str) -> str:
|
||||||
|
return split_compounds(bind_initials(escape_latex(text)))
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_word(word: str) -> str:
|
||||||
|
"""Слово в жёлтой плашке, составное — по плашке на каждую часть.
|
||||||
|
|
||||||
|
Плашка неразрывна, поэтому шорткат переноса «"=» внутри неё не работает
|
||||||
|
и «аппаратно-программный» целиком вылезает за поле. Дефис получает свою
|
||||||
|
плашку, а точка переноса ставится после неё.
|
||||||
|
"""
|
||||||
|
parts = [part for part in word.split("-") if part]
|
||||||
|
return "\\hlw{-}\\allowbreak ".join(f"\\hlw{{{part}}}" for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def highlight(text: str) -> str:
|
||||||
|
"""Жёлтая плашка на каждом слове.
|
||||||
|
|
||||||
|
Одной плашкой на всё выделение строка не разорвётся по его середине,
|
||||||
|
а выделять приходится и целые предложения.
|
||||||
|
"""
|
||||||
|
escaped = bind_initials(escape_latex(text))
|
||||||
|
return " ".join(highlight_word(word) for word in escaped.split(" ") if word)
|
||||||
|
|
||||||
|
|
||||||
|
def render_marked(text: str, plain: Callable[[str], str] = render_text) -> str:
|
||||||
|
"""Разобрать пометки правок, остальное отдать ``plain``.
|
||||||
|
|
||||||
|
Подпись рисунка не проходит через инлайн-рендер, но выделять правки
|
||||||
|
нужно и в ней, поэтому разбор вынесен из обработчика текстового узла.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
end = 0
|
||||||
|
for match in _MARK_RE.finditer(text):
|
||||||
|
parts.append(plain(text[end : match.start()]))
|
||||||
|
parts.append(highlight(match.group(1)))
|
||||||
|
end = match.end()
|
||||||
|
parts.append(plain(text[end:]))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@inline("text")
|
@inline("text")
|
||||||
def _text(tok: Token) -> str:
|
def _text(tok: Token) -> str:
|
||||||
return bind_initials(escape_latex(tok.content))
|
return render_marked(tok.content)
|
||||||
|
|
||||||
|
|
||||||
@inline("strong_open")
|
@inline("strong_open")
|
||||||
@@ -76,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")
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ 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*$")
|
||||||
|
|
||||||
# Длина слова, начиная с которой ему нужны точки разрыва. Идентификаторы вроде
|
|
||||||
# HackRFTestingServer:идентификатор не содержат пробелов и дефисов, поэтому в
|
|
||||||
# узкой колонке LaTeX не может их перенести: строка уезжает за границу колонки
|
|
||||||
# и печатается поверх соседней.
|
|
||||||
_LONG_WORD = 18
|
_LONG_WORD = 18
|
||||||
|
|
||||||
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
|
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
|
||||||
@@ -58,18 +54,10 @@ def _breakable(cell: str) -> str:
|
|||||||
Ячейка уже отрендерена в LaTeX, поэтому слова с обратной косой чертой или
|
Ячейка уже отрендерена в LaTeX, поэтому слова с обратной косой чертой или
|
||||||
фигурными скобками пропускаются: вставка внутрь имени команды сломала бы её.
|
фигурными скобками пропускаются: вставка внутрь имени команды сломала бы её.
|
||||||
"""
|
"""
|
||||||
words = []
|
return " ".join(
|
||||||
for word in cell.split(" "):
|
word if any(ch in word for ch in "\\{}") else allow_breaks(word)
|
||||||
if len(word) < _LONG_WORD or any(ch in word for ch in "\\{}"):
|
for word in cell.split(" ")
|
||||||
words.append(word)
|
)
|
||||||
continue
|
|
||||||
out = []
|
|
||||||
for ch in word:
|
|
||||||
out.append(ch)
|
|
||||||
if ch in _BREAK_AFTER:
|
|
||||||
out.append("\\allowbreak ")
|
|
||||||
words.append("".join(out))
|
|
||||||
return " ".join(words)
|
|
||||||
|
|
||||||
|
|
||||||
def _cell_align(tok: Token) -> str:
|
def _cell_align(tok: Token) -> str:
|
||||||
@@ -180,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))
|
||||||
@@ -191,7 +179,7 @@ def captioned_table(tokens: list[Token]) -> str:
|
|||||||
|
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
"\\begin{table}[ht]",
|
"\\begin{table}[htb]",
|
||||||
f" \\caption{{{caption}}}",
|
f" \\caption{{{caption}}}",
|
||||||
" \\centering",
|
" \\centering",
|
||||||
_tabular(rows, aligns),
|
_tabular(rows, aligns),
|
||||||
|
|||||||
+11
-15
@@ -33,20 +33,16 @@ def abbreviations(text: str) -> str:
|
|||||||
if not rows:
|
if not rows:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
max_abbr = max(len(r[0]) for r in rows)
|
# Колонка l сама берёт ширину самой длинной аббревиатуры, поэтому тире
|
||||||
abbr_col = max_abbr + 2
|
# выстраиваются в столбец без подсчёта ширин на глаз, а отбивка до тире
|
||||||
tabular_rows = []
|
# остаётся обычным пробелом. Расшифровка целиком лежит в колонке X, и её
|
||||||
for abbr, desc in rows:
|
# перенос встаёт под первым словом расшифровки, а не под тире.
|
||||||
pad = max_abbr - len(abbr) + 2
|
body = "\n".join(f"{abbr} & {desc} \\\\" for abbr, desc in rows)
|
||||||
tabular_rows.append(
|
|
||||||
f"{abbr} {' ' * pad}& \\hangafter=1\\hangindent=2em --- {desc} \\\\"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Отбивки колонок убраны с обеих сторон и между ними: иначе сумма ширин
|
# Отбивки колонок убраны с обоих краёв: иначе сумма ширин превышает
|
||||||
# превышает \linewidth на 2\tabcolsep и перечень выходит за правое поле.
|
# \linewidth на 2\tabcolsep и перечень выходит за правое поле.
|
||||||
col_spec = (
|
return (
|
||||||
f"@{{\\hspace{{0pt}}}}p{{{abbr_col}em}}"
|
"\\noindent\\begin{tabularx}{\\linewidth}{@{}l@{\\ ---\\ }X@{}}\n"
|
||||||
f"@{{}}p{{\\dimexpr\\linewidth-{abbr_col}em\\relax}}@{{\\hspace{{0pt}}}}"
|
f"{body}\n"
|
||||||
|
"\\end{tabularx}"
|
||||||
)
|
)
|
||||||
body = "\n".join(tabular_rows)
|
|
||||||
return f"\\noindent\\begin{{tabular}}{{{col_spec}}}\n{body}\n\\end{{tabular}}"
|
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ def parse_log(
|
|||||||
|
|
||||||
|
|
||||||
def _describe(diag: Diagnostic) -> str:
|
def _describe(diag: Diagnostic) -> str:
|
||||||
where = f"строка {diag.line}" if diag.line is not None else "место не указано"
|
where = (
|
||||||
|
f"абзац в строке {diag.line}" if diag.line is not None else "место не указано"
|
||||||
|
)
|
||||||
if diag.kind == "overfull":
|
if diag.kind == "overfull":
|
||||||
return f" {where}: на {diag.amount:.1f} pt шире поля"
|
return f" {where}: на {diag.amount:.1f} pt шире поля"
|
||||||
if diag.kind == "underfull":
|
if diag.kind == "underfull":
|
||||||
|
|||||||
@@ -15,9 +15,22 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\graphicspath{ {./} {./images/} }
|
\graphicspath{ {./} {./images/} }
|
||||||
\usepackage{float}
|
\usepackage{float}
|
||||||
\usepackage[section]{placeins}
|
\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 не используется намеренно: он снимает штраф за разреженные строки
|
||||||
\setlength{\emergencystretch}{2em}
|
% целиком, и абзац с длинным неразрывным словом разгоняется пробелами вдвое
|
||||||
|
% шире нормальных. \emergencystretch включается только там, где иначе строка
|
||||||
|
% вышла бы за поле.
|
||||||
|
\setlength{\emergencystretch}{3em}
|
||||||
\clubpenalty=10000
|
\clubpenalty=10000
|
||||||
\widowpenalty=10000
|
\widowpenalty=10000
|
||||||
\usepackage{enumitem}
|
\usepackage{enumitem}
|
||||||
@@ -27,15 +40,20 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\usepackage{hyperref}
|
\usepackage{hyperref}
|
||||||
\hypersetup {unicode=true}
|
\hypersetup {unicode=true}
|
||||||
\DeclareCaptionLabelSeparator*{emdash}{~--- }
|
\DeclareCaptionLabelSeparator*{emdash}{~--- }
|
||||||
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок}
|
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок,justification=centering}
|
||||||
\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright}
|
\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright}
|
||||||
\usepackage{listings}
|
\usepackage{listings}
|
||||||
\usepackage{xcolor}
|
\usepackage{xcolor}
|
||||||
|
|
||||||
|
% Пометка правок: ==текст== в Markdown. Выделяется каждое слово отдельно,
|
||||||
|
% иначе строка не разорвётся по границе выделения. soul не подошёл: под
|
||||||
|
% XeLaTeX его \hl теряет кириллицу. Команда объявлена устойчивой — она
|
||||||
|
% попадает в заголовки, а оттуда в содержание.
|
||||||
|
\DeclareRobustCommand{\hlw}[1]{{\setlength{\fboxsep}{1pt}\colorbox{yellow}{#1}}}
|
||||||
|
|
||||||
% Палитра подсветки. Тона различаются не только цветом, но и светлотой,
|
% Палитра подсветки. Тона различаются не только цветом, но и светлотой,
|
||||||
% поэтому листинг остаётся читаемым и на чёрно-белой печати.
|
% поэтому листинг остаётся читаемым и на чёрно-белой печати.
|
||||||
\definecolor{lstkeyword}{RGB}{0,0,139}
|
\definecolor{lstkeyword}{RGB}{0,0,139}
|
||||||
\definecolor{lstcomment}{RGB}{110,110,110}
|
|
||||||
\definecolor{lststring}{RGB}{139,0,0}
|
\definecolor{lststring}{RGB}{139,0,0}
|
||||||
\definecolor{lstlineno}{RGB}{130,130,130}
|
\definecolor{lstlineno}{RGB}{130,130,130}
|
||||||
|
|
||||||
@@ -54,9 +72,13 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\lstset{
|
\lstset{
|
||||||
basicstyle=\ttfamily\small\setstretch{1.0},
|
basicstyle=\ttfamily\small\setstretch{1.0},
|
||||||
keywordstyle=\bfseries\color{lstkeyword},
|
keywordstyle=\bfseries\color{lstkeyword},
|
||||||
% Начертание прямое: курсива кириллицы в моноширинном шрифте нет, и
|
% Комментарии не выделяются ни цветом, ни начертанием. listings применяет
|
||||||
% комментарий распадался бы на прямые русские и курсивные латинские слова.
|
% стиль только к символам своего алфавита, кириллица в него не входит:
|
||||||
commentstyle=\color{lstcomment},
|
% цветной commentstyle красит в смешанном комментарии одну латиницу, и текст
|
||||||
|
% рябит двумя цветами. extendedchars=true включает кириллицу в алфавит, но
|
||||||
|
% при этом теряет пробелы между русскими словами, а alsoletter с кириллицей
|
||||||
|
% не собирается вовсе. Однородный комментарий лучше пёстрого.
|
||||||
|
commentstyle=,
|
||||||
stringstyle=\color{lststring},
|
stringstyle=\color{lststring},
|
||||||
numberstyle=\scriptsize\color{lstlineno},
|
numberstyle=\scriptsize\color{lstlineno},
|
||||||
identifierstyle=,
|
identifierstyle=,
|
||||||
|
|||||||
+5
-2
@@ -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
@@ -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:
|
||||||
|
|||||||
+96
-7
@@ -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}[ht]" 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}[ht]" 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}[ht]" 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
|
||||||
@@ -473,13 +503,59 @@ def test_abbreviation_dash_is_single() -> None:
|
|||||||
"""Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного."""
|
"""Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного."""
|
||||||
tex = body(render(ABBR_MD))
|
tex = body(render(ABBR_MD))
|
||||||
assert "------" not in tex
|
assert "------" not in tex
|
||||||
assert "--- Software-Defined Radio" in tex
|
assert "SDR & Software-Defined Radio" in tex
|
||||||
|
|
||||||
|
|
||||||
def test_abbreviation_table_has_no_extra_column_padding() -> None:
|
def test_abbreviation_table_has_no_extra_column_padding() -> None:
|
||||||
"""Отбивка между колонками добавляется к \\linewidth и роняет строку за поле."""
|
"""Отбивка между колонками добавляется к \\linewidth и роняет строку за поле."""
|
||||||
tex = body(render(ABBR_MD))
|
tex = body(render(ABBR_MD))
|
||||||
assert "@{}p{\\dimexpr\\linewidth" in tex
|
assert "{@{}l@{\\ ---\\ }X@{}}" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_abbreviation_column_is_as_wide_as_the_abbreviation() -> None:
|
||||||
|
"""Колонка l берёт ширину по содержимому: до тире остаётся пробел."""
|
||||||
|
tex = body(render(ABBR_MD))
|
||||||
|
assert "p{" not in tex
|
||||||
|
assert "\\begin{tabularx}{\\linewidth}" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_appendix_starts_a_new_page_and_reaches_the_contents() -> None:
|
||||||
|
tex = render("# ПРИЛОЖЕНИЕ\n\nЛистинг 1 — Код\n\n```cpp\nint x;\n```\n")
|
||||||
|
assert "\\section{" not in tex
|
||||||
|
assert "\\clearpage" in tex
|
||||||
|
assert "\\addcontentsline{toc}{section}{ПРИЛОЖЕНИЕ}" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_marked_unnumbered_heading_is_still_recognised() -> None:
|
||||||
|
"""В «==ПРИЛОЖЕНИЕ==» имя раздела не должно теряться за разметкой."""
|
||||||
|
tex = render("# ==ПРИЛОЖЕНИЕ==\n\nТекст.\n")
|
||||||
|
assert "\\section{" not in tex
|
||||||
|
assert "\\clearpage" in tex
|
||||||
|
assert "\\hlw{ПРИЛОЖЕНИЕ}" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_listing_does_not_float() -> None:
|
||||||
|
"""Листинг длиннее страницы не помещается в плавающий объект."""
|
||||||
|
code = "\n".join(f"int x{i};" for i in range(PAGE_LINES + 1))
|
||||||
|
tex = body(render(f"Листинг 1 — Длинный\n\n```cpp\n{code}\n```\n"))
|
||||||
|
assert "caption={Длинный}" in tex
|
||||||
|
assert "float=" not in tex
|
||||||
|
assert "frame=none" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_in_appendix_does_not_float() -> None:
|
||||||
|
"""В приложении плавающий объект переставил бы листинги местами."""
|
||||||
|
tex = body(render(
|
||||||
|
"# ПРИЛОЖЕНИЕ\n\nЛистинг 1 — Короткий\n\n```cpp\nint x;\n```\n"
|
||||||
|
))
|
||||||
|
assert "float=" not in tex
|
||||||
|
assert "frame=none" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_listing_keeps_the_frame() -> None:
|
||||||
|
tex = body(render("Листинг 1 — Короткий\n\n```cpp\nint x;\n```\n"))
|
||||||
|
assert "float=htbp" in tex
|
||||||
|
assert "frame=none" not in tex
|
||||||
|
|
||||||
|
|
||||||
def test_cpp_uses_extended_dialect() -> None:
|
def test_cpp_uses_extended_dialect() -> None:
|
||||||
@@ -501,11 +577,24 @@ def test_bare_listing_does_not_float() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_initials_are_not_split_across_lines() -> None:
|
def test_initials_are_not_split_across_lines() -> None:
|
||||||
"""«/ И.» на одной строке и «В. Гилев» на следующей — ошибка оформления."""
|
"""«/ И.» на одной строке и «В.» на следующей — ошибка оформления."""
|
||||||
tex = body(render("Гилев, И. В. Использование технологии SDR.\n"))
|
tex = body(render("Гилев, И. В. Использование технологии SDR.\n"))
|
||||||
assert "И.~В.~Использование" in tex
|
assert "И.~В. Использование" in tex
|
||||||
|
|
||||||
|
|
||||||
def test_lowercase_abbreviation_is_left_alone() -> None:
|
def test_lowercase_abbreviation_is_left_alone() -> None:
|
||||||
tex = body(render("В 3 т., Воронеж, 2025.\n"))
|
tex = body(render("В 3 т., Воронеж, 2025.\n"))
|
||||||
assert "т.~Воронеж" not in tex
|
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