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

其他:第十三节阶段产物落盘规范(目录树 + 命名 + 留痕纪律)、第十四节评审高频扣分点库。
This commit is contained in:
2026-09-11 23:31:48 +08:00
parent e903833d1f
commit 14ac1cd768
4 changed files with 659 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
# -*- 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()