295 lines
9.0 KiB
Python
295 lines
9.0 KiB
Python
"""Convert md2gost markdown AST → LaTeX body (content.tex)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
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 mdexport import (
|
|
BlockVisitor,
|
|
InlineRenderer,
|
|
is_contents_heading,
|
|
parse_image_caption,
|
|
reference_full_name,
|
|
render_inline,
|
|
walk_blocks,
|
|
)
|
|
|
|
from .escape import escape_text, escape_verbatim_for_listing, latex_label
|
|
|
|
_REF_PREFIXES = {
|
|
"рисунок": "fig",
|
|
"таблица": "tab",
|
|
"листинг": "lst",
|
|
"формула": "eq",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class EmitState(BlockVisitor):
|
|
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 on_heading(self, element) -> None:
|
|
_emit_heading(self, element)
|
|
|
|
def on_table(self, element, caption_id: str | None, caption_text: str | None) -> None:
|
|
_emit_longtable(self, element, caption_id, caption_text)
|
|
|
|
def on_code(self, element, lang: str) -> None:
|
|
_emit_code(self, element, lang)
|
|
|
|
def on_list(self, element) -> None:
|
|
_emit_list(self, element)
|
|
|
|
def on_equation(self, element) -> None:
|
|
_emit_equation(self, element)
|
|
|
|
def on_image(self, image) -> None:
|
|
_emit_image(self, image)
|
|
|
|
def on_body_text(self, children) -> None:
|
|
text = _inline_text(children).strip()
|
|
if text:
|
|
self.w(text)
|
|
self.w()
|
|
|
|
def on_quote(self, element) -> None:
|
|
self.w(r"\begin{quote}")
|
|
for child in element.children:
|
|
if type(child).__name__ == "Paragraph":
|
|
self.w(_inline_text(child.children))
|
|
self.w(r"\end{quote}")
|
|
self.w()
|
|
|
|
def on_other(self, element) -> None:
|
|
self.w(_inline_text(element.children))
|
|
self.w()
|
|
|
|
|
|
class _LatexInline(InlineRenderer):
|
|
def escape(self, text: str) -> str:
|
|
return escape_text(text)
|
|
|
|
def code_span(self, node) -> str:
|
|
return f"\\texttt{{{escape_text(node.children)}}}"
|
|
|
|
def emphasis(self, inner: str) -> str:
|
|
return f"\\textit{{{inner}}}"
|
|
|
|
def strong_emphasis(self, inner: str) -> str:
|
|
return f"\\textbf{{{inner}}}"
|
|
|
|
def link(self, node, inner: str) -> str:
|
|
return f"\\href{{{escape_text(node.dest)}}}{{{inner}}}"
|
|
|
|
def reference(self, node) -> str:
|
|
prefix = _REF_PREFIXES.get(node.type.lower(), "obj")
|
|
return f"{reference_full_name(node.type)}~\\ref{{{latex_label(node.name, prefix)}}}"
|
|
|
|
def inline_equation(self, node) -> str:
|
|
return f"${node.latex_equation}$"
|
|
|
|
def line_break(self) -> str:
|
|
return " "
|
|
|
|
|
|
_INLINE = _LatexInline()
|
|
|
|
|
|
def _inline_text(children) -> str:
|
|
return render_inline(children, _INLINE)
|
|
|
|
|
|
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 is_contents_heading(text):
|
|
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_image(st: EmitState, img) -> None:
|
|
dest = img.dest
|
|
caption_id, caption_text = parse_image_caption(img, getattr(img, "unique_name", None))
|
|
|
|
# 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 = st.take_caption()
|
|
|
|
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 = st.take_caption()[0]
|
|
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)
|
|
|
|
walk_blocks(doc.children, st)
|
|
|
|
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"
|