Files
doc-writing-standard/scripts/md2docx.py
T
snowgitea 14ac1cd768 feat: doc-writing-standard v1.7.0(首次纳入版本控制)
全流程(六阶段):0-A 八类资料登记表 → 0-B 三张清单(涉及/不涉及/待确认)
→ 0-C 模板骨架与逐小节饱满度分档 → 0-D 骨架核对 + 公开渠道补全
→ 0-E 来源三元组 → 分批成文 → 人工审校 → 交付前一致性门禁

正文写作铁律 7.1–7.5:
- 7.1 来源分级 A/B/C(事实 / 编制口径 / 合理推演)
- 7.2 用语正反对照表(禁用「详见第 X 章」「按行业通行做法」「与附件 X 口径一致」等)
- 7.3 扩写两方向(向前找证据 / 向后找影响),不横向注水
- 7.4 饱满度四档:D 一句话 ≤50 字 / C 交代 ≥100 字 / B 论证 ≥200 字 / A 清单(不看字数)
- 7.4.1 三问定档流程、7.4.2 模板小节标题 → 档位速查表、7.4.3《饱满度分档表》为阶段 0-C 强制产出物
- 7.5 成文自检清单(按档位判字数达标)

配套文件:
- references/review-failure-cases.md:梓潼初设 6 例真实评审失败 + docx 落地 6 条技术红线 + 交付前自查清单
- scripts/md2docx.py:md → 宋体(四处字体全设)/ 小四 / 首行缩进 2 字符 / 1.5 倍行距 docx

其他:第十三节阶段产物落盘规范(目录树 + 命名 + 留痕纪律)、第十四节评审高频扣分点库。
2026-09-11 23:31:48 +08:00

271 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()