Files
Igor20264 516abe7b83
Python application / build (push) Has been cancelled
BigUpdate
2026-09-03 10:44:08 +03:00

354 lines
12 KiB
Python

"""Convert md2gost markdown AST → LaTeX body (content.tex)."""
from __future__ import annotations
import os
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from md2gost.extended_markdown import markdown
from md2gost.diagram_renderer import DIAGRAM_LANGS
from md2gost.profiles import preprocess_markdown
from .escape import escape_text, escape_verbatim_for_listing, latex_label
@dataclass
class EmitState:
lines: list[str] = field(default_factory=list)
pending_caption: tuple[str | None, str | None, bool] | None = None # id, text, +listing
biblio: list[tuple[str, str]] = field(default_factory=list)
images_dir: Path | None = None
work_dir: Path | None = None
skip_toc_heading: bool = False
def w(self, s: str = "") -> None:
self.lines.append(s)
def _inline_text(children) -> str:
parts: list[str] = []
for child in children or []:
t = type(child).__name__
if t in ("RawText", "Literal"):
parts.append(escape_text(getattr(child, "children", "") or ""))
elif t == "CodeSpan":
parts.append(f"\\texttt{{{escape_text(child.children)}}}")
elif t == "Emphasis":
parts.append(f"\\textit{{{_inline_text(child.children)}}}")
elif t == "StrongEmphasis":
parts.append(f"\\textbf{{{_inline_text(child.children)}}}")
elif t == "Link":
parts.append(
f"\\href{{{escape_text(child.dest)}}}{{{_inline_text(child.children)}}}"
)
elif t == "Reference":
# @Рисунок:id → \ref{fig:id} with word
cat = {
"рисунок": "рис.",
"таблица": "табл.",
"листинг": "лист.",
"формула": "форм.",
}.get(child.type.lower(), child.type)
# Prefer Russian full names as in ГОСТ
full = {
"рисунок": "Рисунок",
"таблица": "Таблица",
"листинг": "Листинг",
"формула": "формула",
}.get(child.type.lower(), child.type)
prefix = {
"рисунок": "fig",
"таблица": "tab",
"листинг": "lst",
"формула": "eq",
}.get(child.type.lower(), "obj")
parts.append(f"{full}~\\ref{{{latex_label(child.name, prefix)}}}")
elif t == "InlineEquation":
parts.append(f"${child.latex_equation}$")
elif t == "Image":
# handled at block level usually
parts.append("")
elif t == "LineBreak":
parts.append(" ")
elif hasattr(child, "children"):
parts.append(_inline_text(child.children))
else:
parts.append(escape_text(str(child)))
return "".join(parts)
def _cell_plain(cell) -> str:
merge_v = getattr(cell, "merge_v", "none")
merge_h = getattr(cell, "merge_h", "none")
if merge_v == "continue" or merge_h == "continue":
return ""
return _inline_text(getattr(cell, "children", []) or [])
def _emit_longtable(st: EmitState, table, caption_id: str | None, caption_text: str | None) -> None:
rows = table.children
if not rows:
return
n_cols = len(rows[0].children)
col_spec = "|" + "p{" + f"{0.92/n_cols:.3f}" + "\\textwidth}|" * n_cols
header_cells = [_cell_plain(c) for c in rows[0].children]
header_line = " & ".join(header_cells) + r" \\"
label = latex_label(caption_id, "tab") if caption_id else None
cap = escape_text(caption_text) if caption_text else ""
st.w(r"\begin{center}")
st.w(rf"\begin{{longtable}}{{{col_spec}}}")
if cap:
if label:
st.w(rf"\caption{{{cap}\label{{{label}}}}}\\")
else:
st.w(rf"\caption{{{cap}}}\\")
st.w(r"\hline")
st.w(header_line)
st.w(r"\hline")
st.w(r"\endfirsthead")
# Native LaTeX continuation (ГОСТ): different header on page 2+
st.w(r"\caption*{Продолжение таблицы~\thetable}\\")
st.w(r"\hline")
st.w(header_line)
st.w(r"\hline")
st.w(r"\endhead")
st.w(r"\hline")
st.w(r"\endfoot")
for row in rows[1:]:
cells = [_cell_plain(c) for c in row.children]
# pad
while len(cells) < n_cols:
cells.append("")
st.w(" & ".join(cells[:n_cols]) + r" \\")
st.w(r"\hline")
st.w(r"\end{longtable}")
st.w(r"\end{center}")
st.w()
def _emit_heading(st: EmitState, heading) -> None:
text = _inline_text(heading.children).strip()
upper = text.upper()
level = heading.level
numbered = getattr(heading, "numbered", True)
if upper == "СОДЕРЖАНИЕ" or upper.startswith("СОДЕРЖАНИЕ"):
st.skip_toc_heading = True
return # TOC already in tocpage.tex
# Bibliography section: collect later from [n]: — heading still emitted
cmds = {1: "section", 2: "subsection", 3: "subsubsection"}
cmd = cmds.get(level, "subsubsection")
if not numbered or upper in (
"ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ", "ПРИЛОЖЕНИЯ", "ПРИЛОЖЕНИЕ",
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", "СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
) or upper.startswith("ПРИЛОЖЕНИЕ"):
st.w(rf"\{cmd}*{{{text}}}")
st.w(rf"\addcontentsline{{toc}}{{{cmd}}}{{{text}}}")
else:
st.w(rf"\{cmd}{{{text}}}")
st.w()
def _emit_paragraph(st: EmitState, para) -> None:
# detect bibliography lines [n]: ...
raw = _inline_text(para.children).strip()
# Also scan children for plain biblio from original — marko may wrap
text_probe = ""
for ch in para.children or []:
if type(ch).__name__ in ("RawText", "Literal"):
text_probe += ch.children or ""
m = re.match(r"^\[([^\]]+)\]:\s*(.+)$", text_probe.strip())
if m:
st.biblio.append((m.group(1), m.group(2).strip()))
return
# inline images in paragraph
has_image = any(type(c).__name__ == "Image" for c in (para.children or []))
if has_image:
for c in para.children:
if type(c).__name__ == "Image":
_emit_image(st, c)
# remaining text
others = [c for c in para.children if type(c).__name__ != "Image"]
t = _inline_text(others).strip()
if t:
st.w(t)
st.w()
return
if raw:
st.w(raw)
st.w()
def _emit_image(st: EmitState, img) -> None:
dest = img.dest
title = getattr(img, "title", None) or ""
# title may be "%id Caption"
caption_id = getattr(img, "unique_name", None)
caption_text = title
if title.startswith("%"):
parts = title[1:].split(None, 1)
caption_id = parts[0]
caption_text = parts[1] if len(parts) > 1 else ""
# copy image into Images/
src = dest
if st.work_dir and not os.path.isabs(src):
src = str(st.work_dir / dest)
name = Path(dest).name
if st.images_dir and os.path.isfile(src):
st.images_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, st.images_dir / name)
dest_tex = name
else:
dest_tex = dest.replace("\\", "/")
label = latex_label(caption_id, "fig") if caption_id else None
st.w(r"\begin{figure}[!htb]")
st.w(r"\centering")
st.w(rf"\includegraphics[width=0.95\textwidth]{{{dest_tex}}}")
if caption_text:
if label:
st.w(rf"\caption{{{escape_text(caption_text)}\label{{{label}}}}}")
else:
st.w(rf"\caption{{{escape_text(caption_text)}}}")
elif label:
st.w("\\caption{Рисунок\\label{" + label + "}}")
st.w(r"\end{figure}")
st.w()
def _emit_code(st: EmitState, block, lang: str) -> None:
source = block.children[0].children if block.children else ""
cap_id, cap_text, with_listing = (None, None, False)
if st.pending_caption:
cap_id, cap_text, with_listing = st.pending_caption
st.pending_caption = None
lang_l = (lang or "").strip().lower()
if lang_l in DIAGRAM_LANGS:
# leave as listing of source; PDF diagram optional later
pass
lst_lang = lang_l if lang_l and lang_l not in DIAGRAM_LANGS else ""
opts = []
if lst_lang:
opts.append(f"language={lst_lang}")
if cap_text:
opts.append(f"caption={{{escape_text(cap_text)}}}")
if cap_id:
opts.append(f"label={{{latex_label(cap_id, 'lst')}}}")
opt_s = ",".join(opts)
st.w(rf"\begin{{lstlisting}}[{opt_s}]" if opt_s else r"\begin{lstlisting}")
st.w(escape_verbatim_for_listing(source.rstrip("\n")))
st.w(r"\end{lstlisting}")
st.w()
def _emit_list(st: EmitState, lst) -> None:
env = "enumerate" if lst.ordered else "itemize"
st.w(rf"\begin{{{env}}}")
for item in lst.children:
# list item children are paragraphs / nested lists
texts = []
for ch in item.children:
if type(ch).__name__ == "Paragraph":
texts.append(_inline_text(ch.children))
elif type(ch).__name__ == "List":
st.w(r"\item " + " ".join(texts))
texts = []
_emit_list(st, ch)
else:
texts.append(_inline_text(getattr(ch, "children", []) or []))
if texts:
st.w(r"\item " + " ".join(texts))
st.w(rf"\end{{{env}}}")
st.w()
def _emit_equation(st: EmitState, eq) -> None:
cap_id = None
if st.pending_caption:
cap_id = st.pending_caption[0]
st.pending_caption = None
st.w(r"\begin{equation}")
if cap_id:
st.w(rf"\label{{{latex_label(cap_id, 'eq')}}}")
st.w(eq.latex_equation.strip())
st.w(r"\end{equation}")
st.w()
def emit_document(md_text: str, *, work_dir: Path, images_dir: Path) -> str:
st = EmitState(work_dir=work_dir, images_dir=images_dir)
pre = preprocess_markdown(md_text, emdash_to_hyphen=False)
doc = markdown.parse(pre)
for el in doc.children:
name = type(el).__name__
if name == "BlankLine":
continue
if name == "Caption":
st.pending_caption = (
el.unique_name,
el.text,
getattr(el, "with_listing", False),
)
continue
if name == "Heading":
_emit_heading(st, el)
continue
if name == "Paragraph":
_emit_paragraph(st, el)
continue
if name == "Table":
cid = ct = None
if st.pending_caption:
cid, ct, _ = st.pending_caption
st.pending_caption = None
_emit_longtable(st, el, cid, ct)
continue
if name in ("FencedCode", "CodeBlock"):
lang = getattr(el, "lang", "") or ""
_emit_code(st, el, lang)
continue
if name == "List":
_emit_list(st, el)
continue
if name == "Equation":
_emit_equation(st, el)
continue
if name == "TOC":
continue # tocpage.tex
if name == "Quote":
st.w(r"\begin{quote}")
for ch in el.children:
if type(ch).__name__ == "Paragraph":
st.w(_inline_text(ch.children))
st.w(r"\end{quote}")
st.w()
continue
# fallback
if hasattr(el, "children"):
st.w(_inline_text(el.children))
st.w()
if st.biblio:
st.w(r"\begin{thebibliography}{99}")
for key, txt in st.biblio:
st.w(rf"\bibitem{{{escape_text(key)}}} {escape_text(txt)}")
st.w(r"\end{thebibliography}")
st.w()
return "\n".join(st.lines) + "\n"