b1d00a1fed
ПРИЛОЖЕНИЕ добавлено к ненумерованным разделам с новой страницы и попадает
в содержание наравне с введением и заключением.
Листинг длиннее страницы больше не оборачивается в плавающий объект: LaTeX
не смог бы его разместить и вынес бы в конец документа. Рамка у такого
листинга снимается — она всё равно обрывается на границе страницы.
В приложении не плавает ни один листинг независимо от длины: там они идут
подряд и без текста вокруг, а плавающий объект переставлял короткий листинг
за длинный и ломал порядок. Чтобы обработчик знал раздел, составные
обработчики теперь получают рендерер — как и блочные.
Имя ненумерованного раздела ищется в исходном тексте, а не в готовом LaTeX:
у выделенного «==ПРИЛОЖЕНИЕ==» готовый текст — это \hlw{ПРИЛОЖЕНИЕ}, и
раздел нумеровался как обычный.
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""Инлайн-рендер: обработка токенов внутри абзацев и заголовков.
|
||
|
||
Диспетчеризация табличная (registry.INLINE): тип токена -> функция
|
||
``(tok) -> str``. Новый инлайн-узел = одна функция с декоратором.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from collections.abc import Callable
|
||
|
||
from markdown_it.token import Token
|
||
|
||
from md2gost.registry import INLINE, inline
|
||
|
||
_LATEX_SPECIAL = str.maketrans(
|
||
{
|
||
"&": r"\&",
|
||
"%": r"\%",
|
||
"$": r"\$",
|
||
"#": r"\#",
|
||
"_": r"\_",
|
||
"{": r"\{",
|
||
"}": r"\}",
|
||
"~": r"\textasciitilde{}",
|
||
"^": r"\textasciicircum{}",
|
||
"\\": r"\textbackslash{}",
|
||
}
|
||
)
|
||
|
||
|
||
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 render_marked(tok.content)
|
||
|
||
|
||
@inline("strong_open")
|
||
def _strong_open(tok: Token) -> str:
|
||
return r"\textbf{"
|
||
|
||
|
||
@inline("strong_close")
|
||
def _strong_close(tok: Token) -> str:
|
||
return "}"
|
||
|
||
|
||
@inline("em_open")
|
||
def _em_open(tok: Token) -> str:
|
||
return r"\textit{"
|
||
|
||
|
||
@inline("em_close")
|
||
def _em_close(tok: Token) -> str:
|
||
return "}"
|
||
|
||
|
||
@inline("code_inline")
|
||
def _code_inline(tok: Token) -> str:
|
||
content = " ".join(allow_breaks(w) for w in escape_latex(tok.content).split(" "))
|
||
return f"\\texttt{{\\small {content}}}"
|
||
|
||
|
||
@inline("link_open")
|
||
def _link_open(tok: Token) -> str:
|
||
href = str(tok.attrGet("href") or "")
|
||
return f"\\href{{{escape_latex(href)}}}{{"
|
||
|
||
|
||
@inline("link_close")
|
||
def _link_close(tok: Token) -> str:
|
||
return "}"
|
||
|
||
|
||
@inline("softbreak")
|
||
@inline("hardbreak")
|
||
def _break(tok: Token) -> str:
|
||
return "~\\\\"
|
||
|
||
|
||
@inline("math_inline")
|
||
def _math_inline(tok: Token) -> str:
|
||
return f"${tok.content}$"
|
||
|
||
|
||
@inline("image")
|
||
def _image_in_text(tok: Token) -> str:
|
||
print(
|
||
"предупреждение: картинка внутри текста пропущена "
|
||
"(по ГОСТ рисунок оформляется отдельным абзацем)",
|
||
file=sys.stderr,
|
||
)
|
||
return ""
|
||
|
||
|
||
def render_inline(tok: Token) -> str:
|
||
if not tok.children:
|
||
return escape_latex(tok.content or "")
|
||
return render_inline_tokens(tok.children)
|
||
|
||
|
||
def render_inline_tokens(tokens: list[Token]) -> str:
|
||
parts: list[str] = []
|
||
for tok in tokens:
|
||
handler = INLINE.get(tok.type)
|
||
if handler is not None:
|
||
parts.append(handler(tok))
|
||
else:
|
||
parts.append(escape_latex(tok.content) if tok.content else "")
|
||
return "".join(parts)
|