[FIX] разреженные строки: переносы в составных словах и в инлайн-коде

This commit is contained in:
Arseny
2026-09-02 13:45:00 +03:00
parent 7c518cd59d
commit beac1d5a7d
5 changed files with 68 additions and 29 deletions
+40 -7
View File
@@ -33,11 +33,43 @@ def escape_latex(text: str) -> str:
return text.translate(_LATEX_SPECIAL)
# Инициалы не должны отрываться друг от друга и от фамилии переносом строки:
# «/ И.» на одной строке и «В. Гилев» на следующей — ошибка оформления списка
# источников. Связывается одиночная заглавная буква с точкой и следующее слово,
# начинающееся с заглавной.
_INITIALS_RE = re.compile(r"(?<![\w.])([A-ZА-ЯЁ]\.)\s+(?=[A-ZА-ЯЁ])")
# Длина слова, начиная с которой ему нужны точки переноса, и символы, после
# которых переносить допустимо. Неразрывный идентификатор вроде
# 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:
@@ -51,7 +83,7 @@ def bind_initials(text: str) -> str:
@inline("text")
def _text(tok: Token) -> str:
return bind_initials(escape_latex(tok.content))
return split_compounds(bind_initials(escape_latex(tok.content)))
@inline("strong_open")
@@ -76,7 +108,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")
+5 -17
View File
@@ -28,15 +28,11 @@ 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*$")
# Длина слова, начиная с которой ему нужны точки разрыва. Идентификаторы вроде
# HackRFTestingServer:идентификатор не содержат пробелов и дефисов, поэтому в
# узкой колонке LaTeX не может их перенести: строка уезжает за границу колонки
# и печатается поверх соседней.
_LONG_WORD = 18
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
@@ -58,18 +54,10 @@ def _breakable(cell: str) -> str:
Ячейка уже отрендерена в LaTeX, поэтому слова с обратной косой чертой или
фигурными скобками пропускаются: вставка внутрь имени команды сломала бы её.
"""
words = []
for word in cell.split(" "):
if len(word) < _LONG_WORD or any(ch in word for ch in "\\{}"):
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)
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:
+3 -1
View File
@@ -99,7 +99,9 @@ def parse_log(
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":
return f" {where}: на {diag.amount:.1f} pt шире поля"
if diag.kind == "underfull":
+5 -2
View File
@@ -26,8 +26,11 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\setcounter{bottomnumber}{2}
\setcounter{totalnumber}{5}
\usepackage{wrapfig}
\sloppy
\setlength{\emergencystretch}{2em}
% \sloppy не используется намеренно: он снимает штраф за разреженные строки
% целиком, и абзац с длинным неразрывным словом разгоняется пробелами вдвое
% шире нормальных. \emergencystretch включается только там, где иначе строка
% вышла бы за поле.
\setlength{\emergencystretch}{3em}
\clubpenalty=10000
\widowpenalty=10000
\usepackage{enumitem}
+15 -2
View File
@@ -501,11 +501,24 @@ def test_bare_listing_does_not_float() -> None:
def test_initials_are_not_split_across_lines() -> None:
"""«/ И.» на одной строке и «В. Гилев» на следующей — ошибка оформления."""
"""«/ И.» на одной строке и «В.» на следующей — ошибка оформления."""
tex = body(render("Гилев, И. В. Использование технологии SDR.\n"))
assert "И.~В.~Использование" in tex
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