# -*- coding: utf-8 -*- """md2docx.py — 方案类文档 Markdown → Word(宋体 / 小四 / 首行缩进 2 字符 / 1.5 倍行距) 用法(Windows,务必先设 UTF-8): set PYTHONUTF8=1 python scripts/md2docx.py 输入.md 输出.docx 依赖: pip install python-docx 说明: 只负责排版落地,不改动内容。 支持: 标题(#~####)、正文段落、管道表格、无序/有序列表、- [ ] 复选框、**加粗**、代码块。 复杂合并单元格、图片、公式请在 Word 中人工微调。 """ import os import re import sys from docx import Document from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.shared import Cm, Pt # ==================== 可按甲方模板调整的参数 ==================== FONT_NAME = '宋体' # 中西文统一宋体 BODY_SIZE = Pt(12) # 小四 = 12pt FIRST_LINE_INDENT = Pt(24) # 首行缩进 2 字符(2 x 12pt) LINE_SPACING = 1.5 # 行间距 1.5 倍 HEADING_SIZES = {1: Pt(16), 2: Pt(15), 3: Pt(14), 4: Pt(12)} HEADING_ALIGN_CENTER = {1} # 一级标题居中 TABLE_FONT_SIZE = Pt(12) TABLE_HEADER_BOLD = True CODE_FONT_SIZE = Pt(10.5) PAGE_WIDTH, PAGE_HEIGHT = Cm(21), Cm(29.7) # A4 MARGIN_TB, MARGIN_LR = Cm(2.54), Cm(3.17) # ================================================================ CHECKBOX = {False: '\u2610', True: '\u2611'} # ☐ / ☑ def set_run_font(run, size=None, bold=None): """四处字体全设,避免 Word 回落到等线 / Calibri 造成字体不统一。""" run.font.name = FONT_NAME rpr = run._element.get_or_add_rPr() rfonts = rpr.find(qn('w:rFonts')) if rfonts is None: rfonts = rpr.makeelement(qn('w:rFonts'), {}) rpr.append(rfonts) rfonts.set(qn('w:ascii'), FONT_NAME) rfonts.set(qn('w:hAnsi'), FONT_NAME) rfonts.set(qn('w:eastAsia'), FONT_NAME) rfonts.set(qn('w:cs'), FONT_NAME) if size is not None: run.font.size = size if bold is not None: run.font.bold = bold def add_runs(paragraph, text, size, bold=False): """把 **加粗** 解析成多个 run。""" for piece in re.split(r'(\*\*.+?\*\*)', text): if not piece: continue if piece.startswith('**') and piece.endswith('**') and len(piece) > 4: set_run_font(paragraph.add_run(piece[2:-2]), size, True) else: set_run_font(paragraph.add_run(piece), size, bold) def add_body(doc, text): p = doc.add_paragraph() pf = p.paragraph_format pf.first_line_indent = FIRST_LINE_INDENT pf.line_spacing = LINE_SPACING add_runs(p, text, BODY_SIZE) return p def add_heading(doc, text, level): p = doc.add_paragraph() pf = p.paragraph_format pf.line_spacing = LINE_SPACING pf.space_before = Pt(6) pf.space_after = Pt(6) if level in HEADING_ALIGN_CENTER: pf.alignment = WD_ALIGN_PARAGRAPH.CENTER add_runs(p, text, HEADING_SIZES.get(level, BODY_SIZE), True) return p def add_list_item(doc, text, prefix='\u2022 '): p = doc.add_paragraph() pf = p.paragraph_format pf.left_indent = FIRST_LINE_INDENT pf.line_spacing = LINE_SPACING add_runs(p, prefix + text, BODY_SIZE) return p def is_table_sep(line): s = line.strip() return bool(s) and set(s) <= set('|-: ') and '-' in s def split_row(line): s = line.strip() if s.startswith('|'): s = s[1:] if s.endswith('|'): s = s[:-1] return [c.strip() for c in s.split('|')] def add_table(doc, rows): cols = max(len(r) for r in rows) table = doc.add_table(rows=0, cols=cols) table.style = 'Table Grid' table.alignment = WD_TABLE_ALIGNMENT.CENTER for i, row in enumerate(rows): cells = table.add_row().cells for j in range(cols): text = row[j] if j < len(row) else '' para = cells[j].paragraphs[0] para.paragraph_format.line_spacing = LINE_SPACING add_runs(para, text, TABLE_FONT_SIZE, bold=(i == 0 and TABLE_HEADER_BOLD)) return table def normalize_links(text): text = re.sub(r'!\[([^\]]*)\]\([^)]*\)', r'\1', text) # 图片 -> alt 文本 text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1(\2)', text) # 链接 -> 文本(URL) return text def setup_document(): doc = Document() style = doc.styles['Normal'] style.font.name = FONT_NAME style.font.size = BODY_SIZE try: style.element.get_or_add_rPr().get_or_add_rFonts().set(qn('w:eastAsia'), FONT_NAME) except Exception: pass sec = doc.sections[0] sec.page_width, sec.page_height = PAGE_WIDTH, PAGE_HEIGHT sec.top_margin = sec.bottom_margin = MARGIN_TB sec.left_margin = sec.right_margin = MARGIN_LR return doc def convert(md_path, docx_path): with open(md_path, encoding='utf-8') as f: lines = f.read().split('\n') # 跳过 YAML frontmatter(技能元数据不进正文) start = 0 if lines and lines[0].strip() == '---': for k in range(1, len(lines)): if lines[k].strip() == '---': start = k + 1 break doc = setup_document() i, in_code = start, False while i < len(lines): line = lines[i].rstrip() stripped = line.strip() # 代码块 if stripped.startswith('```'): in_code = not in_code i += 1 continue if in_code: p = doc.add_paragraph() p.paragraph_format.line_spacing = 1.0 p.paragraph_format.left_indent = FIRST_LINE_INDENT add_runs(p, line, CODE_FONT_SIZE) i += 1 continue if not stripped: i += 1 continue # 标题 m = re.match(r'^(#{1,6})\s+(.*)$', stripped) if m: add_heading(doc, m.group(2).strip(), len(m.group(1))) i += 1 continue # 表格 if '|' in stripped and i + 1 < len(lines) and is_table_sep(lines[i + 1]): rows = [] while i < len(lines) and '|' in lines[i]: if not is_table_sep(lines[i]): rows.append(split_row(lines[i])) i += 1 if rows: add_table(doc, rows) continue # 分隔线 if re.match(r'^([-*_]\s*){3,}$', stripped): i += 1 continue # 复选框(必须早于普通列表判断) m = re.match(r'^[-*+]\s+\[([ xX])\]\s*(.*)$', stripped) if m: add_list_item(doc, normalize_links(m.group(2)), prefix=CHECKBOX[m.group(1).lower() == 'x'] + ' ') i += 1 continue # 无序列表 m = re.match(r'^[-*+]\s+(.*)$', stripped) if m: add_list_item(doc, normalize_links(m.group(1))) i += 1 continue # 有序列表 m = re.match(r'^(\d+)[.)]\s+(.*)$', stripped) if m: add_list_item(doc, normalize_links(m.group(2)), prefix=m.group(1) + '. ') i += 1 continue # 引用 if stripped.startswith('>'): add_body(doc, normalize_links(stripped.lstrip('>').strip())) i += 1 continue # 普通正文 add_body(doc, normalize_links(stripped)) i += 1 doc.save(docx_path) def main(): if len(sys.argv) < 3: print(__doc__) sys.exit(1) md_path, docx_path = sys.argv[1], sys.argv[2] if not os.path.isfile(md_path): print('输入文件不存在: %s' % md_path) sys.exit(1) out_dir = os.path.dirname(os.path.abspath(docx_path)) if out_dir and not os.path.isdir(out_dir): os.makedirs(out_dir) convert(md_path, docx_path) print('已生成: %s' % docx_path) print('字体=%s 正文=%s 首行缩进=%s 行距=%s' % (FONT_NAME, BODY_SIZE.pt, FIRST_LINE_INDENT.pt, LINE_SPACING)) if __name__ == '__main__': main()