"""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"