|
| 1 | +""" |
| 2 | +Convert metbit_manuscript.md → metbit_manuscript.docx |
| 3 | +Handles: headings, paragraphs, bold/italic/code inline, tables, images, |
| 4 | + horizontal rules, bullet lists, and blank-line paragraph breaks. |
| 5 | +""" |
| 6 | + |
| 7 | +import re |
| 8 | +import os |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +from docx import Document |
| 12 | +from docx.shared import Pt, Cm, RGBColor, Inches |
| 13 | +from docx.enum.text import WD_ALIGN_PARAGRAPH |
| 14 | +from docx.enum.style import WD_STYLE_TYPE |
| 15 | +from docx.oxml.ns import qn |
| 16 | +from docx.oxml import OxmlElement |
| 17 | + |
| 18 | +HERE = Path(__file__).parent |
| 19 | + |
| 20 | + |
| 21 | +# ── helpers ─────────────────────────────────────────────────────────────────── |
| 22 | + |
| 23 | +def set_run_font(run, name="Times New Roman", size=11, bold=False, |
| 24 | + italic=False, color=None): |
| 25 | + run.font.name = name |
| 26 | + run.font.size = Pt(size) |
| 27 | + run.bold = bold |
| 28 | + run.italic = italic |
| 29 | + if color: |
| 30 | + run.font.color.rgb = RGBColor(*color) |
| 31 | + |
| 32 | + |
| 33 | +def add_horizontal_rule(doc): |
| 34 | + """Add a thin bottom-border paragraph as a visual divider.""" |
| 35 | + p = doc.add_paragraph() |
| 36 | + p.paragraph_format.space_before = Pt(4) |
| 37 | + p.paragraph_format.space_after = Pt(4) |
| 38 | + pPr = p._p.get_or_add_pPr() |
| 39 | + pBdr = OxmlElement('w:pBdr') |
| 40 | + bottom = OxmlElement('w:bottom') |
| 41 | + bottom.set(qn('w:val'), 'single') |
| 42 | + bottom.set(qn('w:sz'), '6') |
| 43 | + bottom.set(qn('w:space'), '1') |
| 44 | + bottom.set(qn('w:color'), '999999') |
| 45 | + pBdr.append(bottom) |
| 46 | + pPr.append(pBdr) |
| 47 | + |
| 48 | + |
| 49 | +def add_inline_runs(para, text, base_size=11, base_font="Times New Roman"): |
| 50 | + """ |
| 51 | + Parse inline markdown (* ** ` [text](url)) and add styled runs. |
| 52 | + Handles: **bold**, *italic*, `code`, **bold *nested italic* bold** |
| 53 | + """ |
| 54 | + # tokenise into segments: (text, bold, italic, code) |
| 55 | + # Strategy: walk character by character through a simple state machine |
| 56 | + segments = [] |
| 57 | + i = 0 |
| 58 | + bold = False |
| 59 | + italic = False |
| 60 | + code = False |
| 61 | + buf = [] |
| 62 | + |
| 63 | + def flush(): |
| 64 | + if buf: |
| 65 | + segments.append((''.join(buf), bold, italic, code)) |
| 66 | + buf.clear() |
| 67 | + |
| 68 | + while i < len(text): |
| 69 | + # code span |
| 70 | + if text[i] == '`' and not code: |
| 71 | + flush() |
| 72 | + code = True |
| 73 | + i += 1 |
| 74 | + continue |
| 75 | + if text[i] == '`' and code: |
| 76 | + flush() |
| 77 | + code = False |
| 78 | + i += 1 |
| 79 | + continue |
| 80 | + |
| 81 | + # bold (**) — must check before single * |
| 82 | + if text[i:i+2] == '**' and not code: |
| 83 | + flush() |
| 84 | + bold = not bold |
| 85 | + i += 2 |
| 86 | + continue |
| 87 | + |
| 88 | + # italic (*) |
| 89 | + if text[i] == '*' and not code: |
| 90 | + flush() |
| 91 | + italic = not italic |
| 92 | + i += 1 |
| 93 | + continue |
| 94 | + |
| 95 | + # skip markdown image syntax entirely — handled at block level |
| 96 | + if text[i:i+2] == ' → keep label only |
| 102 | + if text[i] == '[' and not code: |
| 103 | + end_bracket = text.find(']', i) |
| 104 | + if end_bracket != -1 and end_bracket + 1 < len(text) and text[end_bracket+1] == '(': |
| 105 | + end_paren = text.find(')', end_bracket) |
| 106 | + if end_paren != -1: |
| 107 | + flush() |
| 108 | + label = text[i+1:end_bracket] |
| 109 | + buf.append(label) |
| 110 | + flush() |
| 111 | + i = end_paren + 1 |
| 112 | + continue |
| 113 | + buf.append(text[i]) |
| 114 | + i += 1 |
| 115 | + |
| 116 | + flush() |
| 117 | + |
| 118 | + for seg_text, is_bold, is_italic, is_code in segments: |
| 119 | + if not seg_text: |
| 120 | + continue |
| 121 | + run = para.add_run(seg_text) |
| 122 | + if is_code: |
| 123 | + run.font.name = "Courier New" |
| 124 | + run.font.size = Pt(base_size - 0.5) |
| 125 | + run.font.color.rgb = RGBColor(0x8B, 0x00, 0x00) |
| 126 | + else: |
| 127 | + run.font.name = base_font |
| 128 | + run.font.size = Pt(base_size) |
| 129 | + run.bold = is_bold |
| 130 | + run.italic = is_italic |
| 131 | + |
| 132 | + |
| 133 | +def parse_table(lines, doc): |
| 134 | + """Convert markdown table lines into a docx table.""" |
| 135 | + # filter out separator rows (|---|---|) |
| 136 | + data_rows = [l for l in lines |
| 137 | + if not re.match(r'^\s*\|[\s\-|:]+\|\s*$', l)] |
| 138 | + if not data_rows: |
| 139 | + return |
| 140 | + |
| 141 | + rows = [] |
| 142 | + for row in data_rows: |
| 143 | + cells = [c.strip() for c in row.strip().strip('|').split('|')] |
| 144 | + rows.append(cells) |
| 145 | + |
| 146 | + ncols = max(len(r) for r in rows) |
| 147 | + table = doc.add_table(rows=len(rows), cols=ncols) |
| 148 | + table.style = 'Table Grid' |
| 149 | + |
| 150 | + for r_idx, row in enumerate(rows): |
| 151 | + for c_idx, cell_text in enumerate(row): |
| 152 | + if c_idx >= ncols: |
| 153 | + break |
| 154 | + cell = table.cell(r_idx, c_idx) |
| 155 | + cell.text = '' |
| 156 | + p = cell.paragraphs[0] |
| 157 | + p.paragraph_format.space_before = Pt(2) |
| 158 | + p.paragraph_format.space_after = Pt(2) |
| 159 | + |
| 160 | + is_header = (r_idx == 0) |
| 161 | + add_inline_runs(p, cell_text, base_size=9.5) |
| 162 | + if is_header: |
| 163 | + for run in p.runs: |
| 164 | + run.bold = True |
| 165 | + # shade header row |
| 166 | + tc = cell._tc |
| 167 | + tcPr = tc.get_or_add_tcPr() |
| 168 | + shd = OxmlElement('w:shd') |
| 169 | + shd.set(qn('w:val'), 'clear') |
| 170 | + shd.set(qn('w:color'), 'auto') |
| 171 | + shd.set(qn('w:fill'), '1B4F72') |
| 172 | + tcPr.append(shd) |
| 173 | + for run in p.runs: |
| 174 | + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) |
| 175 | + |
| 176 | + doc.add_paragraph() # spacing after table |
| 177 | + |
| 178 | + |
| 179 | +def insert_image(doc, img_path_rel): |
| 180 | + """Try to insert an image; skip gracefully if not found.""" |
| 181 | + img_path = HERE / img_path_rel |
| 182 | + if img_path.exists(): |
| 183 | + try: |
| 184 | + doc.add_picture(str(img_path), width=Inches(5.8)) |
| 185 | + last_para = doc.paragraphs[-1] |
| 186 | + last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER |
| 187 | + except Exception as e: |
| 188 | + doc.add_paragraph(f"[Image: {img_path_rel}]") |
| 189 | + else: |
| 190 | + doc.add_paragraph(f"[Image not found: {img_path_rel}]") |
| 191 | + |
| 192 | + |
| 193 | +# ── document setup ──────────────────────────────────────────────────────────── |
| 194 | + |
| 195 | +def build_document(md_path: Path, out_path: Path): |
| 196 | + doc = Document() |
| 197 | + |
| 198 | + # Page margins |
| 199 | + for section in doc.sections: |
| 200 | + section.top_margin = Cm(2.54) |
| 201 | + section.bottom_margin = Cm(2.54) |
| 202 | + section.left_margin = Cm(3.17) |
| 203 | + section.right_margin = Cm(3.17) |
| 204 | + |
| 205 | + # ── custom styles ───────────────────────────────────────────────────────── |
| 206 | + styles = doc.styles |
| 207 | + |
| 208 | + def _get_or_create(name, style_type=WD_STYLE_TYPE.PARAGRAPH): |
| 209 | + return styles[name] if name in [s.name for s in styles] \ |
| 210 | + else styles.add_style(name, style_type) |
| 211 | + |
| 212 | + # Title style |
| 213 | + title_style = styles['Title'] |
| 214 | + title_style.font.name = "Times New Roman" |
| 215 | + title_style.font.size = Pt(16) |
| 216 | + title_style.font.bold = True |
| 217 | + title_style.font.color.rgb = RGBColor(0x15, 0x43, 0x60) |
| 218 | + title_style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER |
| 219 | + title_style.paragraph_format.space_after = Pt(10) |
| 220 | + |
| 221 | + # Heading 1 |
| 222 | + h1 = styles['Heading 1'] |
| 223 | + h1.font.name = "Times New Roman" |
| 224 | + h1.font.size = Pt(13) |
| 225 | + h1.font.bold = True |
| 226 | + h1.font.color.rgb = RGBColor(0x15, 0x43, 0x60) |
| 227 | + h1.paragraph_format.space_before = Pt(14) |
| 228 | + h1.paragraph_format.space_after = Pt(4) |
| 229 | + |
| 230 | + # Heading 2 |
| 231 | + h2 = styles['Heading 2'] |
| 232 | + h2.font.name = "Times New Roman" |
| 233 | + h2.font.size = Pt(11.5) |
| 234 | + h2.font.bold = True |
| 235 | + h2.font.italic = True |
| 236 | + h2.font.color.rgb = RGBColor(0x1A, 0x52, 0x76) |
| 237 | + h2.paragraph_format.space_before = Pt(10) |
| 238 | + h2.paragraph_format.space_after = Pt(3) |
| 239 | + |
| 240 | + # Heading 3 |
| 241 | + h3 = styles['Heading 3'] |
| 242 | + h3.font.name = "Times New Roman" |
| 243 | + h3.font.size = Pt(11) |
| 244 | + h3.font.bold = True |
| 245 | + h3.font.color.rgb = RGBColor(0x0E, 0x66, 0x55) |
| 246 | + h3.paragraph_format.space_before = Pt(8) |
| 247 | + h3.paragraph_format.space_after = Pt(2) |
| 248 | + |
| 249 | + # Normal body |
| 250 | + normal = styles['Normal'] |
| 251 | + normal.font.name = "Times New Roman" |
| 252 | + normal.font.size = Pt(11) |
| 253 | + normal.paragraph_format.space_after = Pt(6) |
| 254 | + normal.paragraph_format.first_line_indent = Cm(0) |
| 255 | + |
| 256 | + # ── parse markdown ──────────────────────────────────────────────────────── |
| 257 | + raw = md_path.read_text(encoding='utf-8') |
| 258 | + lines = raw.splitlines() |
| 259 | + |
| 260 | + # group consecutive table lines into blocks |
| 261 | + processed = [] |
| 262 | + i = 0 |
| 263 | + while i < len(lines): |
| 264 | + line = lines[i] |
| 265 | + # detect start of a table block |
| 266 | + if re.match(r'^\s*\|', line): |
| 267 | + block = [] |
| 268 | + while i < len(lines) and re.match(r'^\s*\|', lines[i]): |
| 269 | + block.append(lines[i]) |
| 270 | + i += 1 |
| 271 | + processed.append(('TABLE', block)) |
| 272 | + else: |
| 273 | + processed.append(('LINE', line)) |
| 274 | + i += 1 |
| 275 | + |
| 276 | + # ── render ──────────────────────────────────────────────────────────────── |
| 277 | + for kind, payload in processed: |
| 278 | + if kind == 'TABLE': |
| 279 | + parse_table(payload, doc) |
| 280 | + continue |
| 281 | + |
| 282 | + line = payload |
| 283 | + |
| 284 | + # ── headings ────────────────────────────────────────────────────────── |
| 285 | + if line.startswith('#### '): |
| 286 | + p = doc.add_paragraph(line[5:].strip(), style='Heading 3') |
| 287 | + continue |
| 288 | + if line.startswith('### '): |
| 289 | + p = doc.add_paragraph(line[4:].strip(), style='Heading 3') |
| 290 | + continue |
| 291 | + if line.startswith('## '): |
| 292 | + p = doc.add_paragraph(line[3:].strip(), style='Heading 1') |
| 293 | + continue |
| 294 | + if line.startswith('# '): |
| 295 | + p = doc.add_paragraph(line[2:].strip(), style='Title') |
| 296 | + continue |
| 297 | + |
| 298 | + # ── horizontal rule ─────────────────────────────────────────────────── |
| 299 | + if re.match(r'^-{3,}\s*$', line): |
| 300 | + add_horizontal_rule(doc) |
| 301 | + continue |
| 302 | + |
| 303 | + # ── blank line → paragraph break (already implicit, skip) ───────────── |
| 304 | + if line.strip() == '': |
| 305 | + continue |
| 306 | + |
| 307 | + # ── image ───────────────────────────────────────────────────────────── |
| 308 | + img_match = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)\s*$', line) |
| 309 | + if img_match: |
| 310 | + insert_image(doc, img_match.group(2)) |
| 311 | + continue |
| 312 | + |
| 313 | + # ── bullet list ─────────────────────────────────────────────────────── |
| 314 | + bullet_match = re.match(r'^(\s*)[-*]\s+(.+)$', line) |
| 315 | + if bullet_match: |
| 316 | + indent = len(bullet_match.group(1)) // 2 |
| 317 | + p = doc.add_paragraph(style='List Bullet') |
| 318 | + p.paragraph_format.left_indent = Cm(0.75 + indent * 0.5) |
| 319 | + p.paragraph_format.space_after = Pt(2) |
| 320 | + add_inline_runs(p, bullet_match.group(2), base_size=10.5) |
| 321 | + continue |
| 322 | + |
| 323 | + # ── italic-only figure caption line (*Figure …*) ────────────────────── |
| 324 | + if re.match(r'^\*Figure', line) or re.match(r'^\*\*Figure', line): |
| 325 | + p = doc.add_paragraph() |
| 326 | + p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
| 327 | + p.paragraph_format.space_before = Pt(4) |
| 328 | + p.paragraph_format.space_after = Pt(10) |
| 329 | + add_inline_runs(p, line, base_size=9.5) |
| 330 | + continue |
| 331 | + |
| 332 | + # ── meta lines: Authors / affiliations / corresponding ──────────────── |
| 333 | + if line.startswith('**Authors**'): |
| 334 | + p = doc.add_paragraph() |
| 335 | + p.paragraph_format.space_before = Pt(4) |
| 336 | + p.paragraph_format.space_after = Pt(2) |
| 337 | + p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
| 338 | + add_inline_runs(p, line, base_size=11) |
| 339 | + continue |
| 340 | + |
| 341 | + # ── general paragraph ───────────────────────────────────────────────── |
| 342 | + p = doc.add_paragraph() |
| 343 | + p.paragraph_format.space_after = Pt(6) |
| 344 | + p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY |
| 345 | + |
| 346 | + # affiliation / small-print lines (¹ ² * lines) |
| 347 | + if line.startswith(('¹', '²', '³', '\\*', '*Contributor')): |
| 348 | + p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER |
| 349 | + add_inline_runs(p, line.lstrip('\\'), base_size=9.5) |
| 350 | + continue |
| 351 | + |
| 352 | + # keywords line |
| 353 | + if line.startswith('**Keywords'): |
| 354 | + p.paragraph_format.space_before = Pt(4) |
| 355 | + add_inline_runs(p, line, base_size=10) |
| 356 | + continue |
| 357 | + |
| 358 | + add_inline_runs(p, line, base_size=11) |
| 359 | + |
| 360 | + doc.save(str(out_path)) |
| 361 | + print(f"Saved → {out_path}") |
| 362 | + |
| 363 | + |
| 364 | +# ── entry point ─────────────────────────────────────────────────────────────── |
| 365 | +if __name__ == '__main__': |
| 366 | + md = HERE / 'metbit_manuscript.md' |
| 367 | + docx = HERE / 'metbit_manuscript.docx' |
| 368 | + build_document(md, docx) |
0 commit comments