@topmindspace/tms-skills 2.0.1 → 2.1.0

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 (95) hide show
  1. package/CHANGELOG.md +145 -134
  2. package/README.md +1 -1
  3. package/bin/tms-skills.js +160 -160
  4. package/package.json +48 -48
  5. package/top-ppt-html/README.md +254 -254
  6. package/top-ppt-html/SKILL.md +111 -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-architecture-spectrum.html +3926 -3926
  10. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4325 -4325
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
  13. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4325 -4325
  14. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
  15. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  16. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  17. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5527 -5527
  18. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
  19. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5527 -5527
  20. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
  21. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  22. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  23. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5527 -5527
  24. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
  25. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  26. package/top-ppt-html/assets/style-gallery.html +589 -589
  27. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  28. package/top-ppt-html/assets/templates/engine.css +840 -840
  29. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  30. package/top-ppt-html/assets/templates/research.html +4017 -4017
  31. package/top-ppt-html/assets/templates/ui.js +520 -520
  32. package/top-ppt-html/evals/prompts.csv +15 -15
  33. package/top-ppt-html/evals/rubric.schema.json +25 -25
  34. package/top-ppt-html/evals/run_evals.py +220 -220
  35. package/top-ppt-html/evals/trace.example.json +16 -16
  36. package/top-ppt-html/package-lock.json +207 -0
  37. package/top-ppt-html/package.json +35 -35
  38. package/top-ppt-html/references/charts-basic.md +624 -624
  39. package/top-ppt-html/references/charts-discipline.md +108 -108
  40. package/top-ppt-html/references/charts-extended.md +482 -482
  41. package/top-ppt-html/references/charts.md +28 -28
  42. package/top-ppt-html/references/components-atoms.md +624 -624
  43. package/top-ppt-html/references/components.md +30 -30
  44. package/top-ppt-html/references/content-rules.md +490 -490
  45. package/top-ppt-html/references/design-system-engine.md +235 -235
  46. package/top-ppt-html/references/design-system.md +478 -478
  47. package/top-ppt-html/references/failure-modes.md +214 -214
  48. package/top-ppt-html/references/high-fidelity.md +127 -127
  49. package/top-ppt-html/references/icons.md +397 -397
  50. package/top-ppt-html/references/industry-benchmark.md +105 -105
  51. package/top-ppt-html/references/infographics-stats.md +308 -308
  52. package/top-ppt-html/references/infographics-structure.md +226 -226
  53. package/top-ppt-html/references/infographics.md +43 -43
  54. package/top-ppt-html/references/layout-grammar.md +315 -315
  55. package/top-ppt-html/references/layouts-architecture.md +108 -108
  56. package/top-ppt-html/references/layouts-combo.md +600 -600
  57. package/top-ppt-html/references/layouts-research.md +160 -160
  58. package/top-ppt-html/references/modes.md +254 -254
  59. package/top-ppt-html/references/outline-design.md +275 -275
  60. package/top-ppt-html/references/playbook.md +266 -266
  61. package/top-ppt-html/references/pptx-export.md +209 -209
  62. package/top-ppt-html/references/reform-plan.md +252 -252
  63. package/top-ppt-html/references/styles.md +370 -370
  64. package/top-ppt-html/references/tech-design.md +138 -138
  65. package/top-ppt-html/scripts/audit_css.py +109 -109
  66. package/top-ppt-html/scripts/audit_docs.py +176 -176
  67. package/top-ppt-html/scripts/audit_skill.py +220 -220
  68. package/top-ppt-html/scripts/audit_styles.py +351 -351
  69. package/top-ppt-html/scripts/build_examples.py +2276 -2276
  70. package/top-ppt-html/scripts/build_pptx.js +2380 -2380
  71. package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
  72. package/top-ppt-html/scripts/checks_html.py +127 -127
  73. package/top-ppt-html/scripts/cross_verify.py +294 -294
  74. package/top-ppt-html/scripts/env_probe.py +158 -158
  75. package/top-ppt-html/scripts/extract_model.py +210 -210
  76. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  77. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  78. package/top-ppt-html/scripts/layout-constants.json +3377 -3377
  79. package/top-ppt-html/scripts/layout_slots.json +830 -830
  80. package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
  81. package/top-ppt-html/scripts/measure_height.py +178 -178
  82. package/top-ppt-html/scripts/model-schema.json +547 -547
  83. package/top-ppt-html/scripts/negative_tests.py +307 -307
  84. package/top-ppt-html/scripts/package_skill.py +291 -291
  85. package/top-ppt-html/scripts/prepare_images.py +341 -341
  86. package/top-ppt-html/scripts/probe_image_export.py +188 -188
  87. package/top-ppt-html/scripts/quality_gate.py +301 -301
  88. package/top-ppt-html/scripts/regression.py +308 -308
  89. package/top-ppt-html/scripts/render_compare.py +275 -275
  90. package/top-ppt-html/scripts/render_from_model.py +698 -698
  91. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  92. package/top-ppt-html/scripts/section-file-map.json +104 -104
  93. package/top-ppt-html/scripts/sync_runtime.py +662 -662
  94. package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
  95. package/top-ppt-html/scripts/validate_report.py +1456 -1456
@@ -1,351 +1,351 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """TopPPT HTML · 风格与单源质量审计工具:
4
- ① WCAG 对比度:9 风格 × light/dark 的关键 token 配对(MD3/WCAG 阈值)
5
- ② 双源一致性:engine.css(HTML 侧 token)vs layout-constants.json(PPTX 侧 token)
6
- ③ 单源完整性:styleAccents 覆盖各风格 light/dark 强调色;charts.minSize ⊆ charts.types;
7
- 图表最小尺寸键名合法(pxWidthMin / vbHeightMin)
8
- 用法:python scripts/audit_styles.py(改任何风格色值或常量后必跑;退出码 0 = 全部通过)
9
- """
10
- import json
11
- import re
12
- import sys
13
- from pathlib import Path
14
-
15
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
16
- ROOT = Path(__file__).resolve().parent.parent
17
- CSS = (ROOT / 'assets' / 'templates' / 'engine.css').read_text(encoding='utf-8')
18
- LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
19
-
20
-
21
- def parse_block(block):
22
- toks = {}
23
- for m in re.finditer(r'--([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})', block):
24
- toks[m.group(1)] = m.group(2).lower()
25
- return toks
26
-
27
-
28
- def parse_css():
29
- """返回 {style: {'light': toks, 'dark': toks}}。
30
- 9 套风格均有显式 html[data-style="X"]{...}(light)与
31
- html[data-style="X"][data-theme="dark"]{...}(dark)两块;:root 仅作无 data-style 时的默认值。"""
32
- styles = {}
33
- pat = re.compile(r'html\[data-style="([\w-]+)"\](?:\[data-theme="dark"\])?\{([\s\S]*?)\}')
34
- for m in pat.finditer(CSS):
35
- sid, body = m.group(1), m.group(2)
36
- sel = m.group(0).split('{', 1)[0]
37
- side = 'dark' if 'data-theme="dark"' in sel else 'light'
38
- # 同一 (风格, 主题) 可能由多个块叠加(基础 token 块 + 编码色板块)→ 合并而非覆盖
39
- styles.setdefault(sid, {}).setdefault(side, {}).update(parse_block(body))
40
- return styles
41
-
42
-
43
- def rel_lum(hexc):
44
- h = hexc.lstrip('#')
45
- if len(h) == 3:
46
- h = ''.join(c * 2 for c in h)
47
- r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
48
- def f(c):
49
- return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
50
- r, g, b = f(r), f(g), f(b)
51
- return 0.2126 * r + 0.7152 * g + 0.0722 * b
52
-
53
-
54
- def contrast(a, b):
55
- la, lb = rel_lum(a), rel_lum(b)
56
- hi, lo = max(la, lb), min(la, lb)
57
- return (hi + 0.05) / (lo + 0.05)
58
-
59
-
60
- def hex2rgb(h):
61
- h = h.lstrip('#')
62
- if len(h) == 3:
63
- h = ''.join(c * 2 for c in h)
64
- return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
65
-
66
-
67
- def rgb2hls(r, g, b):
68
- """返回 (色相°, 饱和, 明度) —— 用于编码色板两两可区分性判定。"""
69
- r, g, b = r / 255, g / 255, b / 255
70
- mx, mn = max(r, g, b), min(r, g, b)
71
- l = (mx + mn) / 2
72
- if mx == mn:
73
- return 0.0, 0.0, l
74
- d = mx - mn
75
- s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
76
- if mx == r:
77
- h = ((g - b) / d) % 6
78
- elif mx == g:
79
- h = (b - r) / d + 2
80
- else:
81
- h = (r - g) / d + 4
82
- return h * 60, s, l
83
-
84
-
85
- CHECKS = [
86
- # (名称, token对, 阈值) 阈值依据:WCAG AA 正文 4.5 / 大字与图形 3.0
87
- ('正文 text / bg', ('text', 'bg'), 4.5),
88
- ('次级 text-2 / bg', ('text-2', 'bg'), 4.5),
89
- ('弱化 text-3 / bg(注解)', ('text-3', 'bg'), 3.0),
90
- ('强调文字 accent-text / bg', ('accent-text', 'bg'), 4.5),
91
- ('强调色 accent / bg(图形)', ('accent', 'bg'), 3.0),
92
- ('按钮文字 accent-on / accent', ('accent-on', 'accent'), 4.5),
93
- ('正文 text / surface-1', ('text', 'surface-1'), 4.5),
94
- ('次级 text-2 / surface-1', ('text-2', 'surface-1'), 4.5),
95
- ]
96
-
97
-
98
- def audit_contrast(styles):
99
- fails = []
100
- for sid in sorted(styles):
101
- for theme in ('light', 'dark'):
102
- t = styles[sid].get(theme)
103
- if not t or 'bg' not in t:
104
- continue
105
- for name, (fg, bg), th in CHECKS:
106
- if fg not in t or bg not in t:
107
- continue
108
- c = contrast(t[fg], t[bg])
109
- if c < th:
110
- fails.append(f'{sid}.{theme}: {name} = {c:.2f} < {th} ({t[fg]} on {t[bg]})')
111
- return fails
112
-
113
-
114
- def audit_consistency(styles, lc_styles, side='light', lc_key='styles'):
115
- """PPTX 常量(layout-constants.json)与 CSS token 的映射对照(归一化 # 前缀与 3 位缩写)。
116
- side='dark' 时对照 stylesDark ↔ engine.css 深色块(PPTX 深色导出与 HTML 深色主题同源)。"""
117
- def norm(v):
118
- v = (v or '').lower().lstrip('#')
119
- if len(v) == 3:
120
- v = ''.join(c * 2 for c in v)
121
- return v
122
- MAP = [('ink', 'text'), ('body', 'text-2'), ('faint', 'text-3'),
123
- ('bg', 'bg'), ('surface', 'surface-1'), ('line', 'border'),
124
- ('accent', 'accent'), ('onAccent', 'accent-on'), ('soft', 'accent-soft')]
125
- diffs = []
126
- for sid, pt in lc_styles.items():
127
- css = styles.get(sid, {}).get(side, {})
128
- for jk, ck in MAP:
129
- jv, cv = norm(pt.get(jk)), norm(css.get(ck))
130
- if jv and cv and jv != cv:
131
- diffs.append(f'{sid}.{side}: JSON.{jk}={jv} != CSS.{ck}={cv}')
132
- elif jv and not cv:
133
- diffs.append(f'{sid}.{side}: CSS 缺 {ck}')
134
- if not lc_styles:
135
- diffs.append(f'layout-constants.json 缺 {lc_key}(起 PPTX 深色导出依赖它)')
136
- return diffs
137
-
138
-
139
- def audit_data_palette(styles):
140
- """编码色板审计:9 套风格 × light/dark,各 5 色。
141
- ① 覆盖完整(styleDataColors / styleDataColorsDark 与 styles / stylesDark 键集一致)
142
- ② 每套 5 色、与 engine.css 覆盖块逐值一致(双源)
143
- ③ 每个色对所在主题 bg 的对比度 ≥ 3.0(图形元素可辨下限)
144
- ④ 同套内两两可区分(色相差 ≥25° 或明度差 ≥0.12)"""
145
- diffs = []
146
- for key, theme in (('styleDataColors', 'light'), ('styleDataColorsDark', 'dark')):
147
- table = {k: v for k, v in (LC.get(key) or {}).items() if not k.startswith('$')}
148
- base = {k: v for k, v in (LC.get('styles' if theme == 'light' else 'stylesDark') or {}).items()
149
- if not k.startswith('$')}
150
- for sid in base:
151
- if sid not in table:
152
- diffs.append(f'{key} 缺风格 {sid}')
153
- for sid, cols in table.items():
154
- if len(cols) != 5:
155
- diffs.append(f'{key}.{sid} 色数 {len(cols)} != 5')
156
- continue
157
- css = styles.get(sid, {}).get(theme, {})
158
- for i, c in enumerate(cols, 1):
159
- if c.startswith('#'):
160
- diffs.append(f'{key}.{sid}.c{i}={c} 不得带 # 前缀(会原样流入 OOXML srgbClr val)')
161
- continue
162
- cv = (css.get(f'c{i}') or '').lower().lstrip('#')
163
- if not cv:
164
- diffs.append(f'{key}.{sid}.c{i} 未在 engine.css {theme} 块中定义')
165
- elif cv != c.lower().lstrip('#'):
166
- diffs.append(f'{key}.{sid}.c{i}={c} != engine.css {cv}')
167
- bg = (base.get(sid) or {}).get('bg') or ('#ffffff' if theme == 'light' else '#000000')
168
- if not bg.startswith('#'):
169
- bg = '#' + bg
170
- for c in cols:
171
- if contrast(c if c.startswith('#') else '#' + c, bg) < 3.0:
172
- diffs.append(f'{key}.{sid}: {c} 对 bg {bg} 对比 {contrast(c, bg):.2f} < 3.0')
173
- hls = [rgb2hls(*hex2rgb(c)) for c in cols]
174
- for i in range(5):
175
- for j in range(i + 1, 5):
176
- dh = abs(hls[i][0] - hls[j][0]) % 360
177
- dh = min(dh, 360 - dh)
178
- if dh < 25 and abs(hls[i][2] - hls[j][2]) < 0.12:
179
- diffs.append(f'{key}.{sid}: c{i + 1}/c{j + 1} 不可区分(Δh={dh:.0f}°)')
180
- return diffs
181
-
182
-
183
- def audit_style_identity(styles):
184
- """风格语汇审计:每套风格的 HTML 身份 token 必须落地(字体栈 / 圆角 / 字阶微调 / 链接色)。
185
- 与 layout-constants.json 的 styleIdentity 清单逐项对照,拦「styles.md 写了、engine.css 没落」的漂移。"""
186
- diffs = []
187
- ident = {k: v for k, v in (LC.get('styleIdentity') or {}).items() if not k.startswith('$')}
188
- if not ident:
189
- return ['layout-constants.json 缺 styleIdentity(风格语汇清单)']
190
- for sid, rule in ident.items():
191
- css = styles.get(sid, {}).get('light', {})
192
- if not css:
193
- diffs.append(f'styleIdentity.{sid}: engine.css 缺 light 覆盖块')
194
- continue
195
- raw = re.search(
196
- r'html\[data-style="%s"\]\{([\s\S]*?)\}' % re.escape(sid), CSS)
197
- body = raw.group(1) if raw else ''
198
- if '--font-body' not in body:
199
- diffs.append(f'{sid}: 缺 --font-body(风格字体栈未落地)')
200
- if '--font-display' not in body:
201
- diffs.append(f'{sid}: 缺 --font-display(标题字体栈未落地)')
202
- radius = str(rule.get('radius') or '')
203
- if radius and f'--radius:{radius}' not in body.replace(' ', ''):
204
- # 允许 --radius: 16px 带空格
205
- if not re.search(r'--radius:\s*%s' % re.escape(radius), body):
206
- diffs.append(f'{sid}: --radius 未设为 {radius}')
207
- if rule.get('serifDisplay'):
208
- fd = re.search(r'--font-display:\s*([^;]+);', body)
209
- if not fd or 'serif' not in fd.group(1).lower():
210
- diffs.append(f'{sid}: serifDisplay=true 但 --font-display 非 serif 栈')
211
- if rule.get('typeBoost') and '--fs-display' not in body:
212
- diffs.append(f'{sid}: typeBoost=true 但未覆盖 --fs-display')
213
- if rule.get('link'):
214
- if '--link' not in body:
215
- diffs.append(f'{sid}: 声明 link 但缺 --link token')
216
- else:
217
- lv = re.search(r'--link:\s*([^;]+);', body)
218
- if lv and lv.group(1).strip().lower() != str(rule['link']).lower():
219
- diffs.append(f'{sid}: --link={lv.group(1).strip()} != styleIdentity {rule["link"]}')
220
- le = rule.get('letterEyebrow')
221
- if le and f'--letter-eyebrow:{le}' not in body.replace(' ', ''):
222
- if not re.search(r'--letter-eyebrow:\s*%s' % re.escape(le), body):
223
- diffs.append(f'{sid}: --letter-eyebrow 未设为 {le}')
224
- for sid in (LC.get('styles') or {}):
225
- if sid.startswith('$'):
226
- continue
227
- if sid not in ident:
228
- diffs.append(f'styleIdentity 缺风格 {sid}')
229
- return diffs
230
-
231
-
232
- def audit_ui_palette():
233
- """ui.js 的 header 风格下拉色板必须与 layout-constants.json 的 accent 一致
234
- (ui.js 在运行时块之前执行,无法直接读 PRESETS,故用审计兜住这最后一处手写色值)。"""
235
- ui = ROOT / 'assets' / 'templates' / 'ui.js'
236
- if not ui.exists():
237
- return ['assets/templates/ui.js 不存在']
238
- txt = ui.read_text(encoding='utf-8')
239
- m = re.search(r'var STYLES=\[([\s\S]*?)\];', txt)
240
- if not m:
241
- return ['ui.js 未找到 var STYLES 色板数组']
242
- diffs = []
243
- pairs = re.findall(r"\[\s*'([\w-]+)'\s*,\s*'[^']*'\s*,\s*'(#[0-9a-fA-F]{3,8})'\s*\]", m.group(1))
244
- if not pairs:
245
- return ['ui.js STYLES 色板解析失败(格式变化?)']
246
- for sid, hexv in pairs:
247
- acc = str(((LC.get('styles') or {}).get(sid) or {}).get('accent') or '')
248
- if not acc:
249
- diffs.append(f'ui.js 色板含未知风格 {sid}')
250
- elif acc.lower().lstrip('#') != hexv.lower().lstrip('#'):
251
- diffs.append(f'ui.js 色板 {sid} = {hexv} != layout-constants accent #{acc}')
252
- missing = [s for s in (LC.get('styles') or {}) if s not in {p[0] for p in pairs}]
253
- for s in missing:
254
- diffs.append(f'ui.js 色板缺风格 {s}')
255
- return diffs
256
-
257
-
258
- def audit_single_source():
259
- """单源完整性:styleAccents 覆盖 / charts 登记表自洽 / 图表最小尺寸键名合法。"""
260
- diffs = []
261
- accents = {k: v for k, v in (LC.get('styleAccents') or {}).items() if not k.startswith('$')}
262
- for key, side in (('styles', 'light'), ('stylesDark', 'dark')):
263
- for sid, toks in (LC.get(key) or {}).items():
264
- acc = str(toks.get('accent') or '').lower().lstrip('#')
265
- have = {str(x).lower().lstrip('#') for x in (accents.get(sid) or [])}
266
- if acc and acc not in have:
267
- diffs.append(f'styleAccents.{sid} 未收录 {side} 强调色 #{acc}(单一强调色检查会漏判)')
268
- missing = [s for s in (LC.get('styles') or {}) if s not in accents]
269
- for s in missing:
270
- diffs.append(f'styleAccents 缺风格 {s}')
271
- charts = LC.get('charts') or {}
272
- types = set(charts.get('types') or [])
273
- for t, rule in (charts.get('minSize') or {}).items():
274
- if types and t not in types:
275
- diffs.append(f'charts.minSize.{t} 未登记在 charts.types')
276
- for k in (rule or {}):
277
- if k not in ('pxWidthMin', 'vbHeightMin'):
278
- diffs.append(f'charts.minSize.{t}.{k} 键名非法(应为 pxWidthMin / vbHeightMin)')
279
- for t in types:
280
- if t not in (charts.get('minSize') or {}):
281
- diffs.append(f'charts.types.{t} 缺最小尺寸登记(校验器将跳过该类型)')
282
- for key in ('checkBudgets', 'styleAccents', 'aiFlavor', 'pageTypeGeometry',
283
- 'styleDataColors', 'styleDataColorsDark'):
284
- if key not in LC:
285
- diffs.append(f'layout-constants.json 缺 {key}(validate_report.py 依赖它做单源校验)')
286
- return diffs
287
-
288
-
289
- css_styles = parse_css()
290
- n_styles = len(css_styles)
291
- print(f'解析到 {n_styles} 套风格(CSS 侧)')
292
- c_fails = audit_contrast(css_styles)
293
- print('\n── WCAG 对比度审计 ──')
294
- if c_fails:
295
- for f in c_fails:
296
- print(' [FAIL]', f)
297
- else:
298
- print(f' 全部通过({n_styles} 风格 × light/dark × {len(CHECKS)} 组配对 ≥ WCAG 阈值)')
299
-
300
- d_fails = audit_consistency(css_styles, LC['styles'], 'light', 'styles')
301
- print('\n── 双源 token 一致性 · 浅色(engine.css ↔ layout-constants.json styles)──')
302
- if d_fails:
303
- for f in d_fails:
304
- print(' [DIFF]', f)
305
- else:
306
- print(f' 全部一致({n_styles} 风格 × 9 字段)')
307
-
308
- dd_fails = audit_consistency(css_styles, LC.get('stylesDark') or {}, 'dark', 'stylesDark')
309
- print('\n── 双源 token 一致性 · 深色(engine.css dark 块 ↔ layout-constants.json stylesDark)──')
310
- if dd_fails:
311
- for f in dd_fails:
312
- print(' [DIFF]', f)
313
- else:
314
- print(f' 全部一致({n_styles} 风格 × 9 字段 · PPTX 深色导出与 HTML 深色主题同源)')
315
-
316
- s_fails = audit_single_source()
317
- print('\n── 单源完整性(styleAccents / charts 登记表 / 校验预算键)──')
318
- if s_fails:
319
- for f in s_fails:
320
- print(' [DIFF]', f)
321
- else:
322
- n_charts = len((LC.get('charts') or {}).get('types') or [])
323
- n_acc = len([k for k in (LC.get('styleAccents') or {}) if not k.startswith('$')])
324
- print(f' 全部一致(强调色表 {n_acc} 套 · 图表登记 {n_charts} 种 · 校验预算 {len([k for k in (LC.get("checkBudgets") or {}) if not k.startswith("$")])} 模式)')
325
-
326
- dp_fails = audit_data_palette(css_styles)
327
- print('\n── 编码色板(9 风格 × light/dark × c1–c5 · 双源 + 对比度 + 可区分性)──')
328
- if dp_fails:
329
- for f in dp_fails:
330
- print(' [DIFF]', f)
331
- else:
332
- print(f' 全部通过({len([k for k in (LC.get("styleDataColors") or {}) if not k.startswith("$")])} 套 × 2 主题 × 5 色)')
333
-
334
- u_fails = audit_ui_palette()
335
- print('\n── ui.js 风格下拉色板 ↔ layout-constants.json accent ──')
336
- if u_fails:
337
- for f in u_fails:
338
- print(' [DIFF]', f)
339
- else:
340
- print(f' 全部一致({n_styles} 套色板 = {n_styles} 套 accent)')
341
-
342
- id_fails = audit_style_identity(css_styles)
343
- print('\n── 风格语汇(font / radius / typeBoost / serifDisplay / link · engine.css ↔ styleIdentity)──')
344
- if id_fails:
345
- for f in id_fails:
346
- print(' [DIFF]', f)
347
- else:
348
- n_id = len([k for k in (LC.get('styleIdentity') or {}) if not k.startswith('$')])
349
- print(f' 全部落地({n_id} 套风格语汇与 engine.css 覆盖块一致)')
350
-
351
- sys.exit(1 if (c_fails or d_fails or dd_fails or s_fails or dp_fails or u_fails or id_fails) else 0)
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 风格与单源质量审计工具:
4
+ ① WCAG 对比度:9 风格 × light/dark 的关键 token 配对(MD3/WCAG 阈值)
5
+ ② 双源一致性:engine.css(HTML 侧 token)vs layout-constants.json(PPTX 侧 token)
6
+ ③ 单源完整性:styleAccents 覆盖各风格 light/dark 强调色;charts.minSize ⊆ charts.types;
7
+ 图表最小尺寸键名合法(pxWidthMin / vbHeightMin)
8
+ 用法:python scripts/audit_styles.py(改任何风格色值或常量后必跑;退出码 0 = 全部通过)
9
+ """
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+ CSS = (ROOT / 'assets' / 'templates' / 'engine.css').read_text(encoding='utf-8')
18
+ LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
19
+
20
+
21
+ def parse_block(block):
22
+ toks = {}
23
+ for m in re.finditer(r'--([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})', block):
24
+ toks[m.group(1)] = m.group(2).lower()
25
+ return toks
26
+
27
+
28
+ def parse_css():
29
+ """返回 {style: {'light': toks, 'dark': toks}}。
30
+ 9 套风格均有显式 html[data-style="X"]{...}(light)与
31
+ html[data-style="X"][data-theme="dark"]{...}(dark)两块;:root 仅作无 data-style 时的默认值。"""
32
+ styles = {}
33
+ pat = re.compile(r'html\[data-style="([\w-]+)"\](?:\[data-theme="dark"\])?\{([\s\S]*?)\}')
34
+ for m in pat.finditer(CSS):
35
+ sid, body = m.group(1), m.group(2)
36
+ sel = m.group(0).split('{', 1)[0]
37
+ side = 'dark' if 'data-theme="dark"' in sel else 'light'
38
+ # 同一 (风格, 主题) 可能由多个块叠加(基础 token 块 + 编码色板块)→ 合并而非覆盖
39
+ styles.setdefault(sid, {}).setdefault(side, {}).update(parse_block(body))
40
+ return styles
41
+
42
+
43
+ def rel_lum(hexc):
44
+ h = hexc.lstrip('#')
45
+ if len(h) == 3:
46
+ h = ''.join(c * 2 for c in h)
47
+ r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
48
+ def f(c):
49
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
50
+ r, g, b = f(r), f(g), f(b)
51
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
52
+
53
+
54
+ def contrast(a, b):
55
+ la, lb = rel_lum(a), rel_lum(b)
56
+ hi, lo = max(la, lb), min(la, lb)
57
+ return (hi + 0.05) / (lo + 0.05)
58
+
59
+
60
+ def hex2rgb(h):
61
+ h = h.lstrip('#')
62
+ if len(h) == 3:
63
+ h = ''.join(c * 2 for c in h)
64
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
65
+
66
+
67
+ def rgb2hls(r, g, b):
68
+ """返回 (色相°, 饱和, 明度) —— 用于编码色板两两可区分性判定。"""
69
+ r, g, b = r / 255, g / 255, b / 255
70
+ mx, mn = max(r, g, b), min(r, g, b)
71
+ l = (mx + mn) / 2
72
+ if mx == mn:
73
+ return 0.0, 0.0, l
74
+ d = mx - mn
75
+ s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
76
+ if mx == r:
77
+ h = ((g - b) / d) % 6
78
+ elif mx == g:
79
+ h = (b - r) / d + 2
80
+ else:
81
+ h = (r - g) / d + 4
82
+ return h * 60, s, l
83
+
84
+
85
+ CHECKS = [
86
+ # (名称, token对, 阈值) 阈值依据:WCAG AA 正文 4.5 / 大字与图形 3.0
87
+ ('正文 text / bg', ('text', 'bg'), 4.5),
88
+ ('次级 text-2 / bg', ('text-2', 'bg'), 4.5),
89
+ ('弱化 text-3 / bg(注解)', ('text-3', 'bg'), 3.0),
90
+ ('强调文字 accent-text / bg', ('accent-text', 'bg'), 4.5),
91
+ ('强调色 accent / bg(图形)', ('accent', 'bg'), 3.0),
92
+ ('按钮文字 accent-on / accent', ('accent-on', 'accent'), 4.5),
93
+ ('正文 text / surface-1', ('text', 'surface-1'), 4.5),
94
+ ('次级 text-2 / surface-1', ('text-2', 'surface-1'), 4.5),
95
+ ]
96
+
97
+
98
+ def audit_contrast(styles):
99
+ fails = []
100
+ for sid in sorted(styles):
101
+ for theme in ('light', 'dark'):
102
+ t = styles[sid].get(theme)
103
+ if not t or 'bg' not in t:
104
+ continue
105
+ for name, (fg, bg), th in CHECKS:
106
+ if fg not in t or bg not in t:
107
+ continue
108
+ c = contrast(t[fg], t[bg])
109
+ if c < th:
110
+ fails.append(f'{sid}.{theme}: {name} = {c:.2f} < {th} ({t[fg]} on {t[bg]})')
111
+ return fails
112
+
113
+
114
+ def audit_consistency(styles, lc_styles, side='light', lc_key='styles'):
115
+ """PPTX 常量(layout-constants.json)与 CSS token 的映射对照(归一化 # 前缀与 3 位缩写)。
116
+ side='dark' 时对照 stylesDark ↔ engine.css 深色块(PPTX 深色导出与 HTML 深色主题同源)。"""
117
+ def norm(v):
118
+ v = (v or '').lower().lstrip('#')
119
+ if len(v) == 3:
120
+ v = ''.join(c * 2 for c in v)
121
+ return v
122
+ MAP = [('ink', 'text'), ('body', 'text-2'), ('faint', 'text-3'),
123
+ ('bg', 'bg'), ('surface', 'surface-1'), ('line', 'border'),
124
+ ('accent', 'accent'), ('onAccent', 'accent-on'), ('soft', 'accent-soft')]
125
+ diffs = []
126
+ for sid, pt in lc_styles.items():
127
+ css = styles.get(sid, {}).get(side, {})
128
+ for jk, ck in MAP:
129
+ jv, cv = norm(pt.get(jk)), norm(css.get(ck))
130
+ if jv and cv and jv != cv:
131
+ diffs.append(f'{sid}.{side}: JSON.{jk}={jv} != CSS.{ck}={cv}')
132
+ elif jv and not cv:
133
+ diffs.append(f'{sid}.{side}: CSS 缺 {ck}')
134
+ if not lc_styles:
135
+ diffs.append(f'layout-constants.json 缺 {lc_key}(起 PPTX 深色导出依赖它)')
136
+ return diffs
137
+
138
+
139
+ def audit_data_palette(styles):
140
+ """编码色板审计:9 套风格 × light/dark,各 5 色。
141
+ ① 覆盖完整(styleDataColors / styleDataColorsDark 与 styles / stylesDark 键集一致)
142
+ ② 每套 5 色、与 engine.css 覆盖块逐值一致(双源)
143
+ ③ 每个色对所在主题 bg 的对比度 ≥ 3.0(图形元素可辨下限)
144
+ ④ 同套内两两可区分(色相差 ≥25° 或明度差 ≥0.12)"""
145
+ diffs = []
146
+ for key, theme in (('styleDataColors', 'light'), ('styleDataColorsDark', 'dark')):
147
+ table = {k: v for k, v in (LC.get(key) or {}).items() if not k.startswith('$')}
148
+ base = {k: v for k, v in (LC.get('styles' if theme == 'light' else 'stylesDark') or {}).items()
149
+ if not k.startswith('$')}
150
+ for sid in base:
151
+ if sid not in table:
152
+ diffs.append(f'{key} 缺风格 {sid}')
153
+ for sid, cols in table.items():
154
+ if len(cols) != 5:
155
+ diffs.append(f'{key}.{sid} 色数 {len(cols)} != 5')
156
+ continue
157
+ css = styles.get(sid, {}).get(theme, {})
158
+ for i, c in enumerate(cols, 1):
159
+ if c.startswith('#'):
160
+ diffs.append(f'{key}.{sid}.c{i}={c} 不得带 # 前缀(会原样流入 OOXML srgbClr val)')
161
+ continue
162
+ cv = (css.get(f'c{i}') or '').lower().lstrip('#')
163
+ if not cv:
164
+ diffs.append(f'{key}.{sid}.c{i} 未在 engine.css {theme} 块中定义')
165
+ elif cv != c.lower().lstrip('#'):
166
+ diffs.append(f'{key}.{sid}.c{i}={c} != engine.css {cv}')
167
+ bg = (base.get(sid) or {}).get('bg') or ('#ffffff' if theme == 'light' else '#000000')
168
+ if not bg.startswith('#'):
169
+ bg = '#' + bg
170
+ for c in cols:
171
+ if contrast(c if c.startswith('#') else '#' + c, bg) < 3.0:
172
+ diffs.append(f'{key}.{sid}: {c} 对 bg {bg} 对比 {contrast(c, bg):.2f} < 3.0')
173
+ hls = [rgb2hls(*hex2rgb(c)) for c in cols]
174
+ for i in range(5):
175
+ for j in range(i + 1, 5):
176
+ dh = abs(hls[i][0] - hls[j][0]) % 360
177
+ dh = min(dh, 360 - dh)
178
+ if dh < 25 and abs(hls[i][2] - hls[j][2]) < 0.12:
179
+ diffs.append(f'{key}.{sid}: c{i + 1}/c{j + 1} 不可区分(Δh={dh:.0f}°)')
180
+ return diffs
181
+
182
+
183
+ def audit_style_identity(styles):
184
+ """风格语汇审计:每套风格的 HTML 身份 token 必须落地(字体栈 / 圆角 / 字阶微调 / 链接色)。
185
+ 与 layout-constants.json 的 styleIdentity 清单逐项对照,拦「styles.md 写了、engine.css 没落」的漂移。"""
186
+ diffs = []
187
+ ident = {k: v for k, v in (LC.get('styleIdentity') or {}).items() if not k.startswith('$')}
188
+ if not ident:
189
+ return ['layout-constants.json 缺 styleIdentity(风格语汇清单)']
190
+ for sid, rule in ident.items():
191
+ css = styles.get(sid, {}).get('light', {})
192
+ if not css:
193
+ diffs.append(f'styleIdentity.{sid}: engine.css 缺 light 覆盖块')
194
+ continue
195
+ raw = re.search(
196
+ r'html\[data-style="%s"\]\{([\s\S]*?)\}' % re.escape(sid), CSS)
197
+ body = raw.group(1) if raw else ''
198
+ if '--font-body' not in body:
199
+ diffs.append(f'{sid}: 缺 --font-body(风格字体栈未落地)')
200
+ if '--font-display' not in body:
201
+ diffs.append(f'{sid}: 缺 --font-display(标题字体栈未落地)')
202
+ radius = str(rule.get('radius') or '')
203
+ if radius and f'--radius:{radius}' not in body.replace(' ', ''):
204
+ # 允许 --radius: 16px 带空格
205
+ if not re.search(r'--radius:\s*%s' % re.escape(radius), body):
206
+ diffs.append(f'{sid}: --radius 未设为 {radius}')
207
+ if rule.get('serifDisplay'):
208
+ fd = re.search(r'--font-display:\s*([^;]+);', body)
209
+ if not fd or 'serif' not in fd.group(1).lower():
210
+ diffs.append(f'{sid}: serifDisplay=true 但 --font-display 非 serif 栈')
211
+ if rule.get('typeBoost') and '--fs-display' not in body:
212
+ diffs.append(f'{sid}: typeBoost=true 但未覆盖 --fs-display')
213
+ if rule.get('link'):
214
+ if '--link' not in body:
215
+ diffs.append(f'{sid}: 声明 link 但缺 --link token')
216
+ else:
217
+ lv = re.search(r'--link:\s*([^;]+);', body)
218
+ if lv and lv.group(1).strip().lower() != str(rule['link']).lower():
219
+ diffs.append(f'{sid}: --link={lv.group(1).strip()} != styleIdentity {rule["link"]}')
220
+ le = rule.get('letterEyebrow')
221
+ if le and f'--letter-eyebrow:{le}' not in body.replace(' ', ''):
222
+ if not re.search(r'--letter-eyebrow:\s*%s' % re.escape(le), body):
223
+ diffs.append(f'{sid}: --letter-eyebrow 未设为 {le}')
224
+ for sid in (LC.get('styles') or {}):
225
+ if sid.startswith('$'):
226
+ continue
227
+ if sid not in ident:
228
+ diffs.append(f'styleIdentity 缺风格 {sid}')
229
+ return diffs
230
+
231
+
232
+ def audit_ui_palette():
233
+ """ui.js 的 header 风格下拉色板必须与 layout-constants.json 的 accent 一致
234
+ (ui.js 在运行时块之前执行,无法直接读 PRESETS,故用审计兜住这最后一处手写色值)。"""
235
+ ui = ROOT / 'assets' / 'templates' / 'ui.js'
236
+ if not ui.exists():
237
+ return ['assets/templates/ui.js 不存在']
238
+ txt = ui.read_text(encoding='utf-8')
239
+ m = re.search(r'var STYLES=\[([\s\S]*?)\];', txt)
240
+ if not m:
241
+ return ['ui.js 未找到 var STYLES 色板数组']
242
+ diffs = []
243
+ pairs = re.findall(r"\[\s*'([\w-]+)'\s*,\s*'[^']*'\s*,\s*'(#[0-9a-fA-F]{3,8})'\s*\]", m.group(1))
244
+ if not pairs:
245
+ return ['ui.js STYLES 色板解析失败(格式变化?)']
246
+ for sid, hexv in pairs:
247
+ acc = str(((LC.get('styles') or {}).get(sid) or {}).get('accent') or '')
248
+ if not acc:
249
+ diffs.append(f'ui.js 色板含未知风格 {sid}')
250
+ elif acc.lower().lstrip('#') != hexv.lower().lstrip('#'):
251
+ diffs.append(f'ui.js 色板 {sid} = {hexv} != layout-constants accent #{acc}')
252
+ missing = [s for s in (LC.get('styles') or {}) if s not in {p[0] for p in pairs}]
253
+ for s in missing:
254
+ diffs.append(f'ui.js 色板缺风格 {s}')
255
+ return diffs
256
+
257
+
258
+ def audit_single_source():
259
+ """单源完整性:styleAccents 覆盖 / charts 登记表自洽 / 图表最小尺寸键名合法。"""
260
+ diffs = []
261
+ accents = {k: v for k, v in (LC.get('styleAccents') or {}).items() if not k.startswith('$')}
262
+ for key, side in (('styles', 'light'), ('stylesDark', 'dark')):
263
+ for sid, toks in (LC.get(key) or {}).items():
264
+ acc = str(toks.get('accent') or '').lower().lstrip('#')
265
+ have = {str(x).lower().lstrip('#') for x in (accents.get(sid) or [])}
266
+ if acc and acc not in have:
267
+ diffs.append(f'styleAccents.{sid} 未收录 {side} 强调色 #{acc}(单一强调色检查会漏判)')
268
+ missing = [s for s in (LC.get('styles') or {}) if s not in accents]
269
+ for s in missing:
270
+ diffs.append(f'styleAccents 缺风格 {s}')
271
+ charts = LC.get('charts') or {}
272
+ types = set(charts.get('types') or [])
273
+ for t, rule in (charts.get('minSize') or {}).items():
274
+ if types and t not in types:
275
+ diffs.append(f'charts.minSize.{t} 未登记在 charts.types')
276
+ for k in (rule or {}):
277
+ if k not in ('pxWidthMin', 'vbHeightMin'):
278
+ diffs.append(f'charts.minSize.{t}.{k} 键名非法(应为 pxWidthMin / vbHeightMin)')
279
+ for t in types:
280
+ if t not in (charts.get('minSize') or {}):
281
+ diffs.append(f'charts.types.{t} 缺最小尺寸登记(校验器将跳过该类型)')
282
+ for key in ('checkBudgets', 'styleAccents', 'aiFlavor', 'pageTypeGeometry',
283
+ 'styleDataColors', 'styleDataColorsDark'):
284
+ if key not in LC:
285
+ diffs.append(f'layout-constants.json 缺 {key}(validate_report.py 依赖它做单源校验)')
286
+ return diffs
287
+
288
+
289
+ css_styles = parse_css()
290
+ n_styles = len(css_styles)
291
+ print(f'解析到 {n_styles} 套风格(CSS 侧)')
292
+ c_fails = audit_contrast(css_styles)
293
+ print('\n── WCAG 对比度审计 ──')
294
+ if c_fails:
295
+ for f in c_fails:
296
+ print(' [FAIL]', f)
297
+ else:
298
+ print(f' 全部通过({n_styles} 风格 × light/dark × {len(CHECKS)} 组配对 ≥ WCAG 阈值)')
299
+
300
+ d_fails = audit_consistency(css_styles, LC['styles'], 'light', 'styles')
301
+ print('\n── 双源 token 一致性 · 浅色(engine.css ↔ layout-constants.json styles)──')
302
+ if d_fails:
303
+ for f in d_fails:
304
+ print(' [DIFF]', f)
305
+ else:
306
+ print(f' 全部一致({n_styles} 风格 × 9 字段)')
307
+
308
+ dd_fails = audit_consistency(css_styles, LC.get('stylesDark') or {}, 'dark', 'stylesDark')
309
+ print('\n── 双源 token 一致性 · 深色(engine.css dark 块 ↔ layout-constants.json stylesDark)──')
310
+ if dd_fails:
311
+ for f in dd_fails:
312
+ print(' [DIFF]', f)
313
+ else:
314
+ print(f' 全部一致({n_styles} 风格 × 9 字段 · PPTX 深色导出与 HTML 深色主题同源)')
315
+
316
+ s_fails = audit_single_source()
317
+ print('\n── 单源完整性(styleAccents / charts 登记表 / 校验预算键)──')
318
+ if s_fails:
319
+ for f in s_fails:
320
+ print(' [DIFF]', f)
321
+ else:
322
+ n_charts = len((LC.get('charts') or {}).get('types') or [])
323
+ n_acc = len([k for k in (LC.get('styleAccents') or {}) if not k.startswith('$')])
324
+ print(f' 全部一致(强调色表 {n_acc} 套 · 图表登记 {n_charts} 种 · 校验预算 {len([k for k in (LC.get("checkBudgets") or {}) if not k.startswith("$")])} 模式)')
325
+
326
+ dp_fails = audit_data_palette(css_styles)
327
+ print('\n── 编码色板(9 风格 × light/dark × c1–c5 · 双源 + 对比度 + 可区分性)──')
328
+ if dp_fails:
329
+ for f in dp_fails:
330
+ print(' [DIFF]', f)
331
+ else:
332
+ print(f' 全部通过({len([k for k in (LC.get("styleDataColors") or {}) if not k.startswith("$")])} 套 × 2 主题 × 5 色)')
333
+
334
+ u_fails = audit_ui_palette()
335
+ print('\n── ui.js 风格下拉色板 ↔ layout-constants.json accent ──')
336
+ if u_fails:
337
+ for f in u_fails:
338
+ print(' [DIFF]', f)
339
+ else:
340
+ print(f' 全部一致({n_styles} 套色板 = {n_styles} 套 accent)')
341
+
342
+ id_fails = audit_style_identity(css_styles)
343
+ print('\n── 风格语汇(font / radius / typeBoost / serifDisplay / link · engine.css ↔ styleIdentity)──')
344
+ if id_fails:
345
+ for f in id_fails:
346
+ print(' [DIFF]', f)
347
+ else:
348
+ n_id = len([k for k in (LC.get('styleIdentity') or {}) if not k.startswith('$')])
349
+ print(f' 全部落地({n_id} 套风格语汇与 engine.css 覆盖块一致)')
350
+
351
+ sys.exit(1 if (c_fails or d_fails or dd_fails or s_fails or dp_fails or u_fails or id_fails) else 0)