@topmindspace/tms-skills 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +9 -3
  3. package/bin/tms-skills.js +200 -160
  4. package/package.json +12 -9
  5. package/top-ppt-html/README.md +162 -254
  6. package/top-ppt-html/SKILL.md +126 -111
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  11. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  12. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  13. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  14. package/top-ppt-html/assets/style-gallery.html +589 -589
  15. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  16. package/top-ppt-html/assets/templates/engine.css +840 -840
  17. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  18. package/top-ppt-html/assets/templates/research.html +4017 -4017
  19. package/top-ppt-html/assets/templates/ui.js +520 -520
  20. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  21. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  22. package/top-ppt-html/assets/theme-overview.png +0 -0
  23. package/top-ppt-html/evals/prompts.csv +15 -15
  24. package/top-ppt-html/evals/rubric.schema.json +25 -25
  25. package/top-ppt-html/evals/run_evals.py +220 -220
  26. package/top-ppt-html/evals/trace.example.json +16 -16
  27. package/top-ppt-html/package-lock.json +186 -0
  28. package/top-ppt-html/package.json +11 -11
  29. package/top-ppt-html/references/charts-basic.md +624 -624
  30. package/top-ppt-html/references/charts-discipline.md +110 -108
  31. package/top-ppt-html/references/charts-extended.md +482 -482
  32. package/top-ppt-html/references/charts.md +37 -28
  33. package/top-ppt-html/references/components-atoms.md +624 -624
  34. package/top-ppt-html/references/components.md +30 -30
  35. package/top-ppt-html/references/content-rules.md +510 -490
  36. package/top-ppt-html/references/default-surface.md +61 -0
  37. package/top-ppt-html/references/design-system.md +478 -478
  38. package/top-ppt-html/references/failure-modes.md +227 -214
  39. package/top-ppt-html/references/high-fidelity.md +127 -127
  40. package/top-ppt-html/references/icons.md +176 -397
  41. package/top-ppt-html/references/infographics-stats.md +308 -308
  42. package/top-ppt-html/references/infographics-structure.md +226 -226
  43. package/top-ppt-html/references/infographics.md +43 -43
  44. package/top-ppt-html/references/layout-grammar.md +325 -315
  45. package/top-ppt-html/references/layouts-architecture.md +108 -108
  46. package/top-ppt-html/references/layouts-combo.md +600 -600
  47. package/top-ppt-html/references/layouts-research.md +160 -160
  48. package/top-ppt-html/references/modes.md +259 -254
  49. package/top-ppt-html/references/outline-design.md +275 -275
  50. package/top-ppt-html/references/playbook.md +284 -266
  51. package/top-ppt-html/references/pptx-export.md +227 -209
  52. package/top-ppt-html/references/presentation-craft.md +69 -0
  53. package/top-ppt-html/references/styles.md +370 -370
  54. package/top-ppt-html/references/tech-design.md +138 -138
  55. package/top-ppt-html/scripts/audit_css.py +109 -109
  56. package/top-ppt-html/scripts/audit_docs.py +176 -176
  57. package/top-ppt-html/scripts/audit_skill.py +231 -220
  58. package/top-ppt-html/scripts/audit_styles.py +351 -351
  59. package/top-ppt-html/scripts/build_examples.py +55 -2276
  60. package/top-ppt-html/scripts/build_pptx.js +2386 -2380
  61. package/top-ppt-html/scripts/capture_theme_overview.js +79 -78
  62. package/top-ppt-html/scripts/checks_html.py +166 -127
  63. package/top-ppt-html/scripts/cross_verify.py +298 -294
  64. package/top-ppt-html/scripts/env_probe.py +158 -158
  65. package/top-ppt-html/scripts/extract_model.py +210 -210
  66. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  67. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  68. package/top-ppt-html/scripts/layout-constants.json +111 -18
  69. package/top-ppt-html/scripts/lib_layout_regions.js +410 -412
  70. package/top-ppt-html/scripts/measure_height.py +178 -178
  71. package/top-ppt-html/scripts/negative_tests.py +433 -307
  72. package/top-ppt-html/scripts/package_skill.py +294 -291
  73. package/top-ppt-html/scripts/prepare_images.py +341 -341
  74. package/top-ppt-html/scripts/probe_image_export.py +187 -188
  75. package/top-ppt-html/scripts/quality_gate.py +309 -301
  76. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  77. package/top-ppt-html/scripts/regression.py +308 -308
  78. package/top-ppt-html/scripts/render_compare.py +275 -275
  79. package/top-ppt-html/scripts/render_from_model.py +698 -698
  80. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  81. package/top-ppt-html/scripts/section-file-map.json +104 -104
  82. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  83. package/top-ppt-html/scripts/sync_runtime.py +659 -662
  84. package/top-ppt-html/scripts/validate_pptx.py +1579 -1510
  85. package/top-ppt-html/scripts/validate_report.py +1755 -1456
  86. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  87. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  88. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  89. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  90. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  91. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  92. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  93. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  94. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  95. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  96. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  97. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  98. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  99. package/top-ppt-html/references/design-system-engine.md +0 -235
  100. package/top-ppt-html/references/industry-benchmark.md +0 -105
  101. package/top-ppt-html/references/reform-plan.md +0 -252
  102. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -1,662 +1,659 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- TopPPT HTML · 单源注入器(版式常量 + DSL schema · 三模板公共片段统一注入)
5
- 用法:
6
- python scripts/sync_runtime.py
7
-
8
- 职责(消除"多处手动同步"债务):
9
- 1. 读 scripts/layout-constants.json(唯一事实源:页面几何 / 12 列网格 / 语义字阶 C0–T14 / 三模式独立比例尺
10
- / 页型几何 / 9 风格 token / 图表登记四元组 / 容器内边距 / 锚点容差 / 图片准入门
11
- / 校验预算 / 强调色表 / 去AI味词表 / 深度模式配置)
12
- 2. 读 scripts/model-schema.json(DSL schema 单源:页型字段与必填约束 + 图表类型白名单 + 数据表策略)
13
- → 注入 assets/pptx-export.js 的 __TOPPPT_SCHEMA__ 标记块(浏览器端 validateModel 消费)
14
- 3. 重新生成 assets/pptx-export.js 与 assets/style-gallery.html 的 __TOPPPT_CONSTANTS__ 标记块
15
- (含 GRID/TYPO/CONT/REG/DEEP、MTS 三模式比例尺、PRESETS/PRESETS_DARK——画廊配色即由此派生,不再手抄色值)
16
- 4. 对 assets/templates/ 三份模式模板注入公共片段(避免三份漂移):
17
- __TOPPPT_ENGINE__ ← assets/templates/engine.css(公共样式引擎)
18
- __TOPPPT_UI__ ← assets/templates/ui.js(公共 UI 脚本:主题/工具组/翻页/动效/预览模态)
19
- __TOPPPT_RUNTIME__ ← assets/pptx-export.js(PPTX 预览运行时内联副本)
20
- 5. 校验双端单源引用:scripts/build_pptx.js 引用 layout-constants.json(Node require);
21
- scripts/extract_model.py 引用 model-schema.json(本地校验同一份 schema)
22
- 6. 校验「页型四件套」完整性:schema 每个页型都要在 layout-constants.json 的 pageTypeGeometry 有几何映射
23
- 7. 校验「图表登记四元组」完整性:charts.types ↔ charts.registry 双向一致、pptx 通道合法、
24
- 非原生图表 dataTable 不得为 off、schema.chartTypes 必须是 registry 子集
25
- 7b. 校验「图表类型可达性」不变量:每个 registry 类型必须可作 chart.type(schema.chartTypes)
26
- 或可作 sections[].type(信息图专属页型)——否则模型无法表达、双引擎实现沦为死代码
27
- 7c. 校验「页型实现可达性」冒烟:schema 每个页型都应出现在 build_pptx.js 与 pptx-export.js 中
28
- (子串级检查,拦「schema 加了页型、引擎没实现」的静默退化)
29
- 7d. 校验「骨架尺寸单源」不变量:charts.scaffold 键 ⊆ registry,且默认值 ≥ charts.minSize
30
- 同口径阈值(拦「scaffold 硬编码第二源漂移」;信息图页型清单亦单源于此)
31
- 8. 校验语义字阶:每个 level 的 role 必须在 modeTypeScale 中存在
32
- 9. 打印常量摘要哈希,供各方核对(pptx-export.js / 三模板 / 画廊 / build_pptx.js)
33
-
34
- 模式模板(生成时确定形态,非运行时切换):
35
- templates/presentation.html · templates/research.html · templates/architecture.html
36
- 各模板自带:模式密度层 + 模式专属组件 + 模式骨架章节 + REPORT_MODEL(mode 已锁定)
37
- """
38
- import json
39
- import re
40
- import hashlib
41
- import sys
42
- from pathlib import Path
43
-
44
- # Windows GBK 控制台兜底:强制 UTF-8 输出
45
- try:
46
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
47
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
48
- except Exception:
49
- pass
50
-
51
- ROOT = Path(__file__).resolve().parent.parent
52
- LC = ROOT / 'scripts' / 'layout-constants.json'
53
- MS = ROOT / 'scripts' / 'model-schema.json'
54
- PE = ROOT / 'assets' / 'pptx-export.js'
55
- GALLERY = ROOT / 'assets' / 'style-gallery.html'
56
- TPL_DIR = ROOT / 'assets' / 'templates'
57
- ENGINE = TPL_DIR / 'engine.css'
58
- UI = TPL_DIR / 'ui.js'
59
- TEMPLATES = [TPL_DIR / 'presentation.html',
60
- TPL_DIR / 'research.html',
61
- TPL_DIR / 'architecture.html']
62
- BJ = ROOT / 'scripts' / 'build_pptx.js'
63
- EM = ROOT / 'scripts' / 'extract_model.py'
64
-
65
- CONST_PAT = re.compile(r'/\* __TOPPPT_CONSTANTS_START__ \*/[\s\S]*?/\* __TOPPPT_CONSTANTS_END__ \*/')
66
- SCHEMA_PAT = re.compile(r'/\* __TOPPPT_SCHEMA_START__ \*/[\s\S]*?/\* __TOPPPT_SCHEMA_END__ \*/')
67
- ENGINE_PAT = re.compile(r'/\* __TOPPPT_ENGINE_START__ \*/[\s\S]*?/\* __TOPPPT_ENGINE_END__ \*/')
68
- UI_PAT = re.compile(r'/\* __TOPPPT_UI_START__ \*/[\s\S]*?/\* __TOPPPT_UI_END__ \*/')
69
- RUNTIME_PAT = re.compile(r'/\* __TOPPPT_RUNTIME_START__ \*/[\s\S]*?/\* __TOPPPT_RUNTIME_END__ \*/')
70
-
71
-
72
- def _strip_comments(obj):
73
- """递归剔除 JSON 对象里的 $comment 键(注入 JS 常量用)。"""
74
- if isinstance(obj, dict):
75
- return {k: _strip_comments(v) for k, v in obj.items() if not k.startswith('$')}
76
- if isinstance(obj, list):
77
- return [_strip_comments(v) for v in obj]
78
- return obj
79
-
80
-
81
- def build_const_block(lc: dict) -> str:
82
- page, pt = lc['page'], lc['pageTypes']
83
- styles = _strip_comments(lc['styles'])
84
- styles_dark = _strip_comments(lc.get('stylesDark') or {})
85
- ts = _strip_comments(lc['typeScale'])
86
- mts = _strip_comments(lc['modeTypeScale'])
87
- def _presets_js(var_name, styles_dict):
88
- if not styles_dict:
89
- return 'var %s = {};' % var_name
90
- return 'var %s = {\n' % var_name + ',\n'.join(
91
- " '%s': { accent:'%s', ink:'%s', body:'%s', faint:'%s', bg:'%s', surface:'%s', line:'%s', soft:'%s', onAccent:'%s', font:'%s', fontDisplay:'%s' }" % (
92
- k, v['accent'], v['ink'], v['body'], v['faint'], v['bg'], v['surface'], v['line'], v['soft'],
93
- v['onAccent'], v['font'], v['fontDisplay'])
94
- for k, v in styles_dict.items()) + '\n};'
95
- presets_js = _presets_js('PRESETS', styles)
96
- presets_dark_js = _presets_js('PRESETS_DARK', styles_dark)
97
- ts_js = 'var TS_BASE = ' + json.dumps(ts, separators=(',', ':')) + ';'
98
- mts_js = 'var MTS = ' + json.dumps(mts, separators=(',', ':')) + ';'
99
- pt_js = 'var PT = ' + json.dumps(_strip_comments(pt), ensure_ascii=False, separators=(',', ':')) + ';'
100
- colors_js = 'var STYLE_DATA_COLORS = ' + json.dumps(
101
- _strip_comments(lc.get('styleDataColors') or {}), separators=(',', ':')) + ';'
102
- colors_dark_js = 'var STYLE_DATA_COLORS_DARK = ' + json.dumps(
103
- _strip_comments(lc.get('styleDataColorsDark') or {}), separators=(',', ':')) + ';'
104
- # 12 列网格 / 语义字阶 / 容器内边距 / 图表登记四元组 / 深度模式配置
105
- grid_js = 'var GRID = ' + json.dumps(_strip_comments(lc['grid']), separators=(',', ':')) + ';'
106
- typo_js = 'var TYPO = ' + json.dumps(_strip_comments(lc['typography']), ensure_ascii=False,
107
- separators=(',', ':')) + ';'
108
- cont_js = 'var CONT = ' + json.dumps(_strip_comments(lc['containers']), ensure_ascii=False,
109
- separators=(',', ':')) + ';'
110
- reg_js = 'var REG = ' + json.dumps(_strip_comments((lc.get('charts') or {}).get('registry') or {}),
111
- ensure_ascii=False, separators=(',', ':')) + ';'
112
- deep_js = 'var DEEP = ' + json.dumps(_strip_comments(lc['deepMode']), ensure_ascii=False,
113
- separators=(',', ':')) + ';'
114
- # 图片规格与配图占位约定(双引擎拼同一占位文本,cross_verify 逐页文本一致)
115
- img_js = 'var IMG = ' + json.dumps(_strip_comments(lc.get('imageSpec') or {}), ensure_ascii=False,
116
- separators=(',', ':')) + ';'
117
- # 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源公式;浏览器端 A 通道消费)
118
- slots_path = ROOT / 'scripts' / 'layout_slots.json'
119
- slots = {}
120
- if slots_path.exists():
121
- try:
122
- slots = json.loads(slots_path.read_text(encoding='utf-8'))
123
- except json.JSONDecodeError:
124
- slots = {}
125
- slots_js = 'var LAYOUT_SLOTS = ' + json.dumps(_strip_comments(slots), ensure_ascii=False,
126
- separators=(',', ':')) + ';'
127
- region_fn = r'''
128
- /* regionOf(pageType, slotId, opts) — 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源) */
129
- function regionOf(pageType, slotId, opts) {
130
- var type = String(pageType || 'points'), slot = String(slotId || '');
131
- opts = opts || {};
132
- var L = PT.layout || {}, C = PT.common || {};
133
- var topDefault = C.bodyY || 2.5;
134
- var bottomDefault = opts.withNote ? (L.contentBottomWithNote || 6.4) : (L.contentBottom || 6.9);
135
- if (slot === 'head' || slot === 'chrome') {
136
- return { x: MX, y: C.headEyebrowY || 0.55, w: CW,
137
- h: Math.max(0.4, (C.headRuleY || 1.95) - (C.headEyebrowY || 0.55)),
138
- titleY: C.headTitleY, ruleY: C.headRuleY, leadY: C.leadY, bodyY: C.bodyY };
139
- }
140
- if (slot === 'annotation' || slot === 'soWhat') {
141
- var sy = (PT.exhibit && PT.exhibit.soWhatY != null) ? PT.exhibit.soWhatY : 6.05;
142
- return { x: MX, y: sy, w: CW, h: 0.62 };
143
- }
144
- if (slot === 'footnote') {
145
- var fy = (PT.exhibit && PT.exhibit.footnoteY != null) ? PT.exhibit.footnoteY : ((PT.note && PT.note.y) || 6.55);
146
- return { x: MX, y: fy, w: CW - 1.2, h: (PT.note && PT.note.h) || 0.32 };
147
- }
148
- var P = PT[type] || {};
149
- if (type === 'exhibit' && slot === 'primary') {
150
- var ey = P.chartY || 2.95;
151
- return { x: MX, y: ey, w: CW, h: Math.max(1.2, (L.contentBottomWithNote || 6.4) - ey), badgeY: P.badgeY };
152
- }
153
- if (type === 'metrics' && slot === 'primary') {
154
- return { x: MX, y: P.bandY || 3.1, w: CW, h: P.cardH || 2.3, gap: P.gap, maxW: P.maxW,
155
- valY: P.valY, valH: P.valH, capY: P.capY, capH: P.capH };
156
- }
157
- if (type === 'table' && slot === 'primary') {
158
- var ty = P.y || topDefault;
159
- return { x: MX, y: ty, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - ty) };
160
- }
161
- if (type === 'bar' && slot === 'primary') {
162
- var by = P.chartY || 2.95;
163
- var bb = opts.bottom != null ? opts.bottom : bottomDefault;
164
- return { x: MX + (P.chartX || 0), y: by, w: CW - (P.chartW || 0), h: Math.max(1.2, bb - by) };
165
- }
166
- if (type === 'bar' && slot === 'hbar') {
167
- var y0 = P.hbarY0 || 2.6;
168
- var y1 = opts.bottom != null ? opts.bottom : (P.hbarY1 || 6.25);
169
- return { x: MX, y: y0, w: CW, h: Math.max(1.2, y1 - y0) };
170
- }
171
- if (type === 'points' && (slot === 'primary' || slot === 'list')) {
172
- var ly = P.listY || topDefault;
173
- return { x: MX, y: ly, w: CW, h: Math.max(0.8, (opts.bottom != null ? opts.bottom : bottomDefault) - ly) };
174
- }
175
- if (type === 'split' && (slot === 'left' || slot === 'right')) {
176
- var st = P.chartY || topDefault;
177
- var sb = opts.bottom != null ? opts.bottom : bottomDefault;
178
- if (slot === 'left') return { x: MX, y: st, w: CW * (P.leftW || 0.55), h: Math.max(1.2, sb - st) };
179
- return { x: MX + CW * (P.rightX != null ? P.rightX : 0.585), y: st, w: CW * (P.rightW || 0.415), h: Math.max(1.2, sb - st) };
180
- }
181
- if (type === 'twocol' && (slot === 'primary' || slot === 'left' || slot === 'right')) {
182
- var gap = P.colGap || 0.5, colW = (CW - gap) / 2;
183
- var tt = opts.top != null ? opts.top : topDefault;
184
- var tb = opts.bottom != null ? opts.bottom : bottomDefault;
185
- if (slot === 'right') return { x: MX + colW + gap, y: tt, w: colW, h: Math.max(1, tb - tt) };
186
- return { x: MX, y: tt, w: colW, h: Math.max(1, tb - tt) };
187
- }
188
- if ((type === 'diagram' || type === 'lane') && slot === 'primary') {
189
- var arch = PT.arch || {};
190
- var dy = (opts.mode === 'architecture' && arch.fullStartY != null) ? arch.fullStartY : (P.bodyStartY || 2.9);
191
- var db = (opts.mode === 'architecture' && arch.fullEndY != null) ? arch.fullEndY
192
- : (opts.bottom != null ? opts.bottom : bottomDefault);
193
- return { x: MX, y: dy, w: CW, h: Math.max(1.2, db - dy) };
194
- }
195
- if (type === 'kpi' && (slot === 'hero' || slot === 'primary')) {
196
- return { x: MX, y: P.heroY || 2.9, w: CW * (P.heroW || 0.52), h: P.heroH || 1.6,
197
- dividerX: P.dividerX, metricY0: P.metricY0, metricRowH: P.metricRowH };
198
- }
199
- if (type === 'comparison' && (slot === 'left' || slot === 'primary' || slot === 'right')) {
200
- var cg = P.panelGap || 0.4, pw = (CW - cg) / 2;
201
- if (slot === 'right') return { x: MX + pw + cg, y: P.panelY || 2.55, w: pw, h: P.panelH || 3.4 };
202
- return { x: MX, y: P.panelY || 2.55, w: pw, h: P.panelH || 3.4 };
203
- }
204
- if (type === 'quote' && slot === 'primary') {
205
- return { x: MX + 1.05, y: P.textY || 2.7, w: CW - 2.1, h: P.textH || 2.3 };
206
- }
207
- if (type === 'heatmap' && slot === 'primary') {
208
- var hy = P.y || 2.6;
209
- return { x: MX, y: hy, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - hy) };
210
- }
211
- if (type === 'bullet' && slot === 'primary') {
212
- var uy = P.y || 2.6;
213
- return { x: MX, y: uy, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - uy) };
214
- }
215
- if (type === 'pyramid' && slot === 'primary') {
216
- var py2 = P.y || 2.35;
217
- return { x: MX, y: py2, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - py2) };
218
- }
219
- if (type === 'steps' && slot === 'primary') {
220
- return { x: MX, y: P.y || 3.05, w: CW, h: P.barH || 1.35 };
221
- }
222
- if (type === 'cards' && slot === 'primary') {
223
- var cy2 = P.startY || 3.0;
224
- return { x: MX, y: cy2, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - cy2) };
225
- }
226
- if (type === 'timeline' && slot === 'primary') {
227
- return { x: MX, y: P.labelY || 2.95, w: CW, h: Math.max(1, bottomDefault - (P.labelY || 2.95)) };
228
- }
229
- if (type === 'image' && slot === 'primary') {
230
- var iy2 = Math.max(P.y || 2.3, opts.top || 0);
231
- return { x: MX, y: iy2, w: CW, h: Math.max(1.2, Math.min(P.h || 4.2, (opts.bottom != null ? opts.bottom : bottomDefault) - iy2)) };
232
- }
233
- if ((type === 'halftable') && (slot === 'left' || slot === 'primary')) {
234
- var rs = PT.research || {};
235
- return { x: MX, y: topDefault, w: CW * (rs.halfTableW || 0.52), h: Math.max(1, bottomDefault - topDefault) };
236
- }
237
- if ((type === 'halftable') && (slot === 'right' || slot === 'secondary')) {
238
- var rs2 = PT.research || {};
239
- return { x: MX + CW * (rs2.halfChartX || 0.57), y: topDefault, w: CW * (rs2.halfChartW || 0.43), h: Math.max(1, bottomDefault - topDefault) };
240
- }
241
- if (type === 'matrix' && slot === 'primary') {
242
- var rm = PT.research || {};
243
- var my = rm.matrixY || 2.3;
244
- return { x: MX, y: my, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - my) };
245
- }
246
- var INFO = { sankey:1, treemap:1, boxplot:1, network:1, marimekko:1, streamgraph:1 };
247
- if (INFO[type] && slot === 'primary') {
248
- var iy3 = P.y || topDefault;
249
- var ih3 = Math.max(1.2, Math.min(P.h || 4.1, (opts.bottom != null ? opts.bottom : bottomDefault) - iy3));
250
- return { x: MX, y: iy3, w: CW, h: ih3 };
251
- }
252
- return { x: MX, y: topDefault, w: CW, h: Math.max(0.8, bottomDefault - topDefault) };
253
- }
254
- '''
255
- return '\n'.join([
256
- '/* __TOPPPT_CONSTANTS_START__ */',
257
- '/* ── 由 scripts/sync_runtime.py 从 scripts/layout-constants.json 注入 · 禁止手改 ── */',
258
- '/* MTS = 三模式独立排版比例尺(按模式取 pt;基准 TS_BASE = presentation 档) */',
259
- '/* PRESETS_DARK = 风格 dark token(REPORT_MODEL.theme="dark" 时导出深色版)*/',
260
- '/* GRID = 12 列网格;TYPO = 语义字阶 C0–T14;CONT = 容器内边距;REG = 图表登记四元组;DEEP = 深度模式配置 */',
261
- '/* IMG = 素材图片规格与配图占位约定(版式/裁切/建议尺寸/占位标签,双引擎同源) */',
262
- '/* STYLE_DATA_COLORS(_DARK) = 9 套风格各自的编码色板 c1–c5(数据系列;结构色纪律不变) */',
263
- '/* LAYOUT_SLOTS + regionOf = 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源) */',
264
- 'var PW = %s, PH = %s, MX = %s, CW = PW - 2 * MX, EMU = %s;'
265
- % (page['pw'], page['ph'], page['mx'], page['emu']),
266
- ts_js, mts_js, pt_js, presets_js, presets_dark_js, colors_js, colors_dark_js,
267
- grid_js, typo_js, cont_js, reg_js, deep_js, img_js, slots_js, region_fn,
268
- '/* __TOPPPT_CONSTANTS_END__ */'])
269
-
270
-
271
- def build_schema_block(ms: dict) -> str:
272
- return '\n'.join([
273
- '/* __TOPPPT_SCHEMA_START__ */',
274
- '/* ── 由 scripts/sync_runtime.py 从 scripts/model-schema.json 注入 · 禁止手改 ── */',
275
- '/* DSL schema 单源:浏览器端 validateModel 与 scripts/extract_model.py 消费同一份定义 */',
276
- 'var MODEL_SCHEMA = ' + json.dumps(_strip_comments(ms), ensure_ascii=False, separators=(',', ':')) + ';',
277
- '/* __TOPPPT_SCHEMA_END__ */'])
278
-
279
-
280
- def inject(path: Path, pat: re.Pattern, repl: str, label: str) -> bool:
281
- src = path.read_text(encoding='utf-8')
282
- if not pat.search(src):
283
- print(f'错误: {path.name} 缺少 {label} 标记块')
284
- return False
285
- path.write_text(pat.sub(lambda m: repl, src, count=1), encoding='utf-8')
286
- return True
287
-
288
-
289
- def main() -> int:
290
- if not LC.exists():
291
- print(f'错误: 找不到 {LC}')
292
- return 2
293
- lc = json.loads(LC.read_text(encoding='utf-8'))
294
- if not MS.exists():
295
- print(f'错误: 找不到 {MS}(DSL schema 单源)')
296
- return 2
297
- ms = json.loads(MS.read_text(encoding='utf-8'))
298
- ok = True
299
-
300
- # ① 重新生成 pptx-export.js 常量块 + schema 块
301
- src = PE.read_text(encoding='utf-8')
302
- if not CONST_PAT.search(src):
303
- print('错误: assets/pptx-export.js 缺少 __TOPPPT_CONSTANTS__ 标记块')
304
- return 1
305
- if not SCHEMA_PAT.search(src):
306
- print('错误: assets/pptx-export.js 缺少 __TOPPPT_SCHEMA__ 标记块')
307
- return 1
308
- block = build_const_block(lc)
309
- PE.write_text(CONST_PAT.sub(lambda m: block, src), encoding='utf-8')
310
- src = PE.read_text(encoding='utf-8')
311
- schema_block = build_schema_block(ms)
312
- PE.write_text(SCHEMA_PAT.sub(lambda m: schema_block, src), encoding='utf-8')
313
-
314
- # ①-b 风格画廊:同一份常量块注入(画廊配色改为从 PRESETS/PRESETS_DARK 派生,消除第四处手抄色值)
315
- if GALLERY.exists():
316
- gsrc = GALLERY.read_text(encoding='utf-8')
317
- if CONST_PAT.search(gsrc):
318
- GALLERY.write_text(CONST_PAT.sub(lambda m: block, gsrc), encoding='utf-8')
319
- else:
320
- print('警告: assets/style-gallery.html 缺少 __TOPPPT_CONSTANTS__ 标记块(画廊配色无法单源化)')
321
- ok = False
322
-
323
- # ② 公共片段源
324
- if not ENGINE.exists() or not UI.exists():
325
- print(f'错误: 缺少公共片段 {ENGINE} / {UI}')
326
- return 1
327
- engine_css = ENGINE.read_text(encoding='utf-8').rstrip('\n')
328
- ui_js = UI.read_text(encoding='utf-8').rstrip('\n')
329
- runtime = PE.read_text(encoding='utf-8').rstrip('\n')
330
-
331
- engine_repl = ('/* __TOPPPT_ENGINE_START__ */\n'
332
- '/* ══ TopPPT HTML 公共样式引擎(assets/templates/engine.css 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
333
- + engine_css + '\n/* __TOPPPT_ENGINE_END__ */')
334
- ui_repl = ('/* __TOPPPT_UI_START__ */\n'
335
- '/* ══ TopPPT HTML 公共 UI 脚本(assets/templates/ui.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
336
- + ui_js + '\n/* __TOPPPT_UI_END__ */')
337
- runtime_repl = ('/* __TOPPPT_RUNTIME_START__ */\n'
338
- '/* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
339
- + runtime + '\n/* __TOPPPT_RUNTIME_END__ */')
340
-
341
- # ③ 注入三份模式模板
342
- for tpl in TEMPLATES:
343
- if not tpl.exists():
344
- print(f'警告: 模板不存在 {tpl}')
345
- ok = False
346
- continue
347
- ok = inject(tpl, ENGINE_PAT, engine_repl, '__TOPPPT_ENGINE__') and ok
348
- ok = inject(tpl, UI_PAT, ui_repl, '__TOPPPT_UI__') and ok
349
- ok = inject(tpl, RUNTIME_PAT, runtime_repl, '__TOPPPT_RUNTIME__') and ok
350
-
351
- # ④ 校验双端单源引用(build_pptx.js → layout-constants.json;extract_model.py → model-schema.json)
352
- if BJ.exists():
353
- bj = BJ.read_text(encoding='utf-8')
354
- if 'layout-constants.json' not in bj:
355
- print('警告: scripts/build_pptx.js 未引用 layout-constants.json(常量可能漂移)')
356
- ok = False
357
- else:
358
- print('警告: scripts/build_pptx.js 不存在')
359
- ok = False
360
- if EM.exists():
361
- em = EM.read_text(encoding='utf-8')
362
- if 'model-schema.json' not in em:
363
- print('警告: scripts/extract_model.py 未引用 model-schema.json(schema 可能漂移)')
364
- ok = False
365
- else:
366
- print('警告: scripts/extract_model.py 不存在')
367
- ok = False
368
-
369
- # ⑤ 页型四件套完整性:schema 每个页型都要有几何映射,且映射指向存在的几何组
370
- pt = lc.get('pageTypes') or {}
371
- p2g = lc.get('pageTypeGeometry') or {}
372
- geom_keys = {k for k in pt if not k.startswith('$')}
373
- schema_types = [k for k in (ms.get('pageTypes') or {}) if not k.startswith('$')]
374
- for t in schema_types:
375
- g = p2g.get(t)
376
- if not g:
377
- print(f'警告: 页型 {t!r} 在 model-schema.json 中,但 layout-constants.json 的 pageTypeGeometry 缺映射')
378
- ok = False
379
- elif g not in geom_keys:
380
- print(f'警告: 页型 {t!r} 映射到不存在的几何组 {g!r}')
381
- ok = False
382
- orphan = [t for t in p2g if not t.startswith('$') and t not in schema_types]
383
- if orphan:
384
- print(f'警告: pageTypeGeometry 含未登记页型 {orphan}(schema 与几何不同步)')
385
- ok = False
386
-
387
- # ⑥ 图表登记四元组完整性:charts.types 与 charts.registry 双向一致,且 registry 条目字段齐全
388
- chart_types = [t for t in ((lc.get('charts') or {}).get('types') or [])]
389
- registry = (lc.get('charts') or {}).get('registry') or {}
390
- for t in chart_types:
391
- if t not in registry:
392
- print(f'警告: 图表类型 {t!r} 在 charts.types 中,但 charts.registry 缺登记')
393
- ok = False
394
- for t, spec in registry.items():
395
- if t.startswith('$'):
396
- continue
397
- if t not in chart_types:
398
- print(f'警告: charts.registry 含未登记类型 {t!r}(types 与 registry 不同步)')
399
- ok = False
400
- if spec.get('pptx') not in ('native', 'shape'):
401
- print(f'警告: 图表 {t!r} 的 pptx 通道非法(应为 native / shape)')
402
- ok = False
403
- if not spec.get('dataTable'):
404
- print(f'警告: 图表 {t!r} 缺 dataTable 策略')
405
- ok = False
406
- if spec.get('pptx') == 'shape' and spec.get('dataTable') == 'off' and t != 'sparkline':
407
- print(f'警告: 非原生图表 {t!r} 的 dataTable 不得为 off(数据可追溯铁律)')
408
- ok = False
409
-
410
- # ⑦ 图表类型白名单 ↔ 登记表一致(schema.chartTypes 必须是 registry 的子集)
411
- schema_charts = [c for c in (ms.get('chartTypes') or [])]
412
- unknown_charts = [c for c in schema_charts if c not in registry]
413
- if unknown_charts:
414
- print(f'警告: model-schema.json chartTypes 含 registry 未登记类型 {unknown_charts}')
415
- ok = False
416
-
417
- # ⑦-b 可达性不变量:每个 registry 类型都必须「模型可表达」,否则双引擎实现是死代码。
418
- # 可达 = 可作 chart.type(在 schema.chartTypes)∪ 可作 sections[].type(信息图专属页型)。
419
- # 信息图页型清单单源 = charts.scaffold.infoTypes(scaffold/extract_snippet 同源消费)。
420
- scaff = (lc.get('charts') or {}).get('scaffold') or {}
421
- info_page_types = set(scaff.get('infoTypes') or {})
422
- unreachable = [t for t in chart_types if t not in schema_charts and t not in info_page_types]
423
- if unreachable:
424
- print(f'警告: 图表类型 {unreachable} 既不在 schema.chartTypes 也不是信息图专属页型 —— '
425
- '模型无法表达,双引擎实现将成为死代码')
426
- ok = False
427
- ghost_pages = sorted(t for t in info_page_types if t not in schema_types)
428
- if ghost_pages:
429
- print(f'警告: 信息图页型 {ghost_pages} 未登记在 schema.pageTypes(可达性口径失效)')
430
- ok = False
431
- for t in info_page_types:
432
- if t in schema_charts:
433
- print(f'警告: 信息图页型 {t!r} 同时出现在 chartTypes —— 二者载荷不同,'
434
- '会造成 chart 对象字段歧义')
435
- ok = False
436
-
437
- # ⑦-d 骨架尺寸单源不变量:charts.scaffold 是 scaffold_report.py 骨架尺寸的唯一事实源——
438
- # ① 键必须 ⊆ registry(防孤儿尺寸)② 默认值 ≥ charts.minSize 同口径阈值(防骨架天生不达标)
439
- if not scaff:
440
- print('警告: layout-constants.json 缺 charts.scaffold(骨架尺寸退回脚本硬编码第二源)')
441
- ok = False
442
- else:
443
- min_size = (lc.get('charts') or {}).get('minSize') or {}
444
- legal = set(chart_types)
445
- for caliber, key in (('viewBoxHeight', 'vbHeightMin'), ('pxWidth', 'pxWidthMin')):
446
- for t, v in (scaff.get(caliber) or {}).items():
447
- if t not in legal:
448
- print(f'警告: charts.scaffold.{caliber} 含 registry 未登记类型 {t!r}')
449
- ok = False
450
- floor = (min_size.get(t) or {}).get(key)
451
- if floor is not None and v < floor:
452
- print(f'警告: charts.scaffold.{caliber}[{t!r}]={v} < minSize.{key}={floor}'
453
- '(骨架默认值天生不达标)')
454
- ok = False
455
- orphan_info = sorted(t for t in info_page_types if t not in legal)
456
- if orphan_info:
457
- print(f'警告: charts.scaffold.infoTypes 含 registry 未登记类型 {orphan_info}')
458
- ok = False
459
-
460
- # ⑦-c 页型实现可达性(冒烟):schema 的每个页型都应在双引擎源码中出现,
461
- # 否则「schema 加了页型、引擎没实现」会静默退化为默认版式。
462
- # 注:这是子串级冒烟检查(不解析 AST),只能拦住「完全没提」的漏实现。
463
- for eng_name, eng_path in (('scripts/build_pptx.js', ROOT / 'scripts' / 'build_pptx.js'),
464
- ('assets/pptx-export.js', ROOT / 'assets' / 'pptx-export.js')):
465
- try:
466
- eng_txt = eng_path.read_text(encoding='utf-8')
467
- except OSError:
468
- print(f'警告: 无法读取 {eng_name},跳过页型实现核查')
469
- ok = False
470
- continue
471
- eng_missing = [t for t in schema_types if t not in eng_txt]
472
- if eng_missing:
473
- print(f'警告: {eng_name} 未见页型 {eng_missing}(schema 已登记但引擎未提及)')
474
- ok = False
475
-
476
- # ⑧ 语义字阶完整性:每个 level 的 role 必须在 modeTypeScale 的角色集合中存在
477
- mts = lc['modeTypeScale']
478
- mts_roles = {k for k in (mts.get('presentation') or {})}
479
- for lv in (lc.get('typography') or {}).get('levels') or []:
480
- if lv.get('role') not in mts_roles:
481
- print(f'警告: 语义字阶 {lv.get("id")} 指向不存在的比例尺角色 {lv.get("role")!r}')
482
- ok = False
483
-
484
- # ⑨ 图片系统完整性:imageSpec 版式在双引擎可表达 + 占位标签三要素齐全 +
485
- # image 页型三选一必填口径(src/items/placeholder)。拦「schema 放宽了、引擎没实现」
486
- # 与「占位文本拼不出来」两类静默退化。
487
- img = lc.get('imageSpec') or {}
488
- img_layouts = [x for x in (img.get('layouts') or [])]
489
- if not img_layouts:
490
- print('警告: layout-constants.json 缺 imageSpec.layouts(素材图片版式无单源)')
491
- ok = False
492
- for key in ('placeholderLabel', 'placeholderHintPrefix', 'placeholderHintSuffix', 'recommendedSizePx'):
493
- if not img.get(key):
494
- print(f'警告: imageSpec 缺 {key}(配图占位文本无法由双引擎同源拼接)')
495
- ok = False
496
- for lay in img_layouts:
497
- if lay not in (img.get('recommendedSizePx') or {}):
498
- print(f'警告: imageSpec.recommendedSizePx 缺版式 {lay!r} 的建议尺寸')
499
- ok = False
500
- # 版式比例:每个版式都要有 ratioDefault(双引擎据此算高度)+ ratioCssClass(HTML 锁比例类)
501
- ratio_def = img.get('ratioDefault') or {}
502
- ratio_css = img.get('ratioCssClass') or {}
503
- engine_css = (TPL_DIR / 'engine.css').read_text(encoding='utf-8') if (TPL_DIR / 'engine.css').exists() else ''
504
- for lay in img_layouts:
505
- if not ratio_def.get(lay):
506
- print(f'警告: imageSpec.ratioDefault 缺版式 {lay!r} 的比例(HTML/PPTX 版式会不一致)')
507
- ok = False
508
- cls = ratio_css.get(lay)
509
- if not cls:
510
- print(f'警告: imageSpec.ratioCssClass 缺版式 {lay!r} 的锁定类')
511
- ok = False
512
- elif engine_css and ('.' + cls) not in engine_css:
513
- print(f'警告: engine.css 缺图片比例锁定类 .{cls}(HTML 侧无法锁定 {lay} 版式比例)')
514
- ok = False
515
- img_def = (ms.get('pageTypes') or {}).get('image') or {}
516
- if not any(str(s).startswith('anyof:') for s in (img_def.get('required') or [])):
517
- print('警告: model-schema.json 的 image 页型未用 anyof 三选一(src/items/placeholder)')
518
- ok = False
519
- for eng_name, eng_path in (('scripts/build_pptx.js', ROOT / 'scripts' / 'build_pptx.js'),
520
- ('assets/pptx-export.js', ROOT / 'assets' / 'pptx-export.js')):
521
- try:
522
- eng_txt = eng_path.read_text(encoding='utf-8')
523
- except OSError:
524
- continue
525
- eng_missing_lay = [lay for lay in img_layouts
526
- if lay not in ('full', 'half') and ("'%s'" % lay) not in eng_txt]
527
- if eng_missing_lay:
528
- print(f'警告: {eng_name} 未见图片版式 {eng_missing_lay}(imageSpec 已登记但引擎未提及)')
529
- ok = False
530
-
531
- # ⑩ 页型布局 IR(layout_slots.json):高频页型槽位完整性
532
- slots_path = ROOT / 'scripts' / 'layout_slots.json'
533
- n_slots = 0
534
- if not slots_path.exists():
535
- print('警告: 缺 scripts/layout_slots.json(页型布局 IR 单源)')
536
- ok = False
537
- else:
538
- try:
539
- slots_doc = json.loads(slots_path.read_text(encoding='utf-8'))
540
- slots_pages = slots_doc.get('pageTypes') or {}
541
- for pt_name, pt_def in slots_pages.items():
542
- if pt_name not in schema_types:
543
- print(f'警告: layout_slots 页型 {pt_name!r} 不在 schema.pageTypes')
544
- ok = False
545
- slot_list = pt_def.get('slots') or []
546
- if not slot_list:
547
- print(f'警告: layout_slots 页型 {pt_name!r} 无槽位定义')
548
- ok = False
549
- continue
550
- ids = [s.get('id') for s in slot_list]
551
- if 'head' not in ids or not any(s.get('role') == 'primary' for s in slot_list):
552
- print(f'警告: layout_slots 页型 {pt_name!r} 缺 head 或 primary 槽位')
553
- ok = False
554
- n_slots = len(slots_pages)
555
- # 布局 IR 模块存在性(B 通道 require)
556
- ir_mod = ROOT / 'scripts' / 'lib_layout_regions.js'
557
- if not ir_mod.exists():
558
- print('警告: 缺 scripts/lib_layout_regions.js(布局区域 IR 模块)')
559
- ok = False
560
- else:
561
- ir_txt = ir_mod.read_text(encoding='utf-8')
562
- for key in ('regionOf', 'layout_slots.json', 'layout-constants.json'):
563
- if key not in ir_txt:
564
- print(f'警告: lib_layout_regions.js 缺少 {key!r} 引用')
565
- ok = False
566
- # 布局 IR 烟测:regionOf 与 pageTypes 常量一致(防公式漂移)
567
- try:
568
- import subprocess as _sp
569
- smoke_js = r'''
570
- const R = require("./scripts/lib_layout_regions.js");
571
- const LC = R.LC;
572
- const cases = [
573
- ["exhibit","primary", LC.pageTypes.exhibit.chartY],
574
- ["metrics","primary", LC.pageTypes.metrics.bandY],
575
- ["head","head", LC.pageTypes.common.headEyebrowY],
576
- ["bar","primary", LC.pageTypes.bar.chartY],
577
- ["table","primary", LC.pageTypes.table.y],
578
- ];
579
- let bad = 0;
580
- for (const [t,s,expectY] of cases) {
581
- const r = R.regionOf(t, s, {});
582
- if (!r || Math.abs(r.y - expectY) > 0.001) {
583
- console.error("IR drift", t, s, r && r.y, expectY);
584
- bad++;
585
- }
586
- }
587
- process.exit(bad ? 1 : 0);
588
- '''
589
- node = None
590
- try:
591
- sys.path.insert(0, str(ROOT / 'scripts'))
592
- from env_probe import find_node # noqa: E402
593
- node = find_node()
594
- except Exception:
595
- node = None
596
- if node:
597
- rsm = _sp.run([node, '-e', smoke_js], cwd=str(ROOT),
598
- capture_output=True, text=True)
599
- if rsm.returncode != 0:
600
- print('警告: lib_layout_regions regionOf 烟测失败(与 pageTypes 不一致)')
601
- print((rsm.stderr or rsm.stdout or '')[:400])
602
- ok = False
603
- except Exception as exc:
604
- print(f'警告: 布局 IR 烟测未执行: {exc}')
605
- except json.JSONDecodeError as exc:
606
- print(f'警告: layout_slots.json 解析失败: {exc}')
607
- ok = False
608
- n_slots = 0
609
-
610
- # ⑪ 版本单源一致性:layout-constants.json `version` 是唯一事实源;
611
- # model-schema / layout_slots / package.json(npm 语义化 x.y.z)漂移即 WARN
612
- # (v8.3 教训:SKILL.md frontmatter 移除 version 后,五处版本一度只有一处升级)
613
- ver = lc.get('version')
614
- if not ver:
615
- print('警告: layout-constants.json 缺 version(版本唯一事实源)')
616
- ok = False
617
- else:
618
- if ms.get('version') != ver:
619
- print(f'警告: model-schema.json version={ms.get("version")!r} ≠ layout-constants.json {ver!r}')
620
- ok = False
621
- try:
622
- slots_ver = (json.loads((ROOT / 'scripts' / 'layout_slots.json').read_text(encoding='utf-8'))
623
- .get('version'))
624
- if slots_ver != ver:
625
- print(f'警告: layout_slots.json version={slots_ver!r} ≠ {ver!r}')
626
- ok = False
627
- except (OSError, json.JSONDecodeError):
628
- pass # ⑩ 已告警
629
- try:
630
- pj_ver = json.loads((ROOT / 'package.json').read_text(encoding='utf-8')).get('version')
631
- if not str(pj_ver or '').startswith(f'{ver}.'):
632
- print(f'警告: package.json version={pj_ver!r} 未对齐 {ver}.x(npm 语义化)')
633
- ok = False
634
- except (OSError, json.JSONDecodeError):
635
- pass
636
-
637
- # ⑤ 摘要哈希(各方核对用)
638
- raw = LC.read_text(encoding='utf-8')
639
- digest = hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]
640
- ms_raw = MS.read_text(encoding='utf-8')
641
- ms_digest = hashlib.sha256(ms_raw.encode('utf-8')).hexdigest()[:16]
642
- n_styles = len(lc['styles'])
643
- n_pt = len([k for k in lc['pageTypes'] if not k.startswith('$')])
644
- n_schema_pt = len([k for k in ms['pageTypes'] if not k.startswith('$')])
645
- n_charts = len((lc.get('charts') or {}).get('types') or [])
646
- reg = (lc.get('charts') or {}).get('registry') or {}
647
- n_native = len([t for t, s in reg.items() if not t.startswith('$') and s.get('pptx') == 'native'])
648
- n_shape = len([t for t, s in reg.items() if not t.startswith('$') and s.get('pptx') == 'shape'])
649
- n_typo = len((lc.get('typography') or {}).get('levels') or [])
650
- print('单源注入完成:')
651
- print(f' layout-constants.json sha256[:16] = {digest}')
652
- print(f' model-schema.json sha256[:16] = {ms_digest} · 页型 {n_schema_pt} 种')
653
- print(f' 风格 token {n_styles} 套 · 三模式独立比例尺 {len([k for k in lc["modeTypeScale"] if not k.startswith("$")])} 套 · 页型几何 {n_pt} 组')
654
- print(f' 图表登记 {n_charts} 种(原生 {n_native} / 形状 {n_shape}) · 语义字阶 {n_typo} 级 · 12 列网格 {lc["grid"]["columns"]} 列 · 布局 IR {n_slots} 页型')
655
- print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html(引擎/UI/运行时内联副本)')
656
- print(f' 双端单源引用校验: {"PASS" if ok else "WARN(build_pptx 应 require layout-constants.json;extract_model 应读 model-schema.json;页型四件套与图表登记须完整)"}')
657
- print(f' 说明: 页型几何 {n_pt} 组(多页型共享几何组) · schema 页型 {n_schema_pt} 种')
658
- return 0
659
-
660
-
661
- if __name__ == '__main__':
662
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ TopPPT HTML · 单源注入器(版式常量 + DSL schema · 三模板公共片段统一注入)
5
+ 用法:
6
+ python scripts/sync_runtime.py
7
+
8
+ 职责(消除"多处手动同步"债务):
9
+ 1. 读 scripts/layout-constants.json(唯一事实源:页面几何 / 12 列网格 / 语义字阶 C0–T14 / 三模式独立比例尺
10
+ / 页型几何 / 9 风格 token / 图表登记四元组 / 容器内边距 / 锚点容差 / 图片准入门
11
+ / 校验预算 / 强调色表 / 去AI味词表 / 深度模式配置)
12
+ 2. 读 scripts/model-schema.json(DSL schema 单源:页型字段与必填约束 + 图表类型白名单 + 数据表策略)
13
+ → 注入 assets/pptx-export.js 的 __TOPPPT_SCHEMA__ 标记块(浏览器端 validateModel 消费)
14
+ 3. 重新生成 assets/pptx-export.js 与 assets/style-gallery.html 的 __TOPPPT_CONSTANTS__ 标记块
15
+ (含 GRID/TYPO/CONT/REG/DEEP、MTS 三模式比例尺、PRESETS/PRESETS_DARK——画廊配色即由此派生,不再手抄色值)
16
+ 4. 对 assets/templates/ 三份模式模板注入公共片段(避免三份漂移):
17
+ __TOPPPT_ENGINE__ ← assets/templates/engine.css(公共样式引擎)
18
+ __TOPPPT_UI__ ← assets/templates/ui.js(公共 UI 脚本:主题/工具组/翻页/动效/预览模态)
19
+ __TOPPPT_RUNTIME__ ← assets/pptx-export.js(PPTX 预览运行时内联副本)
20
+ 5. 校验双端单源引用:scripts/build_pptx.js 引用 layout-constants.json(Node require);
21
+ scripts/extract_model.py 引用 model-schema.json(本地校验同一份 schema)
22
+ 6. 校验「页型四件套」完整性:schema 每个页型都要在 layout-constants.json 的 pageTypeGeometry 有几何映射
23
+ 7. 校验「图表登记四元组」完整性:charts.types ↔ charts.registry 双向一致、pptx 通道合法、
24
+ 非原生图表 dataTable 不得为 off、schema.chartTypes 必须是 registry 子集
25
+ 7b. 校验「图表类型可达性」不变量:每个 registry 类型必须可作 chart.type(schema.chartTypes)
26
+ 或可作 sections[].type(信息图专属页型)——否则模型无法表达、双引擎实现沦为死代码
27
+ 7c. 校验「页型实现可达性」冒烟:schema 每个页型都应出现在 build_pptx.js 与 pptx-export.js 中
28
+ (子串级检查,拦「schema 加了页型、引擎没实现」的静默退化)
29
+ 7d. 校验「骨架尺寸单源」不变量:charts.scaffold 键 ⊆ registry,且默认值 ≥ charts.minSize
30
+ 同口径阈值(拦「scaffold 硬编码第二源漂移」;信息图页型清单亦单源于此)
31
+ 8. 校验语义字阶:每个 level 的 role 必须在 modeTypeScale 中存在
32
+ 9. 打印常量摘要哈希,供各方核对(pptx-export.js / 三模板 / 画廊 / build_pptx.js)
33
+
34
+ 模式模板(生成时确定形态,非运行时切换):
35
+ templates/presentation.html · templates/research.html · templates/architecture.html
36
+ 各模板自带:模式密度层 + 模式专属组件 + 模式骨架章节 + REPORT_MODEL(mode 已锁定)
37
+ """
38
+ import json
39
+ import re
40
+ import hashlib
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ # Windows GBK 控制台兜底:强制 UTF-8 输出
45
+ try:
46
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
47
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
48
+ except Exception:
49
+ pass
50
+
51
+ ROOT = Path(__file__).resolve().parent.parent
52
+ LC = ROOT / 'scripts' / 'layout-constants.json'
53
+ MS = ROOT / 'scripts' / 'model-schema.json'
54
+ PE = ROOT / 'assets' / 'pptx-export.js'
55
+ GALLERY = ROOT / 'assets' / 'style-gallery.html'
56
+ TPL_DIR = ROOT / 'assets' / 'templates'
57
+ ENGINE = TPL_DIR / 'engine.css'
58
+ UI = TPL_DIR / 'ui.js'
59
+ TEMPLATES = [TPL_DIR / 'presentation.html',
60
+ TPL_DIR / 'research.html',
61
+ TPL_DIR / 'architecture.html']
62
+ BJ = ROOT / 'scripts' / 'build_pptx.js'
63
+ EM = ROOT / 'scripts' / 'extract_model.py'
64
+
65
+ CONST_PAT = re.compile(r'/\* __TOPPPT_CONSTANTS_START__ \*/[\s\S]*?/\* __TOPPPT_CONSTANTS_END__ \*/')
66
+ SCHEMA_PAT = re.compile(r'/\* __TOPPPT_SCHEMA_START__ \*/[\s\S]*?/\* __TOPPPT_SCHEMA_END__ \*/')
67
+ ENGINE_PAT = re.compile(r'/\* __TOPPPT_ENGINE_START__ \*/[\s\S]*?/\* __TOPPPT_ENGINE_END__ \*/')
68
+ UI_PAT = re.compile(r'/\* __TOPPPT_UI_START__ \*/[\s\S]*?/\* __TOPPPT_UI_END__ \*/')
69
+ RUNTIME_PAT = re.compile(r'/\* __TOPPPT_RUNTIME_START__ \*/[\s\S]*?/\* __TOPPPT_RUNTIME_END__ \*/')
70
+
71
+
72
+ def _strip_comments(obj):
73
+ """递归剔除 JSON 对象里的 $comment 键(注入 JS 常量用)。"""
74
+ if isinstance(obj, dict):
75
+ return {k: _strip_comments(v) for k, v in obj.items() if not k.startswith('$')}
76
+ if isinstance(obj, list):
77
+ return [_strip_comments(v) for v in obj]
78
+ return obj
79
+
80
+
81
+ def build_const_block(lc: dict) -> str:
82
+ page, pt = lc['page'], lc['pageTypes']
83
+ styles = _strip_comments(lc['styles'])
84
+ styles_dark = _strip_comments(lc.get('stylesDark') or {})
85
+ ts = _strip_comments(lc['typeScale'])
86
+ mts = _strip_comments(lc['modeTypeScale'])
87
+ def _presets_js(var_name, styles_dict):
88
+ if not styles_dict:
89
+ return 'var %s = {};' % var_name
90
+ return 'var %s = {\n' % var_name + ',\n'.join(
91
+ " '%s': { accent:'%s', ink:'%s', body:'%s', faint:'%s', bg:'%s', surface:'%s', line:'%s', soft:'%s', onAccent:'%s', font:'%s', fontDisplay:'%s' }" % (
92
+ k, v['accent'], v['ink'], v['body'], v['faint'], v['bg'], v['surface'], v['line'], v['soft'],
93
+ v['onAccent'], v['font'], v['fontDisplay'])
94
+ for k, v in styles_dict.items()) + '\n};'
95
+ presets_js = _presets_js('PRESETS', styles)
96
+ presets_dark_js = _presets_js('PRESETS_DARK', styles_dark)
97
+ ts_js = 'var TS_BASE = ' + json.dumps(ts, separators=(',', ':')) + ';'
98
+ mts_js = 'var MTS = ' + json.dumps(mts, separators=(',', ':')) + ';'
99
+ pt_js = 'var PT = ' + json.dumps(_strip_comments(pt), ensure_ascii=False, separators=(',', ':')) + ';'
100
+ colors_js = 'var STYLE_DATA_COLORS = ' + json.dumps(
101
+ _strip_comments(lc.get('styleDataColors') or {}), separators=(',', ':')) + ';'
102
+ colors_dark_js = 'var STYLE_DATA_COLORS_DARK = ' + json.dumps(
103
+ _strip_comments(lc.get('styleDataColorsDark') or {}), separators=(',', ':')) + ';'
104
+ # 12 列网格 / 语义字阶 / 容器内边距 / 图表登记四元组 / 深度模式配置
105
+ grid_js = 'var GRID = ' + json.dumps(_strip_comments(lc['grid']), separators=(',', ':')) + ';'
106
+ typo_js = 'var TYPO = ' + json.dumps(_strip_comments(lc['typography']), ensure_ascii=False,
107
+ separators=(',', ':')) + ';'
108
+ cont_js = 'var CONT = ' + json.dumps(_strip_comments(lc['containers']), ensure_ascii=False,
109
+ separators=(',', ':')) + ';'
110
+ reg_js = 'var REG = ' + json.dumps(_strip_comments((lc.get('charts') or {}).get('registry') or {}),
111
+ ensure_ascii=False, separators=(',', ':')) + ';'
112
+ deep_js = 'var DEEP = ' + json.dumps(_strip_comments(lc['deepMode']), ensure_ascii=False,
113
+ separators=(',', ':')) + ';'
114
+ # 图片规格与配图占位约定(双引擎拼同一占位文本,cross_verify 逐页文本一致)
115
+ img_js = 'var IMG = ' + json.dumps(_strip_comments(lc.get('imageSpec') or {}), ensure_ascii=False,
116
+ separators=(',', ':')) + ';'
117
+ # 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源公式;浏览器端 A 通道消费)
118
+ # Batch 3: layoutSlots 单源 = layout-constants.json(不再读独立 layout_slots.json)
119
+ slots = lc.get('layoutSlots') or {}
120
+ if not slots:
121
+ slots_path = ROOT / 'scripts' / 'layout_slots.json' # 兼容过渡
122
+ if slots_path.exists():
123
+ try:
124
+ slots = json.loads(slots_path.read_text(encoding='utf-8'))
125
+ except json.JSONDecodeError:
126
+ slots = {}
127
+ slots_js = 'var LAYOUT_SLOTS = ' + json.dumps(_strip_comments(slots), ensure_ascii=False,
128
+ separators=(',', ':')) + ';'
129
+ region_fn = r'''
130
+ /* regionOf(pageType, slotId, opts) — 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源) */
131
+ function regionOf(pageType, slotId, opts) {
132
+ var type = String(pageType || 'points'), slot = String(slotId || '');
133
+ opts = opts || {};
134
+ var L = PT.layout || {}, C = PT.common || {};
135
+ var topDefault = C.bodyY || 2.5;
136
+ var bottomDefault = opts.withNote ? (L.contentBottomWithNote || 6.4) : (L.contentBottom || 6.9);
137
+ if (slot === 'head' || slot === 'chrome') {
138
+ return { x: MX, y: C.headEyebrowY || 0.55, w: CW,
139
+ h: Math.max(0.4, (C.headRuleY || 1.95) - (C.headEyebrowY || 0.55)),
140
+ titleY: C.headTitleY, ruleY: C.headRuleY, leadY: C.leadY, bodyY: C.bodyY };
141
+ }
142
+ if (slot === 'annotation' || slot === 'soWhat') {
143
+ var sy = (PT.exhibit && PT.exhibit.soWhatY != null) ? PT.exhibit.soWhatY : 6.05;
144
+ return { x: MX, y: sy, w: CW, h: 0.62 };
145
+ }
146
+ if (slot === 'footnote') {
147
+ var fy = (PT.exhibit && PT.exhibit.footnoteY != null) ? PT.exhibit.footnoteY : ((PT.note && PT.note.y) || 6.55);
148
+ return { x: MX, y: fy, w: CW - 1.2, h: (PT.note && PT.note.h) || 0.32 };
149
+ }
150
+ var P = PT[type] || {};
151
+ if (type === 'exhibit' && slot === 'primary') {
152
+ var ey = P.chartY || 2.95;
153
+ return { x: MX, y: ey, w: CW, h: Math.max(1.2, (L.contentBottomWithNote || 6.4) - ey), badgeY: P.badgeY };
154
+ }
155
+ if (type === 'metrics' && slot === 'primary') {
156
+ return { x: MX, y: P.bandY || 3.1, w: CW, h: P.cardH || 2.3, gap: P.gap, maxW: P.maxW,
157
+ valY: P.valY, valH: P.valH, capY: P.capY, capH: P.capH };
158
+ }
159
+ if (type === 'table' && slot === 'primary') {
160
+ var ty = P.y || topDefault;
161
+ return { x: MX, y: ty, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - ty) };
162
+ }
163
+ if (type === 'bar' && slot === 'primary') {
164
+ var by = P.chartY || 2.95;
165
+ var bb = opts.bottom != null ? opts.bottom : bottomDefault;
166
+ return { x: MX + (P.chartX || 0), y: by, w: CW - (P.chartW || 0), h: Math.max(1.2, bb - by) };
167
+ }
168
+ if (type === 'bar' && slot === 'hbar') {
169
+ var y0 = P.hbarY0 || 2.6;
170
+ var y1 = opts.bottom != null ? opts.bottom : (P.hbarY1 || 6.25);
171
+ return { x: MX, y: y0, w: CW, h: Math.max(1.2, y1 - y0) };
172
+ }
173
+ if (type === 'points' && (slot === 'primary' || slot === 'list')) {
174
+ var ly = P.listY || topDefault;
175
+ return { x: MX, y: ly, w: CW, h: Math.max(0.8, (opts.bottom != null ? opts.bottom : bottomDefault) - ly) };
176
+ }
177
+ if (type === 'split' && (slot === 'left' || slot === 'right')) {
178
+ var st = P.chartY || topDefault;
179
+ var sb = opts.bottom != null ? opts.bottom : bottomDefault;
180
+ if (slot === 'left') return { x: MX, y: st, w: CW * (P.leftW || 0.55), h: Math.max(1.2, sb - st) };
181
+ return { x: MX + CW * (P.rightX != null ? P.rightX : 0.585), y: st, w: CW * (P.rightW || 0.415), h: Math.max(1.2, sb - st) };
182
+ }
183
+ if (type === 'twocol' && (slot === 'primary' || slot === 'left' || slot === 'right')) {
184
+ var gap = P.colGap || 0.5, colW = (CW - gap) / 2;
185
+ var tt = opts.top != null ? opts.top : topDefault;
186
+ var tb = opts.bottom != null ? opts.bottom : bottomDefault;
187
+ if (slot === 'right') return { x: MX + colW + gap, y: tt, w: colW, h: Math.max(1, tb - tt) };
188
+ return { x: MX, y: tt, w: colW, h: Math.max(1, tb - tt) };
189
+ }
190
+ if ((type === 'diagram' || type === 'lane') && slot === 'primary') {
191
+ var arch = PT.arch || {};
192
+ var dy = (opts.mode === 'architecture' && arch.fullStartY != null) ? arch.fullStartY : (P.bodyStartY || 2.9);
193
+ var db = (opts.mode === 'architecture' && arch.fullEndY != null) ? arch.fullEndY
194
+ : (opts.bottom != null ? opts.bottom : bottomDefault);
195
+ return { x: MX, y: dy, w: CW, h: Math.max(1.2, db - dy) };
196
+ }
197
+ if (type === 'kpi' && (slot === 'hero' || slot === 'primary')) {
198
+ return { x: MX, y: P.heroY || 2.9, w: CW * (P.heroW || 0.52), h: P.heroH || 1.6,
199
+ dividerX: P.dividerX, metricY0: P.metricY0, metricRowH: P.metricRowH };
200
+ }
201
+ if (type === 'comparison' && (slot === 'left' || slot === 'primary' || slot === 'right')) {
202
+ var cg = P.panelGap || 0.4, pw = (CW - cg) / 2;
203
+ if (slot === 'right') return { x: MX + pw + cg, y: P.panelY || 2.55, w: pw, h: P.panelH || 3.4 };
204
+ return { x: MX, y: P.panelY || 2.55, w: pw, h: P.panelH || 3.4 };
205
+ }
206
+ if (type === 'quote' && slot === 'primary') {
207
+ return { x: MX + 1.05, y: P.textY || 2.7, w: CW - 2.1, h: P.textH || 2.3 };
208
+ }
209
+ if (type === 'heatmap' && slot === 'primary') {
210
+ var hy = P.y || 2.6;
211
+ return { x: MX, y: hy, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - hy) };
212
+ }
213
+ if (type === 'bullet' && slot === 'primary') {
214
+ var uy = P.y || 2.6;
215
+ return { x: MX, y: uy, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - uy) };
216
+ }
217
+ if (type === 'pyramid' && slot === 'primary') {
218
+ var py2 = P.y || 2.35;
219
+ return { x: MX, y: py2, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - py2) };
220
+ }
221
+ if (type === 'steps' && slot === 'primary') {
222
+ return { x: MX, y: P.y || 3.05, w: CW, h: P.barH || 1.35 };
223
+ }
224
+ if (type === 'cards' && slot === 'primary') {
225
+ var cy2 = P.startY || 3.0;
226
+ return { x: MX, y: cy2, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - cy2) };
227
+ }
228
+ if (type === 'timeline' && slot === 'primary') {
229
+ return { x: MX, y: P.labelY || 2.95, w: CW, h: Math.max(1, bottomDefault - (P.labelY || 2.95)) };
230
+ }
231
+ if (type === 'image' && slot === 'primary') {
232
+ var iy2 = Math.max(P.y || 2.3, opts.top || 0);
233
+ return { x: MX, y: iy2, w: CW, h: Math.max(1.2, Math.min(P.h || 4.2, (opts.bottom != null ? opts.bottom : bottomDefault) - iy2)) };
234
+ }
235
+ if ((type === 'halftable') && (slot === 'left' || slot === 'primary')) {
236
+ var rs = PT.research || {};
237
+ return { x: MX, y: topDefault, w: CW * (rs.halfTableW || 0.52), h: Math.max(1, bottomDefault - topDefault) };
238
+ }
239
+ if ((type === 'halftable') && (slot === 'right' || slot === 'secondary')) {
240
+ var rs2 = PT.research || {};
241
+ return { x: MX + CW * (rs2.halfChartX || 0.57), y: topDefault, w: CW * (rs2.halfChartW || 0.43), h: Math.max(1, bottomDefault - topDefault) };
242
+ }
243
+ if (type === 'matrix' && slot === 'primary') {
244
+ var rm = PT.research || {};
245
+ var my = rm.matrixY || 2.3;
246
+ return { x: MX, y: my, w: CW, h: Math.max(1, (opts.bottom != null ? opts.bottom : bottomDefault) - my) };
247
+ }
248
+ var INFO = { sankey:1, treemap:1, boxplot:1, network:1, marimekko:1, streamgraph:1 };
249
+ if (INFO[type] && slot === 'primary') {
250
+ var iy3 = P.y || topDefault;
251
+ var ih3 = Math.max(1.2, Math.min(P.h || 4.1, (opts.bottom != null ? opts.bottom : bottomDefault) - iy3));
252
+ return { x: MX, y: iy3, w: CW, h: ih3 };
253
+ }
254
+ return { x: MX, y: topDefault, w: CW, h: Math.max(0.8, bottomDefault - topDefault) };
255
+ }
256
+ '''
257
+ return '\n'.join([
258
+ '/* __TOPPPT_CONSTANTS_START__ */',
259
+ '/* ── 由 scripts/sync_runtime.py 从 scripts/layout-constants.json 注入 · 禁止手改 ── */',
260
+ '/* MTS = 三模式独立排版比例尺(按模式取 pt;基准 TS_BASE = presentation 档) */',
261
+ '/* PRESETS_DARK = 风格 dark token(REPORT_MODEL.theme="dark" 时导出深色版)*/',
262
+ '/* GRID = 12 列网格;TYPO = 语义字阶 C0–T14;CONT = 容器内边距;REG = 图表登记四元组;DEEP = 深度模式配置 */',
263
+ '/* IMG = 素材图片规格与配图占位约定(版式/裁切/建议尺寸/占位标签,双引擎同源) */',
264
+ '/* STYLE_DATA_COLORS(_DARK) = 9 套风格各自的编码色板 c1–c5(数据系列;结构色纪律不变) */',
265
+ '/* LAYOUT_SLOTS + regionOf = 页型布局区域 IR(与 scripts/lib_layout_regions.js 同源) */',
266
+ 'var PW = %s, PH = %s, MX = %s, CW = PW - 2 * MX, EMU = %s;'
267
+ % (page['pw'], page['ph'], page['mx'], page['emu']),
268
+ ts_js, mts_js, pt_js, presets_js, presets_dark_js, colors_js, colors_dark_js,
269
+ grid_js, typo_js, cont_js, reg_js, deep_js, img_js, slots_js, region_fn,
270
+ '/* __TOPPPT_CONSTANTS_END__ */'])
271
+
272
+
273
+ def build_schema_block(ms: dict) -> str:
274
+ return '\n'.join([
275
+ '/* __TOPPPT_SCHEMA_START__ */',
276
+ '/* ── 由 scripts/sync_runtime.py 从 scripts/model-schema.json 注入 · 禁止手改 ── */',
277
+ '/* DSL schema 单源:浏览器端 validateModel 与 scripts/extract_model.py 消费同一份定义 */',
278
+ 'var MODEL_SCHEMA = ' + json.dumps(_strip_comments(ms), ensure_ascii=False, separators=(',', ':')) + ';',
279
+ '/* __TOPPPT_SCHEMA_END__ */'])
280
+
281
+
282
+ def inject(path: Path, pat: re.Pattern, repl: str, label: str) -> bool:
283
+ src = path.read_text(encoding='utf-8')
284
+ if not pat.search(src):
285
+ print(f'错误: {path.name} 缺少 {label} 标记块')
286
+ return False
287
+ path.write_text(pat.sub(lambda m: repl, src, count=1), encoding='utf-8')
288
+ return True
289
+
290
+
291
+ def main() -> int:
292
+ if not LC.exists():
293
+ print(f'错误: 找不到 {LC}')
294
+ return 2
295
+ lc = json.loads(LC.read_text(encoding='utf-8'))
296
+ if not MS.exists():
297
+ print(f'错误: 找不到 {MS}(DSL schema 单源)')
298
+ return 2
299
+ ms = json.loads(MS.read_text(encoding='utf-8'))
300
+ ok = True
301
+
302
+ # ① 重新生成 pptx-export.js 常量块 + schema 块
303
+ src = PE.read_text(encoding='utf-8')
304
+ if not CONST_PAT.search(src):
305
+ print('错误: assets/pptx-export.js 缺少 __TOPPPT_CONSTANTS__ 标记块')
306
+ return 1
307
+ if not SCHEMA_PAT.search(src):
308
+ print('错误: assets/pptx-export.js 缺少 __TOPPPT_SCHEMA__ 标记块')
309
+ return 1
310
+ block = build_const_block(lc)
311
+ PE.write_text(CONST_PAT.sub(lambda m: block, src), encoding='utf-8')
312
+ src = PE.read_text(encoding='utf-8')
313
+ schema_block = build_schema_block(ms)
314
+ PE.write_text(SCHEMA_PAT.sub(lambda m: schema_block, src), encoding='utf-8')
315
+
316
+ # ①-b 风格画廊:同一份常量块注入(画廊配色改为从 PRESETS/PRESETS_DARK 派生,消除第四处手抄色值)
317
+ if GALLERY.exists():
318
+ gsrc = GALLERY.read_text(encoding='utf-8')
319
+ if CONST_PAT.search(gsrc):
320
+ GALLERY.write_text(CONST_PAT.sub(lambda m: block, gsrc), encoding='utf-8')
321
+ else:
322
+ print('警告: assets/style-gallery.html 缺少 __TOPPPT_CONSTANTS__ 标记块(画廊配色无法单源化)')
323
+ ok = False
324
+
325
+ # ② 公共片段源
326
+ if not ENGINE.exists() or not UI.exists():
327
+ print(f'错误: 缺少公共片段 {ENGINE} / {UI}')
328
+ return 1
329
+ engine_css = ENGINE.read_text(encoding='utf-8').rstrip('\n')
330
+ ui_js = UI.read_text(encoding='utf-8').rstrip('\n')
331
+ runtime = PE.read_text(encoding='utf-8').rstrip('\n')
332
+
333
+ engine_repl = ('/* __TOPPPT_ENGINE_START__ */\n'
334
+ '/* ══ TopPPT HTML 公共样式引擎(assets/templates/engine.css 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
335
+ + engine_css + '\n/* __TOPPPT_ENGINE_END__ */')
336
+ ui_repl = ('/* __TOPPPT_UI_START__ */\n'
337
+ '/* ══ TopPPT HTML 公共 UI 脚本(assets/templates/ui.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
338
+ + ui_js + '\n/* __TOPPPT_UI_END__ */')
339
+ runtime_repl = ('/* __TOPPPT_RUNTIME_START__ */\n'
340
+ '/* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
341
+ + runtime + '\n/* __TOPPPT_RUNTIME_END__ */')
342
+
343
+ # ③ 注入三份模式模板
344
+ for tpl in TEMPLATES:
345
+ if not tpl.exists():
346
+ print(f'警告: 模板不存在 {tpl}')
347
+ ok = False
348
+ continue
349
+ ok = inject(tpl, ENGINE_PAT, engine_repl, '__TOPPPT_ENGINE__') and ok
350
+ ok = inject(tpl, UI_PAT, ui_repl, '__TOPPPT_UI__') and ok
351
+ ok = inject(tpl, RUNTIME_PAT, runtime_repl, '__TOPPPT_RUNTIME__') and ok
352
+
353
+ # ④ 校验双端单源引用(build_pptx.js → layout-constants.json;extract_model.py → model-schema.json)
354
+ if BJ.exists():
355
+ bj = BJ.read_text(encoding='utf-8')
356
+ if 'layout-constants.json' not in bj:
357
+ print('警告: scripts/build_pptx.js 未引用 layout-constants.json(常量可能漂移)')
358
+ ok = False
359
+ else:
360
+ print('警告: scripts/build_pptx.js 不存在')
361
+ ok = False
362
+ if EM.exists():
363
+ em = EM.read_text(encoding='utf-8')
364
+ if 'model-schema.json' not in em:
365
+ print('警告: scripts/extract_model.py 未引用 model-schema.json(schema 可能漂移)')
366
+ ok = False
367
+ else:
368
+ print('警告: scripts/extract_model.py 不存在')
369
+ ok = False
370
+
371
+ # ⑤ 页型四件套完整性:schema 每个页型都要有几何映射,且映射指向存在的几何组
372
+ pt = lc.get('pageTypes') or {}
373
+ p2g = lc.get('pageTypeGeometry') or {}
374
+ geom_keys = {k for k in pt if not k.startswith('$')}
375
+ schema_types = [k for k in (ms.get('pageTypes') or {}) if not k.startswith('$')]
376
+ for t in schema_types:
377
+ g = p2g.get(t)
378
+ if not g:
379
+ print(f'警告: 页型 {t!r} 在 model-schema.json 中,但 layout-constants.json 的 pageTypeGeometry 缺映射')
380
+ ok = False
381
+ elif g not in geom_keys:
382
+ print(f'警告: 页型 {t!r} 映射到不存在的几何组 {g!r}')
383
+ ok = False
384
+ orphan = [t for t in p2g if not t.startswith('$') and t not in schema_types]
385
+ if orphan:
386
+ print(f'警告: pageTypeGeometry 含未登记页型 {orphan}(schema 与几何不同步)')
387
+ ok = False
388
+
389
+ # ⑥ 图表登记四元组完整性:charts.types 与 charts.registry 双向一致,且 registry 条目字段齐全
390
+ chart_types = [t for t in ((lc.get('charts') or {}).get('types') or [])]
391
+ registry = (lc.get('charts') or {}).get('registry') or {}
392
+ for t in chart_types:
393
+ if t not in registry:
394
+ print(f'警告: 图表类型 {t!r} 在 charts.types 中,但 charts.registry 缺登记')
395
+ ok = False
396
+ for t, spec in registry.items():
397
+ if t.startswith('$'):
398
+ continue
399
+ if t not in chart_types:
400
+ print(f'警告: charts.registry 含未登记类型 {t!r}(types 与 registry 不同步)')
401
+ ok = False
402
+ if spec.get('pptx') not in ('native', 'shape'):
403
+ print(f'警告: 图表 {t!r} 的 pptx 通道非法(应为 native / shape)')
404
+ ok = False
405
+ if not spec.get('dataTable'):
406
+ print(f'警告: 图表 {t!r} 缺 dataTable 策略')
407
+ ok = False
408
+ if spec.get('pptx') == 'shape' and spec.get('dataTable') == 'off' and t != 'sparkline':
409
+ print(f'警告: 非原生图表 {t!r} 的 dataTable 不得为 off(数据可追溯铁律)')
410
+ ok = False
411
+
412
+ # ⑦ 图表类型白名单 ↔ 登记表一致(schema.chartTypes 必须是 registry 的子集)
413
+ schema_charts = [c for c in (ms.get('chartTypes') or [])]
414
+ unknown_charts = [c for c in schema_charts if c not in registry]
415
+ if unknown_charts:
416
+ print(f'警告: model-schema.json chartTypes 含 registry 未登记类型 {unknown_charts}')
417
+ ok = False
418
+
419
+ # ⑦-b 可达性不变量:每个 registry 类型都必须「模型可表达」,否则双引擎实现是死代码。
420
+ # 可达 = 可作 chart.type(在 schema.chartTypes)∪ 可作 sections[].type(信息图专属页型)。
421
+ # 信息图页型清单单源 = charts.scaffold.infoTypes(scaffold/extract_snippet 同源消费)。
422
+ scaff = (lc.get('charts') or {}).get('scaffold') or {}
423
+ info_page_types = set(scaff.get('infoTypes') or {})
424
+ unreachable = [t for t in chart_types if t not in schema_charts and t not in info_page_types]
425
+ if unreachable:
426
+ print(f'警告: 图表类型 {unreachable} 既不在 schema.chartTypes 也不是信息图专属页型 —— '
427
+ '模型无法表达,双引擎实现将成为死代码')
428
+ ok = False
429
+ ghost_pages = sorted(t for t in info_page_types if t not in schema_types)
430
+ if ghost_pages:
431
+ print(f'警告: 信息图页型 {ghost_pages} 未登记在 schema.pageTypes(可达性口径失效)')
432
+ ok = False
433
+ for t in info_page_types:
434
+ if t in schema_charts:
435
+ print(f'警告: 信息图页型 {t!r} 同时出现在 chartTypes —— 二者载荷不同,'
436
+ '会造成 chart 对象字段歧义')
437
+ ok = False
438
+
439
+ # ⑦-d 骨架尺寸单源不变量:charts.scaffold 是 scaffold_report.py 骨架尺寸的唯一事实源——
440
+ # ① 键必须 ⊆ registry(防孤儿尺寸)② 默认值 ≥ charts.minSize 同口径阈值(防骨架天生不达标)
441
+ if not scaff:
442
+ print('警告: layout-constants.json 缺 charts.scaffold(骨架尺寸退回脚本硬编码第二源)')
443
+ ok = False
444
+ else:
445
+ min_size = (lc.get('charts') or {}).get('minSize') or {}
446
+ legal = set(chart_types)
447
+ for caliber, key in (('viewBoxHeight', 'vbHeightMin'), ('pxWidth', 'pxWidthMin')):
448
+ for t, v in (scaff.get(caliber) or {}).items():
449
+ if t not in legal:
450
+ print(f'警告: charts.scaffold.{caliber} 含 registry 未登记类型 {t!r}')
451
+ ok = False
452
+ floor = (min_size.get(t) or {}).get(key)
453
+ if floor is not None and v < floor:
454
+ print(f'警告: charts.scaffold.{caliber}[{t!r}]={v} < minSize.{key}={floor}'
455
+ '(骨架默认值天生不达标)')
456
+ ok = False
457
+ orphan_info = sorted(t for t in info_page_types if t not in legal)
458
+ if orphan_info:
459
+ print(f'警告: charts.scaffold.infoTypes 含 registry 未登记类型 {orphan_info}')
460
+ ok = False
461
+
462
+ # ⑦-c 页型实现可达性(冒烟):schema 的每个页型都应在双引擎源码中出现,
463
+ # 否则「schema 加了页型、引擎没实现」会静默退化为默认版式。
464
+ # 注:这是子串级冒烟检查(不解析 AST),只能拦住「完全没提」的漏实现。
465
+ for eng_name, eng_path in (('scripts/build_pptx.js', ROOT / 'scripts' / 'build_pptx.js'),
466
+ ('assets/pptx-export.js', ROOT / 'assets' / 'pptx-export.js')):
467
+ try:
468
+ eng_txt = eng_path.read_text(encoding='utf-8')
469
+ except OSError:
470
+ print(f'警告: 无法读取 {eng_name},跳过页型实现核查')
471
+ ok = False
472
+ continue
473
+ eng_missing = [t for t in schema_types if t not in eng_txt]
474
+ if eng_missing:
475
+ print(f'警告: {eng_name} 未见页型 {eng_missing}(schema 已登记但引擎未提及)')
476
+ ok = False
477
+
478
+ # ⑧ 语义字阶完整性:每个 level 的 role 必须在 modeTypeScale 的角色集合中存在
479
+ mts = lc['modeTypeScale']
480
+ mts_roles = {k for k in (mts.get('presentation') or {})}
481
+ for lv in (lc.get('typography') or {}).get('levels') or []:
482
+ if lv.get('role') not in mts_roles:
483
+ print(f'警告: 语义字阶 {lv.get("id")} 指向不存在的比例尺角色 {lv.get("role")!r}')
484
+ ok = False
485
+
486
+ # ⑨ 图片系统完整性:imageSpec 版式在双引擎可表达 + 占位标签三要素齐全 +
487
+ # image 页型三选一必填口径(src/items/placeholder)。拦「schema 放宽了、引擎没实现」
488
+ # 与「占位文本拼不出来」两类静默退化。
489
+ img = lc.get('imageSpec') or {}
490
+ img_layouts = [x for x in (img.get('layouts') or [])]
491
+ if not img_layouts:
492
+ print('警告: layout-constants.json 缺 imageSpec.layouts(素材图片版式无单源)')
493
+ ok = False
494
+ for key in ('placeholderLabel', 'placeholderHintPrefix', 'placeholderHintSuffix', 'recommendedSizePx'):
495
+ if not img.get(key):
496
+ print(f'警告: imageSpec 缺 {key}(配图占位文本无法由双引擎同源拼接)')
497
+ ok = False
498
+ for lay in img_layouts:
499
+ if lay not in (img.get('recommendedSizePx') or {}):
500
+ print(f'警告: imageSpec.recommendedSizePx 缺版式 {lay!r} 的建议尺寸')
501
+ ok = False
502
+ # 版式比例:每个版式都要有 ratioDefault(双引擎据此算高度)+ ratioCssClass(HTML 锁比例类)
503
+ ratio_def = img.get('ratioDefault') or {}
504
+ ratio_css = img.get('ratioCssClass') or {}
505
+ engine_css = (TPL_DIR / 'engine.css').read_text(encoding='utf-8') if (TPL_DIR / 'engine.css').exists() else ''
506
+ for lay in img_layouts:
507
+ if not ratio_def.get(lay):
508
+ print(f'警告: imageSpec.ratioDefault 缺版式 {lay!r} 的比例(HTML/PPTX 版式会不一致)')
509
+ ok = False
510
+ cls = ratio_css.get(lay)
511
+ if not cls:
512
+ print(f'警告: imageSpec.ratioCssClass 缺版式 {lay!r} 的锁定类')
513
+ ok = False
514
+ elif engine_css and ('.' + cls) not in engine_css:
515
+ print(f'警告: engine.css 缺图片比例锁定类 .{cls}(HTML 侧无法锁定 {lay} 版式比例)')
516
+ ok = False
517
+ img_def = (ms.get('pageTypes') or {}).get('image') or {}
518
+ if not any(str(s).startswith('anyof:') for s in (img_def.get('required') or [])):
519
+ print('警告: model-schema.json 的 image 页型未用 anyof 三选一(src/items/placeholder)')
520
+ ok = False
521
+ for eng_name, eng_path in (('scripts/build_pptx.js', ROOT / 'scripts' / 'build_pptx.js'),
522
+ ('assets/pptx-export.js', ROOT / 'assets' / 'pptx-export.js')):
523
+ try:
524
+ eng_txt = eng_path.read_text(encoding='utf-8')
525
+ except OSError:
526
+ continue
527
+ eng_missing_lay = [lay for lay in img_layouts
528
+ if lay not in ('full', 'half') and ("'%s'" % lay) not in eng_txt]
529
+ if eng_missing_lay:
530
+ print(f'警告: {eng_name} 未见图片版式 {eng_missing_lay}(imageSpec 已登记但引擎未提及)')
531
+ ok = False
532
+
533
+ # ⑩ 页型布局 IR(LC.layoutSlots):高频页型槽位完整性
534
+ n_slots = 0
535
+ slots_doc = lc.get('layoutSlots') or {}
536
+ if not slots_doc.get('pageTypes'):
537
+ print('警告: layout-constants.json 缺 layoutSlots.pageTypes(页型布局 IR 单源)')
538
+ ok = False
539
+ else:
540
+ try:
541
+ slots_pages = slots_doc.get('pageTypes') or {}
542
+ for pt_name, pt_def in slots_pages.items():
543
+ if pt_name not in schema_types:
544
+ print(f'警告: layoutSlots 页型 {pt_name!r} 不在 schema.pageTypes')
545
+ ok = False
546
+ slot_list = pt_def.get('slots') or []
547
+ if not slot_list:
548
+ print(f'警告: layoutSlots 页型 {pt_name!r} 无槽位定义')
549
+ ok = False
550
+ continue
551
+ ids = [s.get('id') for s in slot_list]
552
+ if 'head' not in ids or not any(s.get('role') == 'primary' for s in slot_list):
553
+ print(f'警告: layoutSlots 页型 {pt_name!r} 缺 head 或 primary 槽位')
554
+ ok = False
555
+ n_slots = len(slots_pages)
556
+ # 布局 IR 模块存在性(B 通道 require)
557
+ ir_mod = ROOT / 'scripts' / 'lib_layout_regions.js'
558
+ if not ir_mod.exists():
559
+ print('警告: 缺 scripts/lib_layout_regions.js(布局区域 IR 模块)')
560
+ ok = False
561
+ else:
562
+ ir_txt = ir_mod.read_text(encoding='utf-8')
563
+ for key in ('regionOf', 'layoutSlots', 'layout-constants.json'):
564
+ if key not in ir_txt:
565
+ print(f'警告: lib_layout_regions.js 缺少 {key!r} 引用')
566
+ ok = False
567
+ # 布局 IR 烟测:regionOf 与 pageTypes 常量一致(防公式漂移)
568
+ try:
569
+ import subprocess as _sp
570
+ smoke_js = r'''
571
+ const R = require("./scripts/lib_layout_regions.js");
572
+ const LC = R.LC;
573
+ const cases = [
574
+ ["exhibit","primary", LC.pageTypes.exhibit.chartY],
575
+ ["metrics","primary", LC.pageTypes.metrics.bandY],
576
+ ["head","head", LC.pageTypes.common.headEyebrowY],
577
+ ["bar","primary", LC.pageTypes.bar.chartY],
578
+ ["table","primary", LC.pageTypes.table.y],
579
+ ];
580
+ let bad = 0;
581
+ for (const [t,s,expectY] of cases) {
582
+ const r = R.regionOf(t, s, {});
583
+ if (!r || Math.abs(r.y - expectY) > 0.001) {
584
+ console.error("IR drift", t, s, r && r.y, expectY);
585
+ bad++;
586
+ }
587
+ }
588
+ process.exit(bad ? 1 : 0);
589
+ '''
590
+ node = None
591
+ try:
592
+ sys.path.insert(0, str(ROOT / 'scripts'))
593
+ from env_probe import find_node # noqa: E402
594
+ node = find_node()
595
+ except Exception:
596
+ node = None
597
+ if node:
598
+ rsm = _sp.run([node, '-e', smoke_js], cwd=str(ROOT),
599
+ capture_output=True, text=True)
600
+ if rsm.returncode != 0:
601
+ print('警告: lib_layout_regions regionOf 烟测失败(与 pageTypes 不一致)')
602
+ print((rsm.stderr or rsm.stdout or '')[:400])
603
+ ok = False
604
+ except Exception as exc:
605
+ print(f'警告: 布局 IR 烟测未执行: {exc}')
606
+ except json.JSONDecodeError as exc:
607
+ print(f'警告: layoutSlots 解析失败: {exc}')
608
+ ok = False
609
+ n_slots = 0
610
+
611
+ # ⑪ 版本单源一致性:layout-constants.json `version` 是唯一事实源;
612
+ # model-schema / layout_slots / package.json(npm 语义化 x.y.z)漂移即 WARN
613
+ # (v8.3 教训:SKILL.md frontmatter 移除 version 后,五处版本一度只有一处升级)
614
+ ver = lc.get('version')
615
+ if not ver:
616
+ print('警告: layout-constants.json 缺 version(版本唯一事实源)')
617
+ ok = False
618
+ else:
619
+ if ms.get('version') != ver:
620
+ print(f'警告: model-schema.json version={ms.get("version")!r} ≠ layout-constants.json {ver!r}')
621
+ ok = False
622
+ slots_ver = (lc.get('layoutSlots') or {}).get('version')
623
+ if slots_ver and slots_ver != ver:
624
+ print(f'警告: layoutSlots.version={slots_ver!r} ≠ layout-constants {ver!r}')
625
+ ok = False
626
+ try:
627
+ pj_ver = json.loads((ROOT / 'package.json').read_text(encoding='utf-8')).get('version')
628
+ if not str(pj_ver or '').startswith(f'{ver}.'):
629
+ print(f'警告: package.json version={pj_ver!r} 未对齐 {ver}.x(npm 语义化)')
630
+ ok = False
631
+ except (OSError, json.JSONDecodeError):
632
+ pass
633
+
634
+ # ⑤ 摘要哈希(各方核对用)
635
+ raw = LC.read_text(encoding='utf-8')
636
+ digest = hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]
637
+ ms_raw = MS.read_text(encoding='utf-8')
638
+ ms_digest = hashlib.sha256(ms_raw.encode('utf-8')).hexdigest()[:16]
639
+ n_styles = len(lc['styles'])
640
+ n_pt = len([k for k in lc['pageTypes'] if not k.startswith('$')])
641
+ n_schema_pt = len([k for k in ms['pageTypes'] if not k.startswith('$')])
642
+ n_charts = len((lc.get('charts') or {}).get('types') or [])
643
+ reg = (lc.get('charts') or {}).get('registry') or {}
644
+ n_native = len([t for t, s in reg.items() if not t.startswith('$') and s.get('pptx') == 'native'])
645
+ n_shape = len([t for t, s in reg.items() if not t.startswith('$') and s.get('pptx') == 'shape'])
646
+ n_typo = len((lc.get('typography') or {}).get('levels') or [])
647
+ print('单源注入完成:')
648
+ print(f' layout-constants.json sha256[:16] = {digest}')
649
+ print(f' model-schema.json sha256[:16] = {ms_digest} · 页型 {n_schema_pt} 种')
650
+ print(f' 风格 token {n_styles} 套 · 三模式独立比例尺 {len([k for k in lc["modeTypeScale"] if not k.startswith("$")])} 套 · 页型几何 {n_pt} 组')
651
+ print(f' 图表登记 {n_charts} 种(原生 {n_native} / 形状 {n_shape}) · 语义字阶 {n_typo} 级 · 12 列网格 {lc["grid"]["columns"]} 列 · 布局 IR {n_slots} 页型')
652
+ print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html(引擎/UI/运行时内联副本)')
653
+ print(f' 双端单源引用校验: {"PASS" if ok else "WARN(build_pptx 应 require layout-constants.json;extract_model 应读 model-schema.json;页型四件套与图表登记须完整)"}')
654
+ print(f' 说明: 页型几何 {n_pt} 组(多页型共享几何组) · schema 页型 {n_schema_pt} 种')
655
+ return 0
656
+
657
+
658
+ if __name__ == '__main__':
659
+ sys.exit(main())