@@ -0,0 +1,51 @@
|
||||
# MD → LaTeX → PDF (XeLaTeX)
|
||||
|
||||
Два пайплайна на базе шаблона [mirea-ninja/Latex-Template-for-Report-Diploma-Thesis](https://github.com/mirea-ninja/Latex-Template-for-Report-Diploma-Thesis).
|
||||
|
||||
| # | Шаг | Инструмент | Результат |
|
||||
|---|-----|------------|-----------|
|
||||
| **1** | MD → LaTeX | `python -m md2latex` | каталог проекта (`main.tex`, `content.tex`, …) |
|
||||
| **2** | LaTeX → PDF | **XeLaTeX** / `latexmk -xelatex` | `main.pdf` |
|
||||
|
||||
Таблицы → **`longtable`** с `\endfirsthead` / `\endhead` и подписью **«Продолжение таблицы N»** на следующих страницах (это делает сам LaTeX, без угадывания высоты в Python).
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.10+ (тот же venv, что и md2gost)
|
||||
- [MiKTeX](https://miktex.org) или TeX Live с **XeLaTeX**
|
||||
- Шрифт **Times New Roman** (Windows обычно есть)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```powershell
|
||||
# оба пайплайна сразу
|
||||
python -m md2latex examples/example.md -o build/example_latex --pdf
|
||||
|
||||
# или
|
||||
powershell -File scripts/md2pdf.ps1 examples/example.md
|
||||
```
|
||||
|
||||
Только пайплайн 1 (без PDF):
|
||||
|
||||
```powershell
|
||||
python -m md2latex report.md -o report_latex
|
||||
# затем:
|
||||
cd report_latex
|
||||
latexmk -xelatex main.tex
|
||||
# или: xelatex main.tex && xelatex main.tex
|
||||
```
|
||||
|
||||
Титул:
|
||||
|
||||
```powershell
|
||||
python -m md2latex report.md -o report_latex --titlepage title.pdf --pdf
|
||||
```
|
||||
|
||||
## Синтаксис MD
|
||||
|
||||
Тот же диалект, что у **md2gost** (`# *ВВЕДЕНИЕ`, `%id`, `@Таблица:id`, pipe-tables, `$$`, fenced code, `[n]: …`).
|
||||
|
||||
## Структура шаблона
|
||||
|
||||
`latex/mirea/` — вендор шаблона МИРЭА (ГОСТ: TNR 14, интервал 1.5, поля 30/10/20/20).
|
||||
Генератор копирует его в `-o` и перезаписывает только `content.tex`.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""md2latex — Markdown (md2gost dialect) → LaTeX project → PDF (XeLaTeX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["convert_md_to_latex_project"]
|
||||
|
||||
from .converter import convert_md_to_latex_project
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Два пайплайна:
|
||||
1) MD → LaTeX (этот модуль пишет project/)
|
||||
2) LaTeX → PDF (XeLaTeX; флаг --pdf или scripts/md2pdf.ps1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .converter import convert_md_to_latex_project
|
||||
|
||||
|
||||
def _run_xelatex(project_dir: Path, passes: int = 2) -> int:
|
||||
main = project_dir / "main.tex"
|
||||
if not main.is_file():
|
||||
print(f"Error: {main} not found", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
xelatex = shutil.which("xelatex")
|
||||
latexmk = shutil.which("latexmk")
|
||||
|
||||
if latexmk:
|
||||
cmd = [
|
||||
latexmk, "-xelatex", "-interaction=nonstopmode",
|
||||
"-halt-on-error", "main.tex",
|
||||
]
|
||||
print("Pipeline 2 (LaTeX → PDF):", " ".join(cmd))
|
||||
r = subprocess.run(cmd, cwd=project_dir)
|
||||
return r.returncode
|
||||
|
||||
if not xelatex:
|
||||
print(
|
||||
"Error: XeLaTeX not found (install MiKTeX/TeX Live and add to PATH).\n"
|
||||
"Pipeline 1 done — compile manually:\n"
|
||||
f" cd {project_dir}\n"
|
||||
" xelatex main.tex && xelatex main.tex",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
for i in range(passes):
|
||||
cmd = [xelatex, "-interaction=nonstopmode", "-halt-on-error", "main.tex"]
|
||||
print(f"Pipeline 2 pass {i + 1}/{passes}:", " ".join(cmd))
|
||||
r = subprocess.run(cmd, cwd=project_dir)
|
||||
if r.returncode != 0:
|
||||
return r.returncode
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="md2latex",
|
||||
description=(
|
||||
"Пайплайн 1: MD (диалект md2gost) → LaTeX-проект (шаблон МИРЭА). "
|
||||
"Пайплайн 2: XeLaTeX → PDF (опция --pdf)."
|
||||
),
|
||||
)
|
||||
p.add_argument("filename", help="Исходный .md")
|
||||
p.add_argument(
|
||||
"-o", "--output-dir",
|
||||
default=None,
|
||||
help="Каталог LaTeX-проекта (по умолчанию: <stem>_latex рядом с md)",
|
||||
)
|
||||
p.add_argument("--titlepage", help="PDF титульного листа → titlepage.pdf")
|
||||
p.add_argument(
|
||||
"--pdf",
|
||||
action="store_true",
|
||||
help="Пайплайн 2: сразу собрать PDF через XeLaTeX/latexmk",
|
||||
)
|
||||
p.add_argument(
|
||||
"--xelatex-passes",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Число проходов xelatex, если нет latexmk (по умолчанию 2)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
md = Path(args.filename)
|
||||
if not md.suffix.lower() == ".md":
|
||||
print("Error: filename must be .md", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not md.is_file():
|
||||
print(f"Error: file not found: {md}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
out = Path(args.output_dir) if args.output_dir else md.with_name(md.stem + "_latex")
|
||||
main_tex = convert_md_to_latex_project(md, out, titlepage=args.titlepage)
|
||||
print(f"Pipeline 1 (MD → LaTeX): {main_tex}")
|
||||
|
||||
if args.pdf:
|
||||
code = _run_xelatex(out, passes=args.xelatex_passes)
|
||||
pdf = out / "main.pdf"
|
||||
if code == 0 and pdf.is_file():
|
||||
print(f"Pipeline 2 (LaTeX → PDF): {pdf.resolve()}")
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Assemble LaTeX project from markdown (pipeline 1: MD → LaTeX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .emitter import emit_document
|
||||
|
||||
|
||||
def package_template_dir() -> Path:
|
||||
return Path(__file__).resolve().parent.parent / "latex" / "mirea"
|
||||
|
||||
|
||||
def convert_md_to_latex_project(
|
||||
md_path: str | Path,
|
||||
out_dir: str | Path,
|
||||
*,
|
||||
titlepage: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Copy MIREA template into out_dir, write content.tex from markdown.
|
||||
|
||||
Returns path to main.tex
|
||||
"""
|
||||
md_path = Path(md_path).resolve()
|
||||
out_dir = Path(out_dir).resolve()
|
||||
tpl = package_template_dir()
|
||||
if not tpl.is_dir():
|
||||
raise FileNotFoundError(f"LaTeX template not found: {tpl}")
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# sync template files (keep user Images/content if regenerating)
|
||||
for item in tpl.iterdir():
|
||||
dest = out_dir / item.name
|
||||
if item.name == "content.tex":
|
||||
continue
|
||||
if item.is_dir():
|
||||
if dest.exists():
|
||||
# merge Settings etc.
|
||||
for sub in item.rglob("*"):
|
||||
if sub.is_file():
|
||||
rel = sub.relative_to(item)
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(sub, target)
|
||||
else:
|
||||
shutil.copytree(item, dest)
|
||||
else:
|
||||
shutil.copy2(item, dest)
|
||||
|
||||
images_dir = out_dir / "Images"
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
body = emit_document(
|
||||
md_text,
|
||||
work_dir=md_path.parent,
|
||||
images_dir=images_dir,
|
||||
)
|
||||
(out_dir / "content.tex").write_text(body, encoding="utf-8")
|
||||
|
||||
if titlepage:
|
||||
tp = Path(titlepage)
|
||||
if tp.is_file():
|
||||
shutil.copy2(tp, out_dir / "titlepage.pdf")
|
||||
|
||||
return out_dir / "main.tex"
|
||||
@@ -0,0 +1,353 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Escape helpers for LaTeX output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_SPECIAL = {
|
||||
"\\": r"\textbackslash{}",
|
||||
"{": r"\{",
|
||||
"}": r"\}",
|
||||
"#": r"\#",
|
||||
"$": r"\$",
|
||||
"%": r"\%",
|
||||
"&": r"\&",
|
||||
"_": r"\_",
|
||||
"~": r"\textasciitilde{}",
|
||||
"^": r"\textasciicircum{}",
|
||||
}
|
||||
|
||||
|
||||
def escape_text(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
out = []
|
||||
for ch in s:
|
||||
out.append(_SPECIAL.get(ch, ch))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def escape_verbatim_for_listing(s: str) -> str:
|
||||
"""lstlisting body — avoid ending the environment accidentally."""
|
||||
return s.replace("\\end{lstlisting}", "\\end\\{lstlisting}")
|
||||
|
||||
|
||||
_LABEL_RE = re.compile(r"[^\w\-]+", re.UNICODE)
|
||||
|
||||
|
||||
def latex_label(name: str | None, prefix: str = "obj") -> str:
|
||||
if not name:
|
||||
return prefix
|
||||
cleaned = _LABEL_RE.sub("-", name.strip()).strip("-")
|
||||
return f"{prefix}:{cleaned or 'x'}"
|
||||
Reference in New Issue
Block a user