#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""交付文档构建器:`docs/src/*.md` → `docs/*.docx`(用户令 2026-09-22)。 ## 为什么要有它(而不是拿 Word 手排) 用户令要求三份文档「区分章节目录、文表图并茂、字体字号分类统一」。手排的三份文档**必然**字体字号漂移、 图表编号对不上、改一处忘一处。所以内容用 markdown 写(可 diff、进版本库),排版由本器**一套样式表**统一执行: | 元素 | 字体 | 字号 | 对齐 / 缩进 | |---|---|---|---| | 封面标题 | 黑体 | 小一(26pt) | 居中 | | 章标题(一级 `##`) | 黑体 | 三号(16pt) | 左对齐,**章前分页** | | 节标题(二级 `###`) | 黑体 | 四号(14pt) | 左对齐 | | 小节标题(三级 `####`) | 黑体 | 小四(12pt) 加粗 | 左对齐 | | 正文 | 宋体 | 小四(12pt) | 首行缩进 2 字符、1.5 倍行距、两端对齐 | | 列表 | 宋体 | 小四(12pt) | 悬挂缩进 | | 表题(表上方) | 黑体 | 五号(10.5pt) | 居中 | | 表头 | 黑体 | 五号(10.5pt) 加粗 | 居中 + 浅灰底纹 + 跨页重复 | | 表格正文 | 宋体 | 五号(10.5pt)(≥6 列降 9pt) | 左对齐 | | 图(居中)与图题(图下方) | 黑体 | 五号(10.5pt) | 居中 | | 页眉 / 页脚页码 | 宋体 | 小五(9pt) | 居中 | 英文与数字用 Times New Roman(中式公文惯例:中文宋体/黑体 + 西文 Times New Roman)。 ## markdown 约定(渲染器支持的子集,写手必须照此写) # 文档标题 (只此一处,进封面) ## 1 章 / ### 1.1 节 / #### 1.1.1 小节 普通段落(一行一段,段间空行) - 无序项 1. 有序项 | 表头 | 表头 | + |---| 分隔行 → 表格 **表 3-1 标题**(紧挨表格上方一行) → 表题 ![图 3-1 标题](figures/xxx.png) → 图片 + 图题 > 引用段 --- → 忽略 ## 用法 python scripts/delivery_docs_build.py # 三份全渲染 python scripts/delivery_docs_build.py --only req # 只渲染一份 (req/des/dat) python scripts/delivery_docs_build.py --check # 只体检源件(章/节/表/图/字数、图是否在位) python scripts/delivery_docs_build.py --spec # 打印本器的排版规范表 退出码: 0 全部成功 · 5 有源件缺件/图缺件(逐条打印,仍会尽力渲染其余内容) """ from __future__ import annotations import argparse import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parents[1] from docx import Document # noqa: E402 from docx.enum.section import WD_SECTION # noqa: E402 from docx.enum.table import WD_TABLE_ALIGNMENT # noqa: E402 from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING # noqa: E402 from docx.oxml import OxmlElement # noqa: E402 from docx.oxml.ns import qn # noqa: E402 from docx.shared import Cm, Pt, RGBColor # noqa: E402 VERSION = __import__('importlib').import_module('src.version').VERSION HEI, SONG, WEST = '黑体', '宋体', 'Times New Roman' SZ = dict(cover=26, h1=16, h2=14, h3=12, body=12, table=10.5, table_small=9, caption=10.5, header=9) DOCS = { 'req': dict(src='docs/src/需求分析_观澜_{v}.md', out='docs/需求分析_观澜_{v}.docx', title='需求分析', subtitle='观澜 v2 · 风电场智能分析系统'), 'des': dict(src='docs/src/系统设计说明_观澜_{v}.md', out='docs/系统设计说明_观澜_{v}.docx', title='系统设计说明', subtitle='观澜 v2 · 风电场智能分析系统'), 'dat': dict(src='docs/src/数据要求说明_观澜_{v}.md', out='docs/数据要求说明_观澜_{v}.docx', title='数据要求说明', subtitle='观澜 v2 · 风电场智能分析系统(结合现场《数据分析收资要求-v3》)', # ★2026-09-22 用户令: 这份是**纯数据需求规格** —— 不体现样本场接入情况, 也不体现实现细节。 # 下面这组模式由 `--check`/`--verify` 机器扫描, 命中即 FAIL(不靠人自觉)。 strict_terms=True), } # 数据要求说明的"不得出现"模式(用户令 2026-09-22): # 英文测点名称 / 文件名称 / 落盘位置 / 机组名称编号 / 样本场现状字样。 # 中文业务含义名称与「中文(英文简写)」(如 平均无故障间隔(MTBF))不在禁止之列。 STRICT_PATTERNS = ( ('英文测点标识(snake_case)', r'\b[a-z][a-z0-9]*_[a-z0-9_]+\b'), ('文件扩展名', r'\.(csv|parquet|json|yaml|yml|xlsx|xls|pdf|mdb|xml|docx|doc)\b'), ('目录/落盘位置', r'(data/raw|outputs/|configs/|scripts/|reference/|docs/|data\\)'), ('机组名称/编号', r'(WTG\d+|\b\d{2}[A-Z]\b)'), ('样本场现状字样', r'(已在位|未到位|现状|实测|样本场|本场|催缴)'), ) # ★去标识化词表(用户令 2026-09-22「内容参考如东风电场,但不体现如东风电场」): # 三份文档是**对外**交付件, 不得出现样本场的中文名、罗马化形态、业主、地域、OEM 与第三方机构名。 # 词表放在代码里 ⇒ `--check`/`--verify` 与 `guanlan.py check` 都会机器扫描, 不靠人自觉。 # ★2026-09-22 补(数据要求说明起草时实逮): 样本场 CMS 导出包名是 `CMS_RuDong_CGN_202603-04` # —— **大小写混合**的罗马化场名/业主缩写能绕过精确匹配,所以匹配一律**大小写不敏感**(见 forbidden_hits)。 FORBIDDEN = ('如东', '如海', '广核', '江苏', 'rudong', 'cgn', '西门子', '上海电气', 'swt', '大生', '4.0-130') SPEC_ROWS = [ ('封面标题', '黑体', '小一(26pt)', '居中'), ('章标题(一级)', '黑体', '三号(16pt)', '左对齐,章前分页'), ('节标题(二级)', '黑体', '四号(14pt)', '左对齐'), ('小节标题(三级)', '黑体', '小四(12pt)加粗', '左对齐'), ('正文', '宋体(西文 Times New Roman)', '小四(12pt)', '首行缩进 2 字符,1.5 倍行距,两端对齐'), ('列表', '宋体', '小四(12pt)', '悬挂缩进 0.74 cm'), ('表题', '黑体', '五号(10.5pt)', '居中,置于表格上方'), ('表头', '黑体', '五号(10.5pt)加粗', '居中,浅灰底纹,跨页重复'), ('表格正文', '宋体', '五号(10.5pt);≥6 列降 9pt', '左对齐,垂直居中'), ('图', '—', '宽度 ≤ 15 cm', '居中'), ('图题', '黑体', '五号(10.5pt)', '居中,置于图下方'), ('页眉', '宋体', '小五(9pt)', '居中'), ('页脚页码', '宋体', '小五(9pt)', '居中(第 X 页 / 共 Y 页)'), ] WARN: list[str] = [] # ── 低层 docx 工具 ───────────────────────────────────────────────────────────── def _rfonts(el, ascii_font: str, ea_font: str): rpr = el.get_or_add_rPr() rf = rpr.find(qn('w:rFonts')) if rf is None: rf = OxmlElement('w:rFonts') rpr.append(rf) rf.set(qn('w:ascii'), ascii_font) rf.set(qn('w:hAnsi'), ascii_font) rf.set(qn('w:eastAsia'), ea_font) def set_run(run, size: float, *, ea: str = SONG, ascii_font: str = WEST, bold: bool = False): run.font.size = Pt(size) run.font.bold = bold run.font.name = ascii_font run.font.color.rgb = RGBColor(0, 0, 0) _rfonts(run._element, ascii_font, ea) def style_setup(doc: Document): """把内置样式(Heading 1-4 / Normal / 表格)改成文档的统一口径。""" normal = doc.styles['Normal'] normal.font.name = WEST normal.font.size = Pt(SZ['body']) _rfonts(normal.element, WEST, SONG) normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE normal.paragraph_format.line_spacing = 1.5 normal.paragraph_format.space_after = Pt(0) spec = {'Heading 1': (SZ['h1'], True), 'Heading 2': (SZ['h2'], True), 'Heading 3': (SZ['h3'], True), 'Heading 4': (SZ['h3'], True)} for name, (size, bold) in spec.items(): st = doc.styles[name] st.font.name = WEST st.font.size = Pt(size) st.font.bold = bold st.font.italic = False st.font.color.rgb = RGBColor(0, 0, 0) _rfonts(st.element, WEST, HEI) pf = st.paragraph_format pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE pf.line_spacing = 1.5 pf.space_before = Pt(12 if name != 'Heading 1' else 0) pf.space_after = Pt(6) pf.first_line_indent = Pt(0) if name == 'Heading 1': pf.page_break_before = True pf.space_after = Pt(12) sec = doc.sections[0] sec.page_width, sec.page_height = Cm(21.0), Cm(29.7) sec.top_margin = sec.bottom_margin = Cm(2.54) sec.left_margin = sec.right_margin = Cm(3.17) def add_field(paragraph, instr: str, placeholder: str = ''): r = paragraph.add_run() b = OxmlElement('w:fldChar'); b.set(qn('w:fldCharType'), 'begin') i = OxmlElement('w:instrText'); i.set(qn('xml:space'), 'preserve'); i.text = instr s = OxmlElement('w:fldChar'); s.set(qn('w:fldCharType'), 'separate') t = OxmlElement('w:t'); t.text = placeholder e = OxmlElement('w:fldChar'); e.set(qn('w:fldCharType'), 'end') for el in (b, i, s, t, e): r._r.append(el) return r def para(doc, text: str = '', *, size=SZ['body'], ea=SONG, bold=False, align='left', indent_chars=0, space_before=0, space_after=6, hanging=False): p = doc.add_paragraph() pf = p.paragraph_format pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE pf.line_spacing = 1.5 pf.space_before = Pt(space_before) pf.space_after = Pt(space_after) pf.first_line_indent = Pt(size * indent_chars) if hanging: pf.left_indent = Cm(0.74) pf.first_line_indent = Cm(-0.0) pf.alignment = {'left': WD_ALIGN_PARAGRAPH.LEFT, 'center': WD_ALIGN_PARAGRAPH.CENTER, 'justify': WD_ALIGN_PARAGRAPH.JUSTIFY}[align] if text: for chunk, is_bold, mono in split_bold(text): r = p.add_run(chunk) set_run(r, size, ea=SONG, ascii_font='Consolas' if mono else WEST, bold=bold or is_bold) return p INLINE_RE = re.compile(r'\*\*(.+?)\*\*|`([^`]+)`') CODE_RE = re.compile(r'`([^`]+)`') BOLD_RE = re.compile(r'\*\*(.+?)\*\*') def _bold_parts(s: str): parts, pos = [], 0 for m in BOLD_RE.finditer(s): if m.start() > pos: parts.append((s[pos:m.start()], False, False)) parts.append((m.group(1), True, False)) pos = m.end() tail = s[pos:] if '**' in tail: # 落单的 `**`(例如通配路径 `pitch/**` 没包在行内代码里)—— 交付文档里不能留字面量, # 去掉并记一条告警,让写手回去补行内代码。 WARN.append('落单的 ** 已剔除: ' + tail.strip()[:60]) tail = tail.replace('**', '') if tail: parts.append((tail, False, False)) return parts def split_bold(text: str): """行内标记 → [(片段, 是否粗体, 是否等宽)]。 ★单遍分词,支持两种嵌套(2026-09-22 两次实逮后的定稿): ① 代码里含 `**`(如通配路径 `` `pitch/**` ``)—— 不能当成粗体起始; ② 粗体里含代码(如 ``**体量以 `data/raw` 为准**``)—— 不能按代码先切、把粗体配对切断。 先按 `**…**` 找配对,配对内部再递归扫代码;代码段内部一律**字面**取,不再解析粗体。 落单的 `**` 剔除并记告警(交付文档里不能留字面星号)。 """ out: list[tuple[str, bool, bool]] = [] def emit(t: str, bold: bool, mono: bool): if t: out.append((t, bold, mono)) def scan(s: str, bold: bool): i = 0 while i < len(s): if s.startswith('**', i): j = s.find('**', i + 2) if j > i + 2: scan(s[i + 2:j], True) i = j + 2 continue WARN.append('落单的 ** 已剔除: ' + s[max(0, i - 20):i + 20].strip()) i += 2 continue if s[i] == '`': j = s.find('`', i + 1) if j > i: emit(s[i + 1:j], bold, True) i = j + 1 continue nxt = len(s) for k in range(i, len(s)): if s[k] == '`' or s.startswith('**', k): nxt = k break emit(s[i:nxt], bold, False) i = nxt scan(text, False) return out or [(text, False, False)] def shade(cell, fill='F2F2F2'): tcPr = cell._tc.get_or_add_tcPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), fill) tcPr.append(shd) def repeat_header(row): trPr = row._tr.get_or_add_trPr() th = OxmlElement('w:tblHeader') th.set(qn('w:val'), 'true') trPr.append(th) def add_table(doc, rows: list[list[str]]): ncol = max(len(r) for r in rows) t = doc.add_table(rows=0, cols=ncol) t.style = 'Table Grid' t.alignment = WD_TABLE_ALIGNMENT.CENTER t.autofit = True size = SZ['table_small'] if ncol >= 6 else SZ['table'] for i, r in enumerate(rows): cells = t.add_row().cells for j in range(ncol): txt = (r[j] if j < len(r) else '').strip() cell = cells[j] cell.text = '' p = cell.paragraphs[0] p.paragraph_format.line_spacing = 1.15 p.paragraph_format.space_after = Pt(0) p.paragraph_format.first_line_indent = Pt(0) p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT for chunk, is_bold, mono in split_bold(txt): run = p.add_run(chunk.replace('\\|', '|')) set_run(run, size, ea=HEI if i == 0 else SONG, ascii_font='Consolas' if mono else WEST, bold=(i == 0) or is_bold) if i == 0: shade(cell) if t.rows: repeat_header(t.rows[0]) return t # ── markdown 解析 ────────────────────────────────────────────────────────────── IMG_RE = re.compile(r'^!\[(?P[^\]]*)\]\((?P[^)]+)\)\s*$') CAP_RE = re.compile(r'^\*\*(?P表\s*[\d\-–.]+.*?)\*\*\s*$') def parse_md(text: str): """→ [('h1'|'h2'|'h3'|'h4'|'p'|'ul'|'ol'|'table'|'img'|'quote', payload)]""" lines = text.splitlines() blocks, i = [], 0 if lines and lines[0].startswith('# '): i = 1 while i < len(lines): ln = lines[i] s = ln.strip() if not s or re.match(r'^([-*_])(\s*\1){2,}$', s): i += 1 # 空行与分隔线(`---` / `***` / `___`) continue m = re.match(r'^(#{2,5})\s+(.*)$', s) if m: blocks.append(('h%d' % (len(m.group(1)) - 1), m.group(2).strip())) i += 1 continue m = IMG_RE.match(s) if m: blocks.append(('img', (m.group('cap'), m.group('path')))) i += 1 continue if s.startswith('|'): rows = [] while i < len(lines) and lines[i].strip().startswith('|'): raw = lines[i].strip().strip('|') if not re.match(r'^[\s:\-|]+$', raw): rows.append([c.strip() for c in raw.split('|')]) i += 1 if rows: cap = None if blocks and blocks[-1][0] == 'p' and CAP_RE.match(blocks[-1][1]): cap = blocks.pop()[1] blocks.append(('table', (cap, rows))) continue if s.startswith('- '): items = [] while i < len(lines) and lines[i].strip().startswith('- '): items.append(lines[i].strip()[2:].strip()) i += 1 blocks.append(('ul', items)) continue if re.match(r'^\d+[.)]\s+', s): items = [] while i < len(lines) and re.match(r'^\d+[.)]\s+', lines[i].strip()): items.append(re.sub(r'^\d+[.)]\s+', '', lines[i].strip())) i += 1 blocks.append(('ol', items)) continue if s.startswith('> '): q = [] while i < len(lines) and lines[i].strip().startswith('> '): q.append(lines[i].strip()[2:].strip()) i += 1 blocks.append(('quote', ' '.join(q))) continue blocks.append(('p', s)) i += 1 return blocks def forbidden_hits(text: str) -> dict: """扫去标识化词表 → {禁用词: 命中次数}(只回报非零项;**大小写不敏感**)。""" low = text.lower() return {w: low.count(w.lower()) for w in FORBIDDEN if low.count(w.lower())} def strict_hits(text: str) -> dict: """数据要求说明的"不得出现实现细节/现状"扫描 → {模式名: 命中次数}(只回报非零项)。""" return {name: len(re.findall(rx, text, re.I)) for name, rx in STRICT_PATTERNS if re.search(rx, text, re.I)} def count_md(text: str) -> dict: blocks = parse_md(text) body = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text) cn = len(re.findall(r'[\u4e00-\u9fff]', body)) return dict( 章=sum(1 for k, _ in blocks if k == 'h1'), 节=sum(1 for k, _ in blocks if k == 'h2'), 小节=sum(1 for k, _ in blocks if k == 'h3'), 表=sum(1 for k, _ in blocks if k == 'table'), 图=sum(1 for k, _ in blocks if k == 'img'), 段落=sum(1 for k, _ in blocks if k == 'p'), 汉字=cn) # ── 渲染 ─────────────────────────────────────────────────────────────────────── def cover(doc, title: str, subtitle: str): for _ in range(4): para(doc, '', space_after=0) para(doc, f'{title}', size=SZ['cover'], ea=HEI, bold=True, align='center', space_after=12) para(doc, subtitle, size=14, ea=HEI, align='center', space_after=6) for _ in range(3): para(doc, '', space_after=0) para(doc, f'版本 {VERSION}(与系统版本一致)', size=14, align='center', space_after=6) para(doc, f'编制:观澜项目组  日期:2026-09-22', size=12, align='center', space_after=6) # ★落款不写生成器文件名与源件路径:那会把实现细节带进"对外、不体现文件名/落盘位置"的文档里 # (2026-09-22 实逮: `--check` 扫源件是 clean, 但 `--verify` 扫渲染结果命中 1 处 snake_case + 2 处路径)。 para(doc, '本文件由统一的交付文档生成器排版(样式表固定,可重跑、可 diff;正文源件随交付包提供)', size=9, align='center', space_after=0) doc.add_page_break() def toc(doc): para(doc, '目录', size=SZ['h1'], ea=HEI, bold=True, align='center', space_after=12) p = doc.add_paragraph() p.paragraph_format.line_spacing = 1.5 add_field(p, r'TOC \o "1-3" \h \z \u', '(目录域:在 Word 中按 Ctrl+A 后 F9,或右键“更新域”即可生成带页码的目录)') doc.add_page_break() def render_blocks(doc, blocks, fig_root: pathlib.Path): """渲染块序列。表号一律**按章编号**「表 <章>-<序>」(附录按字母 A/B/C)—— 与三份稿正文里的引用口径一致(实逮: 原先按出现顺序编成「表 1…表 54」, 正文写「见表 9-2」就指不到)。 """ tno = 0 chap_label = '' per = 0 caps: list[str] = [] for kind, payload in blocks: if kind == 'h1': head = payload.strip() m = re.match(r'^(\d+)', head) if m: chap_label, per = m.group(1), 0 else: m2 = re.match(r'^附录\s*([A-Za-z])', head) chap_label, per = (m2.group(1), 0) if m2 else (chap_label, 0) if kind.startswith('h'): lvl = int(kind[1]) style = {1: 'Heading 1', 2: 'Heading 2', 3: 'Heading 3', 4: 'Heading 4'}[lvl] p = doc.add_paragraph(style=style) size = {1: SZ['h1'], 2: SZ['h2'], 3: SZ['h3'], 4: SZ['h3']}[lvl] for chunk, _b, _m in split_bold(payload): set_run(p.add_run(chunk), size, ea=HEI, bold=True) continue if kind == 'p': para(doc, payload, align='justify', indent_chars=2) continue if kind in ('ul', 'ol'): for n, item in enumerate(payload, 1): mark = '· ' if kind == 'ul' else f'({n})' para(doc, mark + item, align='justify', hanging=True, space_after=3) continue if kind == 'quote': p = para(doc, payload, align='justify', space_before=6, space_after=6) p.paragraph_format.left_indent = Cm(0.74) continue if kind == 'table': cap, rows = payload tno += 1 per += 1 label = f'表 {chap_label}-{per}' if chap_label else f'表 {tno}' title = re.sub(r'^表\s*[\dA-Za-z]+\s*[-–—]\s*\d+\s*', '', cap or '').strip() if cap: author_no = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', cap) if author_no and f'{author_no.group(1)}-{author_no.group(2)}' != f'{chap_label}-{per}': WARN.append('表号与作者自编不一致: 原「%s」→ 本器按章编号「%s」' % (cap[:24], label)) caps.append(label) para(doc, (label + (' ' + title if title else '')), size=SZ['caption'], ea=HEI, bold=True, align='center', space_before=8, space_after=4) add_table(doc, rows) para(doc, '', space_after=2) continue if kind == 'img': cap, rel = payload # md 里的图路径是相对 `docs/` 的(约定 `figures/xxx.png`);容错:只给文件名也认。 cand = fig_root / rel if (fig_root / rel).exists() else fig_root / 'figures' / pathlib.Path(rel).name f = cand.resolve() if f.exists(): p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_before = Pt(8) p.paragraph_format.space_after = Pt(4) p.paragraph_format.first_line_indent = Pt(0) p.add_run().add_picture(str(f), width=Cm(15.0) if f.stat().st_size > 90_000 else Cm(13.0)) else: WARN.append(f'图缺件: {rel}') para(doc, f'(图缺失:{rel})', size=SZ['caption'], align='center', space_after=4) para(doc, cap or rel, size=SZ['caption'], ea=HEI, bold=True, align='center', space_after=8) continue doc._tab_labels = caps # 供调用方自检(本器 --verify 直接从 docx 回读,不依赖它) return tno def header_footer(doc, title: str): sec = doc.sections[0] hp = sec.header.paragraphs[0] hp.alignment = WD_ALIGN_PARAGRAPH.CENTER set_run(hp.add_run(f'{title} · 观澜 v2 风电场智能分析系统 · 版本 {VERSION}'), SZ['header']) fp = sec.footer.paragraphs[0] fp.alignment = WD_ALIGN_PARAGRAPH.CENTER set_run(fp.add_run('第 '), SZ['header']) add_field(fp, 'PAGE', '1') set_run(fp.add_run(' 页 / 共 '), SZ['header']) add_field(fp, 'NUMPAGES', '1') set_run(fp.add_run(' 页'), SZ['header']) def appendix_spec(doc): para(doc, '附:排版规范(字体字号分类)', size=SZ['h1'], ea=HEI, bold=True, align='left', space_before=12, space_after=8) para(doc, '本文件与同批交付的另外两份文档由同一个渲染器生成,字体字号分类完全一致;' '中文用黑体(标题)/宋体(正文),西文与数字用 Times New Roman。', align='justify', indent_chars=2) add_table(doc, [['元素', '字体', '字号', '对齐 / 缩进']] + [list(r) for r in SPEC_ROWS]) def build(key: str) -> int: spec = DOCS[key] src = ROOT / spec['src'].format(v=VERSION) out = ROOT / spec['out'].format(v=VERSION) if not src.exists(): WARN.append(f'源件缺件: {src.relative_to(ROOT).as_posix()}') print(f'✗ {key}: 源件不在位 {src}') return 5 blocks = parse_md(src.read_text(encoding='utf-8')) doc = Document() style_setup(doc) cover(doc, spec['title'], spec['subtitle']) toc(doc) ntab = render_blocks(doc, blocks, ROOT / 'docs') appendix_spec(doc) header_footer(doc, spec['title']) out.parent.mkdir(parents=True, exist_ok=True) doc.save(out) st = count_md(src.read_text(encoding='utf-8')) print('%s → %s(%d 章 · %d 节 · %d 表 · %d 图 · 约 %d 汉字 · %.0f KB)' % (key, out.relative_to(ROOT).as_posix(), st['章'], st['节'], max(ntab, st['表']), st['图'], st['汉字'], out.stat().st_size / 1024)) return 0 def verify_docx(key: str) -> int: """回读渲染结果自检(交付前必跑):计数 + 残留 markdown 标记 + 域 + 字体。""" spec = DOCS[key] out = ROOT / spec['out'].format(v=VERSION) if not out.is_file(): print('✗ %-4s 不在位 %s' % (key, out.relative_to(ROOT).as_posix())) return 5 doc = Document(str(out)) texts = [p.text for p in doc.paragraphs] cells = [c.text for t in doc.tables for r in t.rows for c in r.cells] anti = chr(96) # ★行内代码里的字面量不算残留(实逮: `` `pitch/**` `` 是 glob 通配路径,Word 里就该原样显示)。 mono, plain = [], [] for p in doc.paragraphs: for r in p.runs: (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text) for t in doc.tables: for row in t.rows: for c in row.cells: for p in c.paragraphs: for r in p.runs: (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text) allt = '\n'.join(texts + cells) body = '\n'.join(plain) heads = [p.text for p in doc.paragraphs if p.style.name.startswith('Heading')] residual = dict(反引号=body.count(anti), 星号=body.count('**'), 管道=body.count(' | '), 代码内字面星号='\n'.join(mono).count('**')) foot = doc.sections[0].footer.paragraphs[0]._p.xml head = doc.sections[0].header.paragraphs[0].text ok_field = ('TOC' in doc.element.xml) and ('PAGE' in foot) and ('NUMPAGES' in foot) # 只把"正文里残留的 markdown 标记"判为不合格;代码内的字面星号是内容本身(glob 通配),只报数。 bad = [k for k, v in residual.items() if v and k != '代码内字面星号'] leak = forbidden_hits(allt + '\n' + head) # ★对外件不得出现样本场标识 strict = strict_hits(allt) if spec.get('strict_terms') else {} # ★数据要求说明: 去实现细节/去现状 # ★表号断链检查(2026-09-22 实逮): 正文写「见表 9-2」,文档里就必须真有「表 9-2」。 cap_labels = set() for p in doc.paragraphs: mm = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', p.text.strip()) if mm: cap_labels.add(f'{mm.group(1)}-{mm.group(2)}') def _refs(text: str) -> set: """正文里的「表 X-Y」引用。 ★两处实逮的误报都要挡:① `年-月`('报表 2026-09');② **跨单元格拼接** —— 把表格单元用换行 连起来后, "…如实空表" + "FR-18 至 FR-21" 会拼出 '表\\nFR-18',被当成表号。故分隔符只许空格/制表符。 """ out = set() for a, b in re.findall(r'表[ \t]*([\dA-Za-z]+)[ \t]*[-–—][ \t]*(\d+)', text): if re.fullmatch(r'(19|20)\d{2}', a): continue out.add(f'{a}-{b}') return out refs = _refs('\n'.join( [p.text for p in doc.paragraphs if not re.match(r'^(表|图)\s*[\dA-Za-z]+\s*[-–—]\s*\d+', p.text.strip())] + cells)) broken = sorted(refs - cap_labels) noref = sorted(cap_labels - refs) print('%-4s %-38s 段落 %3d · 表 %2d · 图 %2d · 标题 %2d · 页眉 %r' % (key, out.name, len(doc.paragraphs), len(doc.tables), len(doc.inline_shapes), len(heads), head[:28])) print(' 域(目录/页码): %s · 残留标记: %s · 去标识化: %s%s · 表号: %s' % ('OK' if ok_field else '缺', bad or '无', 'clean' if not leak else '泄漏 %s' % leak, '' if not spec.get('strict_terms') else ' · 去实现细节: %s' % ('clean' if not strict else '命中 %s' % strict), '断链 %s' % broken if broken else '引用闭合(表 %d 张,%d 张无引用)' % (len(cap_labels), len(noref)))) return 0 if ok_field and not bad and not leak and not broken and not strict else 5 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('--only', choices=sorted(DOCS)) ap.add_argument('--check', action='store_true', help='只体检源件与图,不渲染') ap.add_argument('--verify', action='store_true', help='渲染后回读自检(计数/残留标记/域)') ap.add_argument('--spec', action='store_true', help='打印排版规范表') a = ap.parse_args() if a.spec: for row in SPEC_ROWS: print(' | '.join(row)) return 0 rc = 0 if a.verify: for k in ([a.only] if a.only else sorted(DOCS)): rc = max(rc, verify_docx(k)) return rc if a.check: for k, spec in DOCS.items(): src = ROOT / spec['src'].format(v=VERSION) if not src.exists(): print('✗ %-4s 源件不在位 %s' % (k, src.relative_to(ROOT).as_posix())) rc = 5 continue st = count_md(src.read_text(encoding='utf-8')) _src_txt = src.read_text(encoding='utf-8') miss, leak = [], forbidden_hits(_src_txt) strict = strict_hits(_src_txt) if spec.get('strict_terms') else {} for _cap, rel in re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', _src_txt): f = ROOT / 'docs' / rel.split('figures/')[-1] if not (ROOT / 'docs' / 'figures' / rel.split('figures/')[-1]).exists(): miss.append(rel) print('%-4s 章 %2d · 节 %2d · 小节 %2d · 表 %2d · 图 %2d · 约 %5d 汉字 · 去标识化 %s%s%s' % (k, st['章'], st['节'], st['小节'], st['表'], st['图'], st['汉字'], 'clean' if not leak else '泄漏 %s' % leak, '' if not spec.get('strict_terms') else ' · 去实现细节 %s' % ('clean' if not strict else '命中 %s' % strict), '' if not miss else ' ✗ 缺图 %d: %s' % (len(miss), ', '.join(miss)))) if miss or leak or strict: rc = 5 return rc for k in ([a.only] if a.only else sorted(DOCS)): rc = max(rc, build(k)) if WARN: print('\n告警:') for w in sorted(set(WARN)): print(' -', w) return rc if __name__ == '__main__': raise SystemExit(main())