@topmindspace/tms-skills 2.0.1 → 2.0.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 (98) hide show
  1. package/CHANGELOG.md +139 -134
  2. package/bin/tms-skills.js +160 -160
  3. package/package.json +48 -48
  4. package/top-ppt-html/README.md +254 -254
  5. package/top-ppt-html/SKILL.md +111 -111
  6. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3873 -3926
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3873 -3926
  9. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4272 -4325
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4272 -4325
  13. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
  14. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4272 -4325
  15. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  16. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5474 -5527
  17. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
  18. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5474 -5527
  19. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
  20. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5474 -5527
  21. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  22. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5474 -5527
  23. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
  24. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  25. package/top-ppt-html/assets/style-gallery.html +559 -589
  26. package/top-ppt-html/assets/templates/architecture.html +3675 -3728
  27. package/top-ppt-html/assets/templates/engine.css +787 -840
  28. package/top-ppt-html/assets/templates/presentation.html +3685 -3738
  29. package/top-ppt-html/assets/templates/research.html +3964 -4017
  30. package/top-ppt-html/assets/templates/ui.js +520 -520
  31. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  32. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  33. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  34. package/top-ppt-html/assets/theme-overview.png +0 -0
  35. package/top-ppt-html/evals/prompts.csv +15 -15
  36. package/top-ppt-html/evals/rubric.schema.json +25 -25
  37. package/top-ppt-html/evals/run_evals.py +220 -220
  38. package/top-ppt-html/evals/trace.example.json +16 -16
  39. package/top-ppt-html/package-lock.json +175 -0
  40. package/top-ppt-html/package.json +30 -35
  41. package/top-ppt-html/references/charts-basic.md +624 -624
  42. package/top-ppt-html/references/charts-discipline.md +108 -108
  43. package/top-ppt-html/references/charts-extended.md +482 -482
  44. package/top-ppt-html/references/charts.md +28 -28
  45. package/top-ppt-html/references/components-atoms.md +624 -624
  46. package/top-ppt-html/references/components.md +30 -30
  47. package/top-ppt-html/references/content-rules.md +490 -490
  48. package/top-ppt-html/references/design-system-engine.md +235 -235
  49. package/top-ppt-html/references/design-system.md +471 -478
  50. package/top-ppt-html/references/failure-modes.md +214 -214
  51. package/top-ppt-html/references/high-fidelity.md +127 -127
  52. package/top-ppt-html/references/icons.md +397 -397
  53. package/top-ppt-html/references/industry-benchmark.md +105 -105
  54. package/top-ppt-html/references/infographics-stats.md +308 -308
  55. package/top-ppt-html/references/infographics-structure.md +226 -226
  56. package/top-ppt-html/references/infographics.md +43 -43
  57. package/top-ppt-html/references/layout-grammar.md +315 -315
  58. package/top-ppt-html/references/layouts-architecture.md +108 -108
  59. package/top-ppt-html/references/layouts-combo.md +600 -600
  60. package/top-ppt-html/references/layouts-research.md +160 -160
  61. package/top-ppt-html/references/modes.md +254 -254
  62. package/top-ppt-html/references/outline-design.md +275 -275
  63. package/top-ppt-html/references/playbook.md +266 -266
  64. package/top-ppt-html/references/pptx-export.md +209 -209
  65. package/top-ppt-html/references/reform-plan.md +252 -252
  66. package/top-ppt-html/references/styles.md +336 -370
  67. package/top-ppt-html/references/tech-design.md +138 -138
  68. package/top-ppt-html/scripts/audit_css.py +109 -109
  69. package/top-ppt-html/scripts/audit_docs.py +176 -176
  70. package/top-ppt-html/scripts/audit_skill.py +220 -220
  71. package/top-ppt-html/scripts/audit_styles.py +293 -351
  72. package/top-ppt-html/scripts/build_examples.py +2276 -2276
  73. package/top-ppt-html/scripts/build_pptx.js +2380 -2380
  74. package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
  75. package/top-ppt-html/scripts/checks_html.py +127 -127
  76. package/top-ppt-html/scripts/cross_verify.py +294 -294
  77. package/top-ppt-html/scripts/env_probe.py +158 -158
  78. package/top-ppt-html/scripts/extract_model.py +210 -210
  79. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  80. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  81. package/top-ppt-html/scripts/layout-constants.json +3309 -3377
  82. package/top-ppt-html/scripts/layout_slots.json +830 -830
  83. package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
  84. package/top-ppt-html/scripts/measure_height.py +178 -178
  85. package/top-ppt-html/scripts/model-schema.json +547 -547
  86. package/top-ppt-html/scripts/negative_tests.py +307 -307
  87. package/top-ppt-html/scripts/package_skill.py +291 -291
  88. package/top-ppt-html/scripts/prepare_images.py +341 -341
  89. package/top-ppt-html/scripts/probe_image_export.py +188 -188
  90. package/top-ppt-html/scripts/quality_gate.py +301 -301
  91. package/top-ppt-html/scripts/regression.py +307 -308
  92. package/top-ppt-html/scripts/render_compare.py +275 -275
  93. package/top-ppt-html/scripts/render_from_model.py +698 -698
  94. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  95. package/top-ppt-html/scripts/section-file-map.json +104 -104
  96. package/top-ppt-html/scripts/sync_runtime.py +662 -662
  97. package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
  98. package/top-ppt-html/scripts/validate_report.py +1456 -1456
@@ -1,351 +1,293 @@
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_ui_palette():
184
+ """ui.js 的 header 风格下拉色板必须与 layout-constants.json 的 accent 一致
185
+ (ui.js 在运行时块之前执行,无法直接读 PRESETS,故用审计兜住这最后一处手写色值)。"""
186
+ ui = ROOT / 'assets' / 'templates' / 'ui.js'
187
+ if not ui.exists():
188
+ return ['assets/templates/ui.js 不存在']
189
+ txt = ui.read_text(encoding='utf-8')
190
+ m = re.search(r'var STYLES=\[([\s\S]*?)\];', txt)
191
+ if not m:
192
+ return ['ui.js 未找到 var STYLES 色板数组']
193
+ diffs = []
194
+ pairs = re.findall(r"\[\s*'([\w-]+)'\s*,\s*'[^']*'\s*,\s*'(#[0-9a-fA-F]{3,8})'\s*\]", m.group(1))
195
+ if not pairs:
196
+ return ['ui.js STYLES 色板解析失败(格式变化?)']
197
+ for sid, hexv in pairs:
198
+ acc = str(((LC.get('styles') or {}).get(sid) or {}).get('accent') or '')
199
+ if not acc:
200
+ diffs.append(f'ui.js 色板含未知风格 {sid}')
201
+ elif acc.lower().lstrip('#') != hexv.lower().lstrip('#'):
202
+ diffs.append(f'ui.js 色板 {sid} = {hexv} != layout-constants accent #{acc}')
203
+ missing = [s for s in (LC.get('styles') or {}) if s not in {p[0] for p in pairs}]
204
+ for s in missing:
205
+ diffs.append(f'ui.js 色板缺风格 {s}')
206
+ return diffs
207
+
208
+
209
+ def audit_single_source():
210
+ """单源完整性:styleAccents 覆盖 / charts 登记表自洽 / 图表最小尺寸键名合法。"""
211
+ diffs = []
212
+ accents = {k: v for k, v in (LC.get('styleAccents') or {}).items() if not k.startswith('$')}
213
+ for key, side in (('styles', 'light'), ('stylesDark', 'dark')):
214
+ for sid, toks in (LC.get(key) or {}).items():
215
+ acc = str(toks.get('accent') or '').lower().lstrip('#')
216
+ have = {str(x).lower().lstrip('#') for x in (accents.get(sid) or [])}
217
+ if acc and acc not in have:
218
+ diffs.append(f'styleAccents.{sid} 未收录 {side} 强调色 #{acc}(单一强调色检查会漏判)')
219
+ missing = [s for s in (LC.get('styles') or {}) if s not in accents]
220
+ for s in missing:
221
+ diffs.append(f'styleAccents 缺风格 {s}')
222
+ charts = LC.get('charts') or {}
223
+ types = set(charts.get('types') or [])
224
+ for t, rule in (charts.get('minSize') or {}).items():
225
+ if types and t not in types:
226
+ diffs.append(f'charts.minSize.{t} 未登记在 charts.types')
227
+ for k in (rule or {}):
228
+ if k not in ('pxWidthMin', 'vbHeightMin'):
229
+ diffs.append(f'charts.minSize.{t}.{k} 键名非法(应为 pxWidthMin / vbHeightMin)')
230
+ for t in types:
231
+ if t not in (charts.get('minSize') or {}):
232
+ diffs.append(f'charts.types.{t} 缺最小尺寸登记(校验器将跳过该类型)')
233
+ for key in ('checkBudgets', 'styleAccents', 'aiFlavor', 'pageTypeGeometry',
234
+ 'styleDataColors', 'styleDataColorsDark'):
235
+ if key not in LC:
236
+ diffs.append(f'layout-constants.json 缺 {key}(validate_report.py 依赖它做单源校验)')
237
+ return diffs
238
+
239
+
240
+ css_styles = parse_css()
241
+ n_styles = len(css_styles)
242
+ print(f'解析到 {n_styles} 套风格(CSS 侧)')
243
+ c_fails = audit_contrast(css_styles)
244
+ print('\n── WCAG 对比度审计 ──')
245
+ if c_fails:
246
+ for f in c_fails:
247
+ print(' [FAIL]', f)
248
+ else:
249
+ print(f' 全部通过({n_styles} 风格 × light/dark × {len(CHECKS)} 组配对 ≥ WCAG 阈值)')
250
+
251
+ d_fails = audit_consistency(css_styles, LC['styles'], 'light', 'styles')
252
+ print('\n── 双源 token 一致性 · 浅色(engine.css ↔ layout-constants.json styles)──')
253
+ if d_fails:
254
+ for f in d_fails:
255
+ print(' [DIFF]', f)
256
+ else:
257
+ print(f' 全部一致({n_styles} 风格 × 9 字段)')
258
+
259
+ dd_fails = audit_consistency(css_styles, LC.get('stylesDark') or {}, 'dark', 'stylesDark')
260
+ print('\n── 双源 token 一致性 · 深色(engine.css dark 块 ↔ layout-constants.json stylesDark)──')
261
+ if dd_fails:
262
+ for f in dd_fails:
263
+ print(' [DIFF]', f)
264
+ else:
265
+ print(f' 全部一致({n_styles} 风格 × 9 字段 · PPTX 深色导出与 HTML 深色主题同源)')
266
+
267
+ s_fails = audit_single_source()
268
+ print('\n── 单源完整性(styleAccents / charts 登记表 / 校验预算键)──')
269
+ if s_fails:
270
+ for f in s_fails:
271
+ print(' [DIFF]', f)
272
+ else:
273
+ n_charts = len((LC.get('charts') or {}).get('types') or [])
274
+ n_acc = len([k for k in (LC.get('styleAccents') or {}) if not k.startswith('$')])
275
+ print(f' 全部一致(强调色表 {n_acc} 套 · 图表登记 {n_charts} 种 · 校验预算 {len([k for k in (LC.get("checkBudgets") or {}) if not k.startswith("$")])} 模式)')
276
+
277
+ dp_fails = audit_data_palette(css_styles)
278
+ print('\n── 编码色板(9 风格 × light/dark × c1–c5 · 双源 + 对比度 + 可区分性)──')
279
+ if dp_fails:
280
+ for f in dp_fails:
281
+ print(' [DIFF]', f)
282
+ else:
283
+ print(f' 全部通过({len([k for k in (LC.get("styleDataColors") or {}) if not k.startswith("$")])} 套 × 2 主题 × 5 色)')
284
+
285
+ u_fails = audit_ui_palette()
286
+ print('\n── ui.js 风格下拉色板 ↔ layout-constants.json accent ──')
287
+ if u_fails:
288
+ for f in u_fails:
289
+ print(' [DIFF]', f)
290
+ else:
291
+ print(f' 全部一致({n_styles} 套色板 = {n_styles} 套 accent)')
292
+
293
+ sys.exit(1 if (c_fails or d_fails or dd_fails or s_fails or dp_fails or u_fails) else 0)