Files
md2gost/md2gost/handlers/inline.py
T
Arseny b1d00a1fed [ADD] раздел ПРИЛОЖЕНИЕ и листинги длиннее страницы
ПРИЛОЖЕНИЕ добавлено к ненумерованным разделам с новой страницы и попадает
в содержание наравне с введением и заключением.

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

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

Имя ненумерованного раздела ищется в исходном тексте, а не в готовом LaTeX:
у выделенного «==ПРИЛОЖЕНИЕ==» готовый текст — это \hlw{ПРИЛОЖЕНИЕ}, и
раздел нумеровался как обычный.
2026-09-07 17:48:59 +03:00

217 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Инлайн-рендер: обработка токенов внутри абзацев и заголовков.
Диспетчеризация табличная (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)