Files
md2gost/md2gost/handlers/image.py
T

107 lines
4.1 KiB
Python

"""Обработка рисунков по ГОСТ.
Синтаксис в Markdown — вики-ссылка отдельным абзацем, подпись — абзац
под ней (см. parser._preprocess_wiki_images):
![[images/antenna.png]]
Рисунок 1 —— Антенна
Правила:
- подпись распознаётся по шаблону «Рисунок N <тире> Название»; номер
отбрасывается — LaTeX нумерует рисунки сам (последовательность
гарантируется);
- абзац с картинкой и посторонним текстом не является рисунком: инлайн-
картинка внутри текста пропускается с предупреждением;
- ширина задаётся суффиксом ``|N`` или ``|N%`` (доля от textwidth),
по умолчанию 80 %;
- размещение ``[ht]``, а не ``[H]``: жёсткая привязка к месту оставляла до
половины страницы пустой, когда рисунок не помещался в её остаток. ГОСТ
допускает перенос иллюстрации на следующую страницу.
Паттерн чистый: match() смотрит на срез токенов, render_figure() строит
LaTeX из того же среза; renderer.pos двигает рендерер.
"""
from __future__ import annotations
import re
from urllib.parse import unquote
from markdown_it.token import Token
from md2gost.handlers.inline import escape_latex
from md2gost.registry import pattern
DEFAULT_WIDTH = 0.8
_CAPTION_RE = re.compile(r"^\s*Рисунок\s+\d+\s*[—–-]+\s*(.+?)\s*$")
_WIDTH_RE = re.compile(r"^\d{1,3}%?$")
def _parse_image_src(raw: str) -> tuple[str, str]:
"""Разобрать 'путь|ширина' -> (путь, LaTeX-опция ширины)."""
path, sep, width = raw.rpartition("|")
if sep and _WIDTH_RE.match(width.strip()):
fraction = int(width.strip().rstrip("%")) / 100
return path, f"{fraction:g}\\textwidth"
return raw, f"{DEFAULT_WIDTH:g}\\textwidth"
def _single_image_paragraph(tokens: list[Token], pos: int) -> Token | None:
"""Inline-токен, если абзац на pos состоит ровно из одной картинки."""
if pos + 2 >= len(tokens):
return None
if tokens[pos].type != "paragraph_open" or tokens[pos + 1].type != "inline":
return None
children = tokens[pos + 1].children or []
images = [t for t in children if t.type == "image"]
if len(images) != 1:
return None
if any(
t.type not in ("image", "text", "softbreak") or t.content.strip()
for t in children
):
return None
return images[0]
def match_figure(tokens: list[Token], pos: int) -> int | None:
"""Абзац-картинка (+2 за подпись, если следующий абзац её содержит)."""
img_tok = _single_image_paragraph(tokens, pos)
if img_tok is None:
return None
caption_pos = pos + 3
if (
caption_pos + 2 < len(tokens)
and tokens[caption_pos].type == "paragraph_open"
and tokens[caption_pos + 1].type == "inline"
and _CAPTION_RE.match(tokens[caption_pos + 1].content)
):
return 5
return 3
@pattern(match_figure)
def figure(tokens: list[Token]) -> str:
img_tok = _single_image_paragraph(tokens, 0)
assert img_tok is not None
raw_src = unquote(str(img_tok.attrGet("src") or ""))
path, width = _parse_image_src(raw_src)
caption = ""
if len(tokens) >= 5:
m = _CAPTION_RE.match(tokens[4].content)
if m:
caption = escape_latex(m.group(1))
lines = [
"\\begin{figure}[htb]",
" \\centering",
rf" \includegraphics[width={width}]{{\detokenize{{{path}}}}}",
]
if caption:
lines.append(f" \\caption{{{caption}}}")
lines.append("\\end{figure}")
return "\n".join(lines)