Skip to content

Commit a190941

Browse files
committed
add manuscript
1 parent 162871d commit a190941

13 files changed

Lines changed: 1527 additions & 0 deletions

figures/graphical_abstract.png

163 KB
Loading

figures/workflow_diagram.png

297 KB
Loading

manuscript/convert_to_docx.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
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] == '![':
97+
j = text.find(')', i)
98+
i = j + 1 if j != -1 else i + 1
99+
continue
100+
101+
# markdown link [label](url) → 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

Comments
 (0)