diff --git a/md2gost/handlers/inline.py b/md2gost/handlers/inline.py index 8016ab4..d1b5aff 100644 --- a/md2gost/handlers/inline.py +++ b/md2gost/handlers/inline.py @@ -33,11 +33,43 @@ def escape_latex(text: str) -> str: return text.translate(_LATEX_SPECIAL) -# Инициалы не должны отрываться друг от друга и от фамилии переносом строки: -# «/ И.» на одной строке и «В. Гилев» на следующей — ошибка оформления списка -# источников. Связывается одиночная заглавная буква с точкой и следующее слово, -# начинающееся с заглавной. -_INITIALS_RE = re.compile(r"(? 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"(? 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") diff --git a/md2gost/handlers/table.py b/md2gost/handlers/table.py index 703f6f5..a227c3a 100644 --- a/md2gost/handlers/table.py +++ b/md2gost/handlers/table.py @@ -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: diff --git a/md2gost/latex/log.py b/md2gost/latex/log.py index 8b14ed0..28974d3 100644 --- a/md2gost/latex/log.py +++ b/md2gost/latex/log.py @@ -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": diff --git a/md2gost/latex/preamble.py b/md2gost/latex/preamble.py index 4b1471e..c41f3a6 100644 --- a/md2gost/latex/preamble.py +++ b/md2gost/latex/preamble.py @@ -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} diff --git a/tests/test_render.py b/tests/test_render.py index ec527c1..52e17b4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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