[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) 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:
@@ -51,7 +83,7 @@ def bind_initials(text: str) -> str:
@inline("text") @inline("text")
def _text(tok: Token) -> str: def _text(tok: Token) -> str:
return bind_initials(escape_latex(tok.content)) return split_compounds(bind_initials(escape_latex(tok.content)))
@inline("strong_open") @inline("strong_open")
@@ -76,7 +108,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")
+5 -17
View File
@@ -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:
+3 -1
View File
@@ -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":
+5 -2
View File
@@ -26,8 +26,11 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
\setcounter{bottomnumber}{2} \setcounter{bottomnumber}{2}
\setcounter{totalnumber}{5} \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}
+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: 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