|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +md_to_tex.py — Convert a PACSeries paper.md into paper.tex. |
| 4 | +
|
| 5 | +Standalone (no pandoc). Handles the constructs the PACSeries papers use: |
| 6 | +headings, paragraphs, $...$/$$...$$ math (passthrough), pipe tables (booktabs), |
| 7 | +ordered/unordered lists, bold/italic/inline-code, links, and horizontal rules. |
| 8 | +Math and inline-code spans are protected before LaTeX escaping so their contents |
| 9 | +are never mangled. |
| 10 | +
|
| 11 | +Compiles with xelatex or lualatex (unicode passthrough via unicode-math), or |
| 12 | +pdflatex (unicode chars in prose may need the fallback below). |
| 13 | +
|
| 14 | +Usage: |
| 15 | + python md_to_tex.py <paper_dir> --number N --date "May 2026" |
| 16 | + # writes <paper_dir>/paper.tex from <paper_dir>/paper.md |
| 17 | +""" |
| 18 | +import re |
| 19 | +import sys |
| 20 | +import argparse |
| 21 | +from pathlib import Path |
| 22 | + |
| 23 | +PREAMBLE = r"""\documentclass[11pt,a4paper]{article} |
| 24 | +\usepackage{iftex} |
| 25 | +\ifPDFTeX |
| 26 | + \usepackage[T1]{fontenc} |
| 27 | + \usepackage[utf8]{inputenc} |
| 28 | + \usepackage{textcomp} |
| 29 | +\else |
| 30 | + \usepackage{unicode-math} |
| 31 | + \defaultfontfeatures{Scale=MatchLowercase} |
| 32 | +\fi |
| 33 | +\usepackage{amsmath,amssymb,amsthm} |
| 34 | +\usepackage{booktabs} |
| 35 | +\usepackage{longtable} |
| 36 | +\usepackage{array} |
| 37 | +\usepackage{graphicx} |
| 38 | +\usepackage[margin=1in]{geometry} |
| 39 | +\usepackage{xcolor} |
| 40 | +\usepackage{hyperref} |
| 41 | +\hypersetup{colorlinks=true,linkcolor=blue!60!black,citecolor=green!50!black,urlcolor=blue!60!black} |
| 42 | +\usepackage{fancyhdr} |
| 43 | +\pagestyle{fancy} |
| 44 | +\fancyhf{} |
| 45 | +\fancyhead[L]{\small PACSeries Paper %(number)s} |
| 46 | +\fancyhead[R]{\small Dawn Field Institute} |
| 47 | +\fancyfoot[C]{\thepage} |
| 48 | +\renewcommand{\headrulewidth}{0.4pt} |
| 49 | +\setlength{\parindent}{0pt} |
| 50 | +\setlength{\parskip}{6pt plus 2pt minus 1pt} |
| 51 | +\providecommand{\tightlist}{\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} |
| 52 | +
|
| 53 | +\title{%(title)s} |
| 54 | +\author{Peter Groom \\ Dawn Field Institute} |
| 55 | +\date{%(date)s} |
| 56 | +
|
| 57 | +\begin{document} |
| 58 | +\maketitle |
| 59 | +""" |
| 60 | + |
| 61 | +FOOTER = "\n\\end{document}\n" |
| 62 | + |
| 63 | + |
| 64 | +def escape_tex(s): |
| 65 | + # s has NO math/code spans (already protected). Escape LaTeX specials. |
| 66 | + out = [] |
| 67 | + for ch in s: |
| 68 | + if ch == '\\': |
| 69 | + out.append(r'\textbackslash{}') |
| 70 | + elif ch in '&%#_${}': |
| 71 | + out.append('\\' + ch) |
| 72 | + elif ch == '~': |
| 73 | + out.append(r'\textasciitilde{}') |
| 74 | + elif ch == '^': |
| 75 | + out.append(r'\textasciicircum{}') |
| 76 | + else: |
| 77 | + out.append(ch) |
| 78 | + return ''.join(out) |
| 79 | + |
| 80 | + |
| 81 | +def inline(text, store): |
| 82 | + """Protect math/code, escape, then apply md inline formatting, then restore.""" |
| 83 | + # 1. protect $$...$$, $...$, `code` |
| 84 | + def protect(pat, m): |
| 85 | + store.append(m.group(0) if pat != 'code' else '\\texttt{' + escape_tex(m.group(1)) + '}') |
| 86 | + return f'\x00{len(store)-1}\x00' |
| 87 | + text = re.sub(r'\$\$.*?\$\$', lambda m: protect('math', m), text, flags=re.S) |
| 88 | + text = re.sub(r'(?<!\\)\$.+?(?<!\\)\$', lambda m: protect('math', m), text) |
| 89 | + text = re.sub(r'`([^`]+)`', lambda m: protect('code', m), text) |
| 90 | + # 2. escape the remaining prose |
| 91 | + text = escape_tex(text) |
| 92 | + # 3. md inline -> latex (operate on escaped text; ** and * survive escaping) |
| 93 | + text = re.sub(r'\*\*(.+?)\*\*', r'\\textbf{\1}', text) |
| 94 | + text = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'\\emph{\1}', text) |
| 95 | + # links [t](u) -> \href{u}{t} (u was escaped: unescape _ and # inside url) |
| 96 | + def link(m): |
| 97 | + t, u = m.group(1), m.group(2) |
| 98 | + u = u.replace(r'\_', '_').replace(r'\#', '#').replace(r'\%', '%').replace(r'\&', '&') |
| 99 | + return r'\href{' + u + '}{' + t + '}' |
| 100 | + text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', link, text) |
| 101 | + # 4. restore protected spans |
| 102 | + text = re.sub(r'\x00(\d+)\x00', lambda m: store[int(m.group(1))], text) |
| 103 | + return text |
| 104 | + |
| 105 | + |
| 106 | +def _split_cells(r): |
| 107 | + # protect $...$ math (which may contain '|') and escaped \| before splitting |
| 108 | + spans = [] |
| 109 | + def prot(m): |
| 110 | + spans.append(m.group(0)); return f'\x01{len(spans)-1}\x01' |
| 111 | + r2 = re.sub(r'(?<!\\)\$.+?(?<!\\)\$', prot, r) |
| 112 | + r2 = r2.replace(r'\|', '\x02') |
| 113 | + parts = [p.strip() for p in r2.strip().strip('|').split('|')] |
| 114 | + out = [] |
| 115 | + for p in parts: |
| 116 | + p = p.replace('\x02', r'\|') |
| 117 | + p = re.sub(r'\x01(\d+)\x01', lambda m: spans[int(m.group(1))], p) |
| 118 | + out.append(p) |
| 119 | + return out |
| 120 | + |
| 121 | + |
| 122 | +def convert_table(rows): |
| 123 | + # rows: list of raw md table lines (including header + separator) |
| 124 | + cells = [_split_cells(r) for r in rows] |
| 125 | + header = cells[0] |
| 126 | + body = cells[2:] # skip separator row (cells[1]) |
| 127 | + ncol = len(header) |
| 128 | + align = 'l' * ncol |
| 129 | + out = [r'\begin{longtable}[]{@{}' + align + r'@{}}', r'\toprule'] |
| 130 | + store = [] |
| 131 | + out.append(' & '.join(inline(h, store) for h in header) + r' \\') |
| 132 | + out.append(r'\midrule\endhead') |
| 133 | + for row in body: |
| 134 | + row = (row + [''] * ncol)[:ncol] |
| 135 | + out.append(' & '.join(inline(c, store) for c in row) + r' \\') |
| 136 | + out.append(r'\bottomrule') |
| 137 | + out.append(r'\end{longtable}') |
| 138 | + return '\n'.join(out) |
| 139 | + |
| 140 | + |
| 141 | +def convert(md, title): |
| 142 | + lines = md.split('\n') |
| 143 | + out = [] |
| 144 | + i = 0 |
| 145 | + n = len(lines) |
| 146 | + store = [] |
| 147 | + seen_title = False |
| 148 | + while i < n: |
| 149 | + line = lines[i] |
| 150 | + stripped = line.strip() |
| 151 | + |
| 152 | + # fenced blocks not expected; skip if present |
| 153 | + if stripped.startswith('```'): |
| 154 | + i += 1 |
| 155 | + buf = [] |
| 156 | + while i < n and not lines[i].strip().startswith('```'): |
| 157 | + buf.append(lines[i]); i += 1 |
| 158 | + i += 1 |
| 159 | + out.append(r'\begin{verbatim}') |
| 160 | + out.extend(buf) |
| 161 | + out.append(r'\end{verbatim}') |
| 162 | + continue |
| 163 | + |
| 164 | + # display math block |
| 165 | + if stripped.startswith('$$') and stripped.endswith('$$') and len(stripped) > 3: |
| 166 | + out.append(r'\[' + stripped[2:-2].strip() + r'\]') |
| 167 | + i += 1; continue |
| 168 | + if stripped == '$$': |
| 169 | + i += 1; buf = [] |
| 170 | + while i < n and lines[i].strip() != '$$': |
| 171 | + buf.append(lines[i]); i += 1 |
| 172 | + i += 1 |
| 173 | + out.append(r'\[' + '\n'.join(buf) + r'\]') |
| 174 | + continue |
| 175 | + |
| 176 | + # headings |
| 177 | + m = re.match(r'^(#{1,6})\s+(.*)$', stripped) |
| 178 | + if m: |
| 179 | + level = len(m.group(1)); htext = m.group(2) |
| 180 | + if level == 1 and not seen_title: |
| 181 | + seen_title = True # title handled in preamble |
| 182 | + i += 1; continue |
| 183 | + cmd = {1: 'section*', 2: 'section*', 3: 'subsection*', |
| 184 | + 4: 'subsubsection*', 5: 'paragraph', 6: 'subparagraph'}.get(level, 'subsection*') |
| 185 | + out.append('\\%s{%s}' % (cmd, inline(htext, store))) |
| 186 | + i += 1; continue |
| 187 | + |
| 188 | + # horizontal rule |
| 189 | + if re.match(r'^-{3,}$', stripped) or re.match(r'^\*{3,}$', stripped): |
| 190 | + out.append(r'\begin{center}\rule{0.5\linewidth}{0.4pt}\end{center}') |
| 191 | + i += 1; continue |
| 192 | + |
| 193 | + # table (a line with | and next line is separator) |
| 194 | + if '|' in line and i + 1 < n and re.match(r'^\s*\|?[\s:\-|]+\|?\s*$', lines[i+1]) and '-' in lines[i+1]: |
| 195 | + tbl = [lines[i], lines[i+1]] |
| 196 | + i += 2 |
| 197 | + while i < n and '|' in lines[i] and lines[i].strip(): |
| 198 | + tbl.append(lines[i]); i += 1 |
| 199 | + out.append(convert_table(tbl)) |
| 200 | + continue |
| 201 | + |
| 202 | + # unordered list |
| 203 | + if re.match(r'^\s*[-*]\s+', line): |
| 204 | + out.append(r'\begin{itemize}\tightlist') |
| 205 | + while i < n and re.match(r'^\s*[-*]\s+', lines[i]): |
| 206 | + item = re.sub(r'^\s*[-*]\s+', '', lines[i]) |
| 207 | + out.append(r'\item ' + inline(item, store)) |
| 208 | + i += 1 |
| 209 | + out.append(r'\end{itemize}') |
| 210 | + continue |
| 211 | + |
| 212 | + # ordered list |
| 213 | + if re.match(r'^\s*\d+\.\s+', line): |
| 214 | + out.append(r'\begin{enumerate}\tightlist') |
| 215 | + while i < n and re.match(r'^\s*\d+\.\s+', lines[i]): |
| 216 | + item = re.sub(r'^\s*\d+\.\s+', '', lines[i]) |
| 217 | + out.append(r'\item ' + inline(item, store)) |
| 218 | + i += 1 |
| 219 | + out.append(r'\end{enumerate}') |
| 220 | + continue |
| 221 | + |
| 222 | + # blank line |
| 223 | + if stripped == '': |
| 224 | + out.append('') |
| 225 | + i += 1; continue |
| 226 | + |
| 227 | + # paragraph (gather until blank/structural) |
| 228 | + para = [line] |
| 229 | + i += 1 |
| 230 | + while i < n and lines[i].strip() != '' and not re.match(r'^(#{1,6}\s|\s*[-*]\s|\s*\d+\.\s|\$\$|```|-{3,}$)', lines[i]) and not ('|' in lines[i] and i+1 < n): |
| 231 | + para.append(lines[i]); i += 1 |
| 232 | + out.append(inline(' '.join(p.strip() for p in para), store)) |
| 233 | + |
| 234 | + return '\n'.join(out) |
| 235 | + |
| 236 | + |
| 237 | +def main(): |
| 238 | + ap = argparse.ArgumentParser() |
| 239 | + ap.add_argument('paper_dir') |
| 240 | + ap.add_argument('--number', required=True) |
| 241 | + ap.add_argument('--date', default='2026') |
| 242 | + args = ap.parse_args() |
| 243 | + |
| 244 | + pdir = Path(args.paper_dir) |
| 245 | + md = (pdir / 'paper.md').read_text() |
| 246 | + # title = first '# ' heading |
| 247 | + tm = re.search(r'^#\s+(.*)$', md, flags=re.M) |
| 248 | + title = tm.group(1).strip() if tm else pdir.name |
| 249 | + store = [] |
| 250 | + title_tex = inline(title, store) |
| 251 | + |
| 252 | + body = convert(md, title) |
| 253 | + tex = (PREAMBLE % {'number': args.number, 'title': title_tex, 'date': args.date}) + body + FOOTER |
| 254 | + (pdir / 'paper.tex').write_text(tex) |
| 255 | + print(f"wrote {pdir/'paper.tex'} ({len(tex.splitlines())} lines)") |
| 256 | + |
| 257 | + |
| 258 | +if __name__ == '__main__': |
| 259 | + main() |
0 commit comments