14ac1cd768
全流程(六阶段):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 其他:第十三节阶段产物落盘规范(目录树 + 命名 + 留痕纪律)、第十四节评审高频扣分点库。
271 lines
7.9 KiB
Python
271 lines
7.9 KiB
Python
# -*- 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()
|