diff --git a/md2gost/handlers/__init__.py b/md2gost/handlers/__init__.py index 240247c..b8f634a 100644 --- a/md2gost/handlers/__init__.py +++ b/md2gost/handlers/__init__.py @@ -4,7 +4,7 @@ from md2gost.handlers.hr import hr from md2gost.handlers.image import figure, match_figure from md2gost.handlers.lists import bullet_list, ordered_list from md2gost.handlers.math import math_block -from md2gost.handlers.table import table +from md2gost.handlers.table import captioned_table, match_table, table from md2gost.handlers.text import paragraph __all__ = [ @@ -17,5 +17,7 @@ __all__ = [ "math_block", "ordered_list", "table", + "captioned_table", + "match_table", "paragraph", ] diff --git a/md2gost/handlers/table.py b/md2gost/handlers/table.py index 3cd9e5f..b519cf3 100644 --- a/md2gost/handlers/table.py +++ b/md2gost/handlers/table.py @@ -1,53 +1,144 @@ +"""Обработка таблиц по ГОСТ. + +Подпись оформляется абзацем НАД таблицей (в отличие от рисунка, у которого +подпись снизу): + + Таблица 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 render_inline -from md2gost.registry import block +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*$") + +_ALIGN_BY_STYLE = { + "text-align:left": "l", + "text-align:center": "c", + "text-align:right": "r", +} -@block("table_open") -def table(r, tok: Token) -> str: +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]] = [] - r.pos += 1 - while r.pos < len(r.tokens): - tok = r.tokens[r.pos] + aligns: list[str] = [] + pos += 1 + while pos < len(tokens): + tok = tokens[pos] if tok.type == "table_close": - r.pos += 1 + pos += 1 break - if tok.type in ("thead_open", "tbody_open"): - r.pos += 1 + if tok.type != "tr_open": + pos += 1 continue - if tok.type == "tr_open": - row: list[str] = [] - r.pos += 1 - while r.pos < len(r.tokens): - ctok = r.tokens[r.pos] - if ctok.type == "tr_close": - r.pos += 1 - break - if ctok.type in ("th_open", "td_open"): - inline = r.tokens[r.pos + 1] - row.append(render_inline(inline)) - r.pos += 3 - else: - r.pos += 1 - rows.append(row) - else: - r.pos += 1 + 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(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 - if not rows: - return "" +def _tabular(rows: list[list[str]], aligns: list[str]) -> str: cols = max(len(row) for row in rows) for row in rows: row.extend([""] * (cols - len(row))) - col_spec = "|".join(["l"] * cols) + spec = list(aligns[:cols]) + ["l"] * max(0, cols - len(aligns)) + col_spec = "|".join(spec) lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"] for row in rows: - line = " & ".join(row) + " \\\\" - lines.append(line) + lines.append(" & ".join(row) + " \\\\") lines.append("\\hline") lines.append("\\end{tabular}") 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}[H]", + f" \\caption{{{caption}}}", + " \\centering", + _tabular(rows, aligns), + "\\end{table}", + ] + ) diff --git a/md2gost/latex/preamble.py b/md2gost/latex/preamble.py index 950dda7..8918d3d 100644 --- a/md2gost/latex/preamble.py +++ b/md2gost/latex/preamble.py @@ -26,6 +26,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle} \hypersetup {unicode=true} \DeclareCaptionLabelSeparator*{emdash}{~--- } \captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок} +\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright} \usepackage{listings} \lstset{ basicstyle=\ttfamily\footnotesize, diff --git a/md2gost/render.py b/md2gost/render.py index 81c638e..b25c6c3 100644 --- a/md2gost/render.py +++ b/md2gost/render.py @@ -22,6 +22,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage # Импорт обработчиков регистрирует их в реестрах. from md2gost.handlers import ( # noqa: F401 bullet_list, + captioned_table, fence, figure, heading, diff --git a/tests/test_render.py b/tests/test_render.py index cd604f4..f1e27bb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -33,7 +33,7 @@ def test_figure_with_caption(tmp_path) -> None: "![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n" "Данная антенна используется для ...\n" ) - assert "\\begin{figure}[ht]" in tex + assert "\\begin{figure}[H]" in tex assert "\\centering" in tex assert ( "\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}" @@ -45,7 +45,7 @@ def test_figure_with_caption(tmp_path) -> None: def test_figure_without_caption() -> None: tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n") - assert "\\begin{figure}[ht]" in tex + assert "\\begin{figure}[H]" in tex assert "\\caption{" not in tex assert "Обычный абзац после картинки." in tex @@ -100,3 +100,42 @@ def _figure_block(tex: str) -> str: start = tex.index("\\begin{figure}") end = tex.index("\\end{figure}") + len("\\end{figure}") return tex[start:end] + + +TABLE_MD = ( + "Таблица 1 —— Сравнение скорости\n\n" + "| № Запуска | Python (сек) | C++ (сек) |\n" + "| :--- | :---: | ---: |\n" + "| **1** | 0.027852 | 0.000285 |\n" +) + + +def test_table_with_caption() -> None: + tex = render(TABLE_MD) + assert "\\begin{table}[H]" in tex + assert "\\caption{Сравнение скорости}" in tex + assert "\\begin{tabular}{|l|c|r|}" in tex + assert "\\textbf{1} & 0.027852 & 0.000285 \\\\" in tex + + +def test_table_caption_number_is_discarded() -> None: + tex = render(TABLE_MD) + block = _table_block(tex) + assert "Таблица 1" not in block + + +def test_table_without_caption_stays_bare() -> None: + tex = render("| a | b |\n| --- | --- |\n| 1 | 2 |\n") + assert "\\begin{table}" not in tex + assert "\\begin{tabular}{|l|l|}" in tex + + +def test_table_caption_paragraph_is_not_duplicated() -> None: + tex = render(TABLE_MD) + assert tex.count("Сравнение скорости") == 1 + + +def _table_block(tex: str) -> str: + start = tex.index("\\begin{table}") + end = tex.index("\\end{table}") + len("\\end{table}") + return tex[start:end]