[ADD] таблицы по ГОСТ: подпись сверху и выравнивание колонок
Подпись «Таблица N —— Название» абзацем над таблицей превращается в float table[H] с \caption сверху (labelsep=emdash, name=Таблица, выключка влево), номер отбрасывается — нумерует LaTeX. Выравнивание колонок берётся из строки-разделителя GFM: :--- , :---: , ---: . Таблица без подписи по-прежнему рендерится голым tabular.
This commit is contained in:
@@ -4,7 +4,7 @@ from md2gost.handlers.hr import hr
|
|||||||
from md2gost.handlers.image import figure, match_figure
|
from md2gost.handlers.image import figure, match_figure
|
||||||
from md2gost.handlers.lists import bullet_list, ordered_list
|
from md2gost.handlers.lists import bullet_list, ordered_list
|
||||||
from md2gost.handlers.math import math_block
|
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
|
from md2gost.handlers.text import paragraph
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -17,5 +17,7 @@ __all__ = [
|
|||||||
"math_block",
|
"math_block",
|
||||||
"ordered_list",
|
"ordered_list",
|
||||||
"table",
|
"table",
|
||||||
|
"captioned_table",
|
||||||
|
"match_table",
|
||||||
"paragraph",
|
"paragraph",
|
||||||
]
|
]
|
||||||
|
|||||||
+123
-32
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token
|
||||||
|
|
||||||
from md2gost.handlers.inline import render_inline
|
from md2gost.handlers.inline import escape_latex, render_inline
|
||||||
from md2gost.registry import block
|
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 _cell_align(tok: Token) -> str:
|
||||||
def table(r, 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]] = []
|
rows: list[list[str]] = []
|
||||||
r.pos += 1
|
aligns: list[str] = []
|
||||||
while r.pos < len(r.tokens):
|
pos += 1
|
||||||
tok = r.tokens[r.pos]
|
while pos < len(tokens):
|
||||||
|
tok = tokens[pos]
|
||||||
if tok.type == "table_close":
|
if tok.type == "table_close":
|
||||||
r.pos += 1
|
pos += 1
|
||||||
break
|
break
|
||||||
if tok.type in ("thead_open", "tbody_open"):
|
if tok.type != "tr_open":
|
||||||
r.pos += 1
|
pos += 1
|
||||||
continue
|
continue
|
||||||
if tok.type == "tr_open":
|
row: list[str] = []
|
||||||
row: list[str] = []
|
row_aligns: list[str] = []
|
||||||
r.pos += 1
|
pos += 1
|
||||||
while r.pos < len(r.tokens):
|
while pos < len(tokens):
|
||||||
ctok = r.tokens[r.pos]
|
ctok = tokens[pos]
|
||||||
if ctok.type == "tr_close":
|
if ctok.type == "tr_close":
|
||||||
r.pos += 1
|
pos += 1
|
||||||
break
|
break
|
||||||
if ctok.type in ("th_open", "td_open"):
|
if ctok.type in ("th_open", "td_open"):
|
||||||
inline = r.tokens[r.pos + 1]
|
row.append(render_inline(tokens[pos + 1]))
|
||||||
row.append(render_inline(inline))
|
row_aligns.append(_cell_align(ctok))
|
||||||
r.pos += 3
|
pos += 3
|
||||||
else:
|
else:
|
||||||
r.pos += 1
|
pos += 1
|
||||||
rows.append(row)
|
if not aligns:
|
||||||
else:
|
aligns = row_aligns
|
||||||
r.pos += 1
|
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)
|
cols = max(len(row) for row in rows)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row.extend([""] * (cols - len(row)))
|
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"]
|
lines = [f"\\begin{{tabular}}{{|{col_spec}|}}", "\\hline"]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
line = " & ".join(row) + " \\\\"
|
lines.append(" & ".join(row) + " \\\\")
|
||||||
lines.append(line)
|
|
||||||
lines.append("\\hline")
|
lines.append("\\hline")
|
||||||
lines.append("\\end{tabular}")
|
lines.append("\\end{tabular}")
|
||||||
return "\n".join(lines)
|
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}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ PREAMBLE = r"""\documentclass[a4paper, 14pt]{extarticle}
|
|||||||
\hypersetup {unicode=true}
|
\hypersetup {unicode=true}
|
||||||
\DeclareCaptionLabelSeparator*{emdash}{~--- }
|
\DeclareCaptionLabelSeparator*{emdash}{~--- }
|
||||||
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок}
|
\captionsetup[figure]{labelsep=emdash,font=onehalfspacing,position=bottom,name=Рисунок}
|
||||||
|
\captionsetup[table]{labelsep=emdash,font=onehalfspacing,position=top,name=Таблица,singlelinecheck=false,justification=raggedright}
|
||||||
\usepackage{listings}
|
\usepackage{listings}
|
||||||
\lstset{
|
\lstset{
|
||||||
basicstyle=\ttfamily\footnotesize,
|
basicstyle=\ttfamily\footnotesize,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from md2gost.latex import END_DOCUMENT, PREAMBLE, render_titlepage
|
|||||||
# Импорт обработчиков регистрирует их в реестрах.
|
# Импорт обработчиков регистрирует их в реестрах.
|
||||||
from md2gost.handlers import ( # noqa: F401
|
from md2gost.handlers import ( # noqa: F401
|
||||||
bullet_list,
|
bullet_list,
|
||||||
|
captioned_table,
|
||||||
fence,
|
fence,
|
||||||
figure,
|
figure,
|
||||||
heading,
|
heading,
|
||||||
|
|||||||
+41
-2
@@ -33,7 +33,7 @@ def test_figure_with_caption(tmp_path) -> None:
|
|||||||
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
|
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
|
||||||
"Данная антенна используется для ...\n"
|
"Данная антенна используется для ...\n"
|
||||||
)
|
)
|
||||||
assert "\\begin{figure}[ht]" in tex
|
assert "\\begin{figure}[H]" in tex
|
||||||
assert "\\centering" in tex
|
assert "\\centering" in tex
|
||||||
assert (
|
assert (
|
||||||
"\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}"
|
"\\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:
|
def test_figure_without_caption() -> None:
|
||||||
tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n")
|
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 "\\caption{" not in tex
|
||||||
assert "Обычный абзац после картинки." in tex
|
assert "Обычный абзац после картинки." in tex
|
||||||
|
|
||||||
@@ -100,3 +100,42 @@ def _figure_block(tex: str) -> str:
|
|||||||
start = tex.index("\\begin{figure}")
|
start = tex.index("\\begin{figure}")
|
||||||
end = tex.index("\\end{figure}") + len("\\end{figure}")
|
end = tex.index("\\end{figure}") + len("\\end{figure}")
|
||||||
return tex[start:end]
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user