Files
md2gost/md2gost/handlers/table.py
T

201 lines
7.8 KiB
Python

"""Обработка таблиц по ГОСТ.
Подпись оформляется абзацем НАД таблицей (в отличие от рисунка, у которого
подпись снизу):
Таблица 1 —— Сравнение скорости
| № запуска | Python (сек) | C++ (сек) |
| :--- | :---: | :---: |
| 1 | 0.027852 | 0.000285 |
Правила:
- подпись распознаётся по шаблону «Таблица N <тире> Название»; номер
отбрасывается — LaTeX нумерует таблицы сам (последовательность
гарантируется);
- выравнивание колонок берётся из строки-разделителя GFM: ``:---`` — по
левому краю, ``:---:`` — по центру, ``---:`` — по правому; по умолчанию
по левому краю;
- таблица без подписи рендерится голым ``tabular``, как и раньше.
Паттерн чистый: match_table() смотрит на срез токенов, captioned_table()
строит LaTeX из того же среза; renderer.pos двигает рендерер.
"""
from __future__ import annotations
import re
from markdown_it.token import Token
from md2gost.handlers.inline import 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
# Колонка, где самое длинное значение короче этого, в растягивании не нуждается:
# отдав ей равную долю ширины, мы отнимаем место у колонок с текстом, и те
# начинают переноситься по одному слову.
_NARROW_CELL = 16
_BREAK_AFTER = ":/_.-"
_ALIGN_BY_STYLE = {
"text-align:left": "l",
"text-align:center": "c",
"text-align:right": "r",
}
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)
def _cell_align(tok: Token) -> str:
style = str(tok.attrGet("style") or "").replace(" ", "")
return _ALIGN_BY_STYLE.get(style, "l")
def _collect_rows(
tokens: list[Token], pos: int
) -> tuple[int, list[list[str]], list[str]]:
"""Разобрать table_open..table_close -> (позиция за таблицей, строки, выравнивания)."""
rows: list[list[str]] = []
aligns: list[str] = []
pos += 1
while pos < len(tokens):
tok = tokens[pos]
if tok.type == "table_close":
pos += 1
break
if tok.type != "tr_open":
pos += 1
continue
row: list[str] = []
row_aligns: list[str] = []
pos += 1
while pos < len(tokens):
ctok = tokens[pos]
if ctok.type == "tr_close":
pos += 1
break
if ctok.type in ("th_open", "td_open"):
row.append(_breakable(render_inline(tokens[pos + 1])))
row_aligns.append(_cell_align(ctok))
pos += 3
else:
pos += 1
if not aligns:
aligns = row_aligns
rows.append(row)
return pos, rows, aligns
_STRETCH = ">{\\raggedright\\arraybackslash}X"
def _tabular(rows: list[list[str]], aligns: list[str]) -> str:
"""Табличное окружение для строк с заданным выравниванием колонок.
Колонка, выровненная по левому краю, считается текстовой: её содержимое
должно переноситься по словам, иначе широкая таблица уезжает за правое
поле страницы. Такие колонки верстаются растягиваемым типом ``X``, а вся
таблица — окружением ``tabularx`` шириной в текстовое поле. Колонки по
центру и по правому краю (обычно числовые) остаются узкими.
Если текстовых колонок нет, растягивать нечего и используется обычный
``tabular``: ``tabularx`` без единой ``X``-колонки не собирается.
"""
cols = max(len(row) for row in rows)
for row in rows:
row.extend([""] * (cols - len(row)))
spec = list(aligns[:cols]) + ["l"] * max(0, cols - len(aligns))
widths = [max(len(row[i]) for row in rows) for i in range(cols)]
stretch = [
align == "l" and width >= _NARROW_CELL for align, width in zip(spec, widths)
]
stretchable = any(stretch)
col_spec = "|".join(
_STRETCH if is_wide else col for col, is_wide in zip(spec, stretch)
)
env = "tabularx" if stretchable else "tabular"
width = "{\\textwidth}" if stretchable else ""
lines = [f"\\begin{{{env}}}{width}{{|{col_spec}|}}", "\\hline"]
for row in rows:
lines.append(" & ".join(row) + " \\\\")
lines.append("\\hline")
lines.append(f"\\end{{{env}}}")
return "\n".join(lines)
@block("table_open")
def table(r, tok: Token) -> str:
"""Таблица без подписи — голый tabular."""
r.pos, rows, aligns = _collect_rows(r.tokens, r.pos)
if not rows:
return ""
return _tabular(rows, aligns)
def match_table(tokens: list[Token], pos: int) -> int | None:
"""Абзац «Таблица N —— Название» + непосредственно следующая таблица."""
if pos + 3 >= len(tokens):
return None
if (
tokens[pos].type != "paragraph_open"
or tokens[pos + 1].type != "inline"
or tokens[pos + 2].type != "paragraph_close"
or tokens[pos + 3].type != "table_open"
):
return None
if not _CAPTION_RE.match(tokens[pos + 1].content):
return None
for i in range(pos + 4, len(tokens)):
if tokens[i].type == "table_close":
return i - pos + 1
return None
@pattern(match_table)
def captioned_table(tokens: list[Token]) -> str:
m = _CAPTION_RE.match(tokens[1].content)
assert m is not None
caption = escape_latex(m.group(1))
_, rows, aligns = _collect_rows(tokens, 3)
if not rows:
return ""
return "\n".join(
[
"\\begin{table}[ht]",
f" \\caption{{{caption}}}",
" \\centering",
_tabular(rows, aligns),
"\\end{table}",
]
)