@topmindspace/tms-skills 0.1.1 → 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 (54) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/package.json +1 -1
  3. package/top-ppt-html/README.md +38 -130
  4. package/top-ppt-html/SKILL.md +34 -19
  5. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  6. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  7. package/top-ppt-html/assets/theme-overview.png +0 -0
  8. package/top-ppt-html/package.json +1 -1
  9. package/top-ppt-html/references/charts-discipline.md +3 -1
  10. package/top-ppt-html/references/charts.md +24 -15
  11. package/top-ppt-html/references/content-rules.md +54 -34
  12. package/top-ppt-html/references/default-surface.md +61 -0
  13. package/top-ppt-html/references/design-system.md +2 -2
  14. package/top-ppt-html/references/failure-modes.md +14 -1
  15. package/top-ppt-html/references/icons.md +56 -277
  16. package/top-ppt-html/references/layout-grammar.md +16 -6
  17. package/top-ppt-html/references/modes.md +16 -11
  18. package/top-ppt-html/references/playbook.md +31 -13
  19. package/top-ppt-html/references/pptx-export.md +20 -2
  20. package/top-ppt-html/references/presentation-craft.md +69 -0
  21. package/top-ppt-html/references/tech-design.md +5 -5
  22. package/top-ppt-html/scripts/audit_skill.py +13 -2
  23. package/top-ppt-html/scripts/build_examples.py +39 -2260
  24. package/top-ppt-html/scripts/build_pptx.js +6 -0
  25. package/top-ppt-html/scripts/checks_html.py +39 -0
  26. package/top-ppt-html/scripts/cross_verify.py +5 -1
  27. package/top-ppt-html/scripts/extract_snippet.py +2 -2
  28. package/top-ppt-html/scripts/layout-constants.json +111 -18
  29. package/top-ppt-html/scripts/lib_layout_regions.js +7 -9
  30. package/top-ppt-html/scripts/negative_tests.py +127 -1
  31. package/top-ppt-html/scripts/package_skill.py +13 -10
  32. package/top-ppt-html/scripts/quality_gate.py +11 -3
  33. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  34. package/top-ppt-html/scripts/regression.py +1 -1
  35. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  36. package/top-ppt-html/scripts/sync_runtime.py +22 -25
  37. package/top-ppt-html/scripts/validate_pptx.py +69 -0
  38. package/top-ppt-html/scripts/validate_report.py +309 -10
  39. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  40. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  41. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  42. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  43. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  44. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  45. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  46. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  47. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  48. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  49. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  50. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  51. package/top-ppt-html/references/design-system-engine.md +0 -235
  52. package/top-ppt-html/references/industry-benchmark.md +0 -105
  53. package/top-ppt-html/references/reform-plan.md +0 -252
  54. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 布局选型建议(Batch 2)
4
+
5
+ 用法:
6
+ python scripts/recommend_layout.py --mode B --intent "经营分析 占比极偏" --pages 10
7
+ python scripts/recommend_layout.py --mode A --intent "路演" --intent "趋势" --json
8
+ python scripts/recommend_layout.py --mode C --from-model path/to.model.json
9
+ echo '{"mode":"research","intents":["架构"]}' | python scripts/recommend_layout.py --stdin
10
+
11
+ 输出 JSON 页序列:{pageType, skel, chart?, rationale, v?}
12
+ 启发式:playbook V1–V4 / sizeByComplexity / 极偏禁 donut / 高容量多页序列 / layoutSystem.defaultCharts(核图 8);advanced 仅意图命中;Mode A 主力骨架 P1–P4+P6/P10,次级 P7–P9/P11–P12 不默认。
13
+ 不列入 package REQUIRED(可选工具;Batch 3 再定是否纳入)。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ try:
23
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
24
+ except Exception:
25
+ pass
26
+
27
+ ROOT = Path(__file__).resolve().parent
28
+ LC = json.loads((ROOT / 'layout-constants.json').read_text(encoding='utf-8'))
29
+ LS = LC.get('layoutSystem') or {}
30
+ PAGE_TO_PRESET = dict(LS.get('pageToPreset') or {})
31
+ DEFAULT_CHARTS = list(LS.get('defaultCharts') or
32
+ ['bar', 'hbar', 'line', 'donut', 'progress', 'area', 'stack', 'dualline'])
33
+ ADVANCED_CHARTS = set(LS.get('advancedCharts') or [])
34
+ _MODE_SKELS = LS.get('modeSkels') or {}
35
+ PRIMARY_SKELS = {
36
+ 'presentation': list((_MODE_SKELS.get('presentation') or {}).get('primary') or
37
+ ['P1', 'P2', 'P3', 'P4', 'P6', 'P10']),
38
+ 'research': list((_MODE_SKELS.get('research') or {}).get('primary') or
39
+ ['P5', 'P7', 'P8', 'P4', 'P6']),
40
+ 'architecture': list((_MODE_SKELS.get('architecture') or {}).get('primary') or
41
+ ['P9', 'P10', 'P11']),
42
+ }
43
+ SECONDARY_SKELS = {
44
+ 'presentation': list((_MODE_SKELS.get('presentation') or {}).get('secondary') or
45
+ ['P7', 'P8', 'P9', 'P11', 'P12']),
46
+ 'research': list((_MODE_SKELS.get('research') or {}).get('secondary') or []),
47
+ 'architecture': list((_MODE_SKELS.get('architecture') or {}).get('secondary') or []),
48
+ }
49
+
50
+ SIZE_BANDS = list(((LC.get('charts') or {}).get('sizeByComplexity') or {}).get('bands') or [])
51
+ V_MAP = dict(LS.get('vMap') or {'V1': 'P1', 'V2': 'P2', 'V3': 'P3', 'V4': 'P4'})
52
+
53
+ MODE_ALIAS = {
54
+ 'a': 'presentation', 'presentation': 'presentation', '演示': 'presentation',
55
+ 'b': 'research', 'research': 'research', '研究': 'research',
56
+ 'c': 'architecture', 'architecture': 'architecture', '架构': 'architecture',
57
+ }
58
+
59
+ # 模式默认节奏(页型序列);cover/closing 由调用方决定是否保留
60
+ DEFAULT_SEQ = {
61
+ # Mode A:偏 V1–V4 / claim 页(kpi/points/quote);降低 donut 默认权重(构成意图命中再用)
62
+ 'presentation': [
63
+ 'cover', 'agenda', 'kpi', 'points', 'bar', 'points', 'comparison', 'quote', 'closing',
64
+ ],
65
+ 'research': [
66
+ 'cover', 'agenda', 'metrics', 'exhibit', 'twocol', 'halftable',
67
+ 'exhibit', 'threecol', 'matrix', 'flags', 'closing',
68
+ ],
69
+ 'architecture': [
70
+ 'cover', 'diagram', 'lane', 'diagram', 'points', 'closing',
71
+ ],
72
+ }
73
+
74
+ INTENT_RULES: list[tuple[tuple[str, ...], dict]] = [
75
+ (('极偏', '占比失衡', '长尾', '0.5%'), {
76
+ 'pageType': 'kpi', 'chart': None, 'v': 'V3',
77
+ 'rationale': '极偏占比禁 donut/pie → V3 大数+佐证',
78
+ }),
79
+ (('占比', '构成', '份额', '结构'), {
80
+ 'pageType': 'points', 'chart': 'stack', 'v': 'V1',
81
+ 'rationale': '构成类优先 stack/hbar+V1;donut 仅非极偏且需环形直觉时用(降默认权重)',
82
+ }),
83
+ (('趋势', '爬坡', '时间序列', '同比', '环比'), {
84
+ 'pageType': 'bar', 'chart': 'line', 'v': 'V2',
85
+ 'rationale': '时间趋势 → line;演示用 V2 上图下带',
86
+ }),
87
+ (('排名', '对比', '谁高谁低', '对标'), {
88
+ 'pageType': 'bar', 'chart': 'hbar', 'v': 'V1',
89
+ 'rationale': '排名/对比 → hbar',
90
+ }),
91
+ (('架构', '拓扑', '分层', '泳道', '流程'), {
92
+ 'pageType': 'diagram', 'chart': 'network', 'v': None,
93
+ 'rationale': '结构图为王;skel P10/P11',
94
+ }),
95
+ (('路演', '融资', 'pitch', '发布', '汇报', '演讲', 'demo', '演示'), {
96
+ 'pageType': 'points', 'chart': None, 'v': 'V1',
97
+ 'rationale': '演示节奏偏 V1–V4 + claim;简单图禁全幅;motion=none',
98
+ }),
99
+ (('经营', '复盘', '分析', '调研'), {
100
+ 'pageType': 'exhibit', 'chart': 'bar', 'v': None,
101
+ 'rationale': '研究证据页 exhibit + bar',
102
+ }),
103
+ (('瀑布', 'waterfall', '增减拆解', '桥图'), {
104
+ 'pageType': 'bar', 'chart': 'waterfall', 'v': 'V1',
105
+ 'rationale': 'advanced:waterfall 意图命中(非默认池)',
106
+ }),
107
+ (('漏斗', 'funnel', '转化率'), {
108
+ 'pageType': 'bar', 'chart': 'funnel', 'v': 'V1',
109
+ 'rationale': 'advanced:funnel 意图命中',
110
+ }),
111
+ (('甘特', 'gantt', '排期', '里程碑计划'), {
112
+ 'pageType': 'bar', 'chart': 'gantt', 'v': None,
113
+ 'rationale': 'advanced:gantt 意图命中',
114
+ }),
115
+ (('桑基', 'sankey', '流向', '分流'), {
116
+ 'pageType': 'diagram', 'chart': 'sankey', 'v': None,
117
+ 'rationale': 'advanced/信息图:流向意图',
118
+ }),
119
+ (('长文', '密集', '材料多', '证据多', '详细', 'deep dive', '白皮书', '高容量'), {
120
+ 'pageType': 'points', 'chart': None, 'v': 'V1',
121
+ 'rationale': '高容量→多页序列(勿塞一页)',
122
+ 'sequence': 'high_volume',
123
+ }),
124
+ ]
125
+
126
+ # 高容量内容:推荐多页分解,而非一页塞爆
127
+ HIGH_VOLUME_SEQ = {
128
+ 'presentation': [
129
+ 'cover', 'agenda',
130
+ 'kpi', # claim
131
+ 'points', # evidence cards
132
+ 'cards', # secondary evidence
133
+ 'bar', # chart evidence
134
+ 'points', # detail list
135
+ 'table', # detail table if needed
136
+ 'quote', # breath
137
+ 'closing',
138
+ ],
139
+ 'research': [
140
+ 'cover', 'agenda', 'metrics', 'exhibit', 'twocol',
141
+ 'halftable', 'exhibit', 'threecol', 'table', 'flags', 'closing',
142
+ ],
143
+ 'architecture': [
144
+ 'cover', 'diagram', 'lane', 'diagram', 'points', 'points', 'closing',
145
+ ],
146
+ }
147
+
148
+
149
+ def _norm_mode(m: str) -> str:
150
+ return MODE_ALIAS.get((m or 'b').lower().strip(), 'research')
151
+
152
+
153
+ def _skel(page_type: str, v: str | None = None, mode: str | None = None,
154
+ allow_secondary: bool = False) -> str:
155
+ if page_type in ('cover', 'closing'):
156
+ return 'P1'
157
+ if page_type in ('agenda',):
158
+ return 'P2'
159
+ if v and v in V_MAP:
160
+ return V_MAP[v]
161
+ sk = PAGE_TO_PRESET.get(page_type, 'P4')
162
+ # Mode A: remap secondary skels to primary unless intent explicitly allows
163
+ if mode == 'presentation' and not allow_secondary and sk in set(SECONDARY_SKELS.get('presentation') or []):
164
+ remap = {
165
+ 'P7': 'P4', 'P8': 'P1', 'P9': 'P10', 'P11': 'P6', 'P12': 'P6',
166
+ }
167
+ sk = remap.get(sk, 'P4')
168
+ return sk
169
+
170
+
171
+ def _chart_ok(chart: str | None, n_cats: int, n_pts: int) -> str | None:
172
+ """Apply sizeByComplexity forbid list; return possibly replaced chart."""
173
+ if not chart:
174
+ return None
175
+ for band in SIZE_BANDS:
176
+ if n_cats <= int(band.get('maxCats') or 99) and n_pts <= int(band.get('maxPts') or 99):
177
+ forbid = set(band.get('forbid') or [])
178
+ if chart in forbid:
179
+ prefer = list(band.get('prefer') or ['kpi'])
180
+ return None if prefer[0] in ('kpi', 'progress', 'vsbar') else prefer[0]
181
+ return chart
182
+ return chart if chart in DEFAULT_CHARTS else DEFAULT_CHARTS[0]
183
+
184
+
185
+ def _complexity(sec: dict) -> tuple[int, int]:
186
+ ch = sec.get('chart') if isinstance(sec.get('chart'), dict) else {}
187
+ vals = ch.get('values') or ch.get('data') or []
188
+ labels = ch.get('labels') or []
189
+ pts = sec.get('points') or sec.get('items') or []
190
+ n_pts = len(vals) if vals else len(pts)
191
+ n_cats = len(labels) if labels else (len(vals) if vals else (1 if pts else 0))
192
+ return max(n_cats, 0), max(n_pts, 0)
193
+
194
+
195
+ def _skew(sec: dict) -> bool:
196
+ ch = sec.get('chart') if isinstance(sec.get('chart'), dict) else {}
197
+ vals = [float(v) for v in (ch.get('values') or []) if isinstance(v, (int, float))]
198
+ vals = [v for v in vals if v >= 0]
199
+ if len(vals) < 2:
200
+ return False
201
+ total = sum(vals) or 1.0
202
+ pcts = [v / total * 100 for v in vals]
203
+ mn, mx = min(pcts), max(pcts)
204
+ return mn < 5.0 or (mn > 0 and mx / mn > 20)
205
+
206
+
207
+ def _match_intent(text: str) -> dict | None:
208
+ for keys, hint in INTENT_RULES:
209
+ if any(k in text for k in keys):
210
+ return dict(hint)
211
+ return None
212
+
213
+
214
+ def recommend(
215
+ mode: str,
216
+ intents: list[str] | None = None,
217
+ pages: int | None = None,
218
+ model: dict | None = None,
219
+ ) -> list[dict]:
220
+ mode = _norm_mode(mode)
221
+ intents = intents or []
222
+ blob = ' '.join(intents)
223
+
224
+ if model and isinstance(model.get('sections'), list) and model['sections']:
225
+ out = []
226
+ for i, sec in enumerate(model['sections'], 1):
227
+ if not isinstance(sec, dict):
228
+ continue
229
+ pt = str(sec.get('type') or 'points')
230
+ n_cats, n_pts = _complexity(sec)
231
+ ch = None
232
+ if isinstance(sec.get('chart'), dict):
233
+ ch = sec['chart'].get('type')
234
+ elif pt in DEFAULT_CHARTS:
235
+ ch = pt if pt != 'bar' else 'bar'
236
+ rationale = 'from-model'
237
+ v = None
238
+ if _skew(sec) and (ch in ('donut', 'pie', 'multidonut') or pt == 'donut'):
239
+ pt, ch, v = 'kpi', None, 'V3'
240
+ rationale = 'model 极偏占比 → V3 KPI(禁 donut)'
241
+ else:
242
+ ch = _chart_ok(ch, n_cats or 3, n_pts or 3)
243
+ if mode == 'presentation':
244
+ if n_cats <= 2 and n_pts <= 3:
245
+ v = 'V3'
246
+ rationale = '演示·简单数据 → V3 大数,禁全幅简单图'
247
+ elif n_pts >= 8:
248
+ v = 'V2'
249
+ rationale = '演示·复杂序列 → V2 上图下带'
250
+ else:
251
+ v = 'V1'
252
+ rationale = '演示·默认 V1 主视觉+右注解'
253
+ if blob:
254
+ hit = _match_intent(blob)
255
+ if hit and i == min(3, len(model['sections'])):
256
+ pt = hit.get('pageType', pt)
257
+ ch = hit.get('chart', ch)
258
+ v = hit.get('v', v)
259
+ rationale = hit.get('rationale', rationale)
260
+ allow_sec = bool(ch and ch in ADVANCED_CHARTS) or pt in ('diagram', 'lane', 'matrix', 'heatmap')
261
+ skel = sec.get('layoutPreset') or _skel(pt, v, mode, allow_secondary=allow_sec)
262
+ row = {'page': i, 'pageType': pt, 'skel': skel, 'chart': ch,
263
+ 'rationale': rationale}
264
+ if v:
265
+ row['v'] = v
266
+ out.append(row)
267
+ return out
268
+
269
+ intent_hit = _match_intent(blob) if blob else None
270
+ high_vol = bool(intent_hit and intent_hit.get('sequence') == 'high_volume')
271
+ # 页数偏多或意图含高容量 → 多页序列(议程→主张→证据卡→明细),禁一页塞爆
272
+ if high_vol or (pages and pages >= 10) or any(
273
+ k in blob for k in ('长文', '密集', '材料多', '证据多', '详细', '白皮书')):
274
+ seq = list(HIGH_VOLUME_SEQ.get(mode, HIGH_VOLUME_SEQ['presentation']))
275
+ high_vol = True
276
+ else:
277
+ seq = list(DEFAULT_SEQ.get(mode, DEFAULT_SEQ['research']))
278
+ if pages and pages > 0:
279
+ mid = seq[1:-1] if len(seq) > 2 else seq
280
+ if pages <= 2:
281
+ seq = seq[:1] + seq[-1:]
282
+ else:
283
+ need = pages - 2
284
+ body = (mid * ((need // max(len(mid), 1)) + 1))[:need]
285
+ seq = [seq[0]] + body + [seq[-1]]
286
+
287
+ out = []
288
+ for i, pt in enumerate(seq, 1):
289
+ # 默认只从核图 8 轮换;advanced 不进默认节奏
290
+ ch = pt if pt in DEFAULT_CHARTS else (DEFAULT_CHARTS[(i - 1) % len(DEFAULT_CHARTS)]
291
+ if pt in ('bar', 'exhibit', 'donut') else None)
292
+ if ch in ADVANCED_CHARTS:
293
+ ch = DEFAULT_CHARTS[(i - 1) % len(DEFAULT_CHARTS)]
294
+ if pt == 'bar':
295
+ ch = 'bar'
296
+ if pt == 'donut':
297
+ ch = 'donut'
298
+ if pt == 'exhibit':
299
+ ch = 'bar'
300
+ if pt == 'comparison':
301
+ ch = 'hbar' # 对比默认 hbar,避免 donut 占默认序列
302
+ # Mode A:内容页默认 V1;kpi→V3;复杂序列由 intent/from-model 升 V2
303
+ if mode == 'presentation' and pt not in ('cover', 'agenda', 'closing', 'quote'):
304
+ v = 'V3' if pt == 'kpi' else 'V1'
305
+ else:
306
+ v = None
307
+ rationale = f'default {mode} rhythm'
308
+ allow_sec = bool(ch and ch in ADVANCED_CHARTS) or pt in ('diagram', 'lane', 'matrix', 'heatmap')
309
+ skel = _skel(pt, v, mode, allow_secondary=allow_sec)
310
+ row = {'page': i, 'pageType': pt, 'skel': skel, 'chart': ch, 'rationale': rationale}
311
+ if v:
312
+ row['v'] = v
313
+ out.append(row)
314
+
315
+ if intent_hit and out and intent_hit.get('sequence') != 'high_volume':
316
+ idx = 2 if len(out) > 2 else 0
317
+ if out[idx]['pageType'] in ('cover', 'agenda'):
318
+ idx = min(idx + 1, len(out) - 1)
319
+ out[idx]['pageType'] = intent_hit.get('pageType', out[idx]['pageType'])
320
+ out[idx]['chart'] = intent_hit.get('chart')
321
+ if intent_hit.get('v'):
322
+ out[idx]['v'] = intent_hit['v']
323
+ ch_i = out[idx].get('chart')
324
+ allow_sec = bool(ch_i and ch_i in ADVANCED_CHARTS) or out[idx]['pageType'] in ('diagram', 'lane', 'matrix', 'heatmap')
325
+ out[idx]['skel'] = _skel(out[idx]['pageType'], out[idx].get('v'), mode, allow_secondary=allow_sec)
326
+ out[idx]['rationale'] = intent_hit.get('rationale', '')
327
+ if high_vol:
328
+ for r in out:
329
+ if r['pageType'] in ('cover', 'closing', 'agenda'):
330
+ continue
331
+ r['rationale'] = (r.get('rationale') or '') + (
332
+ ' · 高容量多页分解(禁一页塞爆/截断)' if '高容量' not in (r.get('rationale') or '') else '')
333
+ return out
334
+
335
+
336
+ def main() -> int:
337
+ ap = argparse.ArgumentParser(description='Recommend pageType/skel/chart sequence')
338
+ ap.add_argument('--mode', default=None, help='A|B|C or presentation|research|architecture')
339
+ ap.add_argument('--intent', action='append', default=[], help='repeatable intent phrases')
340
+ ap.add_argument('--pages', type=int, default=None)
341
+ ap.add_argument('--from-model', default=None, help='path to REPORT_MODEL JSON')
342
+ ap.add_argument('--stdin', action='store_true', help='read JSON {mode,intents,pages,model} from stdin')
343
+ ap.add_argument('--json', action='store_true', help='machine-readable (default when --stdin/--from-model)')
344
+ args = ap.parse_args()
345
+
346
+ model = None
347
+ intents = list(args.intent or [])
348
+ mode = args.mode
349
+ pages = args.pages
350
+
351
+ if args.stdin:
352
+ raw = sys.stdin.read()
353
+ data = json.loads(raw) if raw.strip() else {}
354
+ mode = mode or data.get('mode')
355
+ intents = intents or list(data.get('intents') or [])
356
+ pages = pages if pages is not None else data.get('pages')
357
+ model = data.get('model') or data if 'sections' in data else None
358
+
359
+ if args.from_model:
360
+ model = json.loads(Path(args.from_model).read_text(encoding='utf-8'))
361
+ mode = mode or model.get('mode')
362
+
363
+ if not mode:
364
+ ap.error('--mode is required (or provide via --from-model/--stdin)')
365
+
366
+ rows = recommend(mode, intents, pages, model)
367
+ as_json = args.json or args.stdin or bool(args.from_model)
368
+ payload = {'mode': _norm_mode(mode), 'pages': rows}
369
+ if as_json:
370
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
371
+ else:
372
+ print(f"# recommend_layout · mode={payload['mode']}"
373
+ + (f" · intents={intents!r}" if intents else '')
374
+ + (f" · pages={pages}" if pages else ''))
375
+ print(f"{'#':>3} {'pageType':<14} {'skel':<6} {'chart':<12} {'v':<4} rationale")
376
+ for r in rows:
377
+ print(f"{r['page']:>3} {r['pageType']:<14} {r['skel']:<6} "
378
+ f"{str(r.get('chart') or '—'):<12} {str(r.get('v') or '—'):<4} "
379
+ f"{r.get('rationale') or ''}")
380
+ return 0
381
+
382
+
383
+ if __name__ == '__main__':
384
+ sys.exit(main())
@@ -240,7 +240,7 @@ def main() -> int:
240
240
  fails.append('dark-theme')
241
241
 
242
242
  # ⑥ 双通道交叉裁判(python-pptx):放在探针之后,A/B 产物齐全时才逐页比对
243
- r4 = run([PY, str(ROOT / 'scripts' / 'cross_verify.py')])
243
+ r4 = run([PY, str(ROOT / 'scripts' / 'cross_verify.py')] + (['--full-ab'] if '--full-ab' in sys.argv else []))
244
244
  if r4.returncode == 2:
245
245
  print(' [cross_verify] SKIP(python-pptx 未安装,可选第三方裁判)')
246
246
  skipped.append('cross-verify')
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # TopPPT HTML · PPTX 轻量冒烟(Batch 2)
3
+ # extract_model → build_pptx → validate_pptx --strict --model=
4
+ # 用法: bash scripts/smoke_pptx.sh [example.html]
5
+ set -euo pipefail
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ cd "$ROOT"
8
+ HTML="${1:-assets/examples/2026-09-09-presentation-business-blue.html}"
9
+ if [[ ! -f "$HTML" ]]; then
10
+ echo "smoke_pptx: missing $HTML" >&2
11
+ exit 2
12
+ fi
13
+ STEM="$(basename "$HTML" .html)"
14
+ OUT_DIR="${TMPDIR:-/tmp}/top-ppt-html-smoke"
15
+ mkdir -p "$OUT_DIR"
16
+ MODEL="$OUT_DIR/${STEM}.model.json"
17
+ PPTX="$OUT_DIR/${STEM}.pptx"
18
+
19
+ echo "== smoke_pptx =="
20
+ echo "HTML $HTML"
21
+ python3 scripts/extract_model.py "$HTML" "$MODEL"
22
+ echo "MODEL $MODEL ($(wc -c < "$MODEL") bytes)"
23
+
24
+ # Prefer local node_modules pptxgenjs
25
+ export NODE_PATH="${ROOT}/node_modules${NODE_PATH:+:$NODE_PATH}"
26
+ node scripts/build_pptx.js "$PPTX" --model="$MODEL"
27
+ echo "PPTX $PPTX ($(wc -c < "$PPTX") bytes)"
28
+
29
+ python3 scripts/validate_pptx.py "$PPTX" --strict --model="$MODEL"
30
+ echo "smoke_pptx: OK"
@@ -115,13 +115,15 @@ def build_const_block(lc: dict) -> str:
115
115
  img_js = 'var IMG = ' + json.dumps(_strip_comments(lc.get('imageSpec') or {}), ensure_ascii=False,
116
116
  separators=(',', ':')) + ';'
117
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 = {}
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 = {}
125
127
  slots_js = 'var LAYOUT_SLOTS = ' + json.dumps(_strip_comments(slots), ensure_ascii=False,
126
128
  separators=(',', ':')) + ';'
127
129
  region_fn = r'''
@@ -528,28 +530,27 @@ def main() -> int:
528
530
  print(f'警告: {eng_name} 未见图片版式 {eng_missing_lay}(imageSpec 已登记但引擎未提及)')
529
531
  ok = False
530
532
 
531
- # ⑩ 页型布局 IR(layout_slots.json):高频页型槽位完整性
532
- slots_path = ROOT / 'scripts' / 'layout_slots.json'
533
+ # ⑩ 页型布局 IR(LC.layoutSlots):高频页型槽位完整性
533
534
  n_slots = 0
534
- if not slots_path.exists():
535
- print('警告: 缺 scripts/layout_slots.json(页型布局 IR 单源)')
535
+ slots_doc = lc.get('layoutSlots') or {}
536
+ if not slots_doc.get('pageTypes'):
537
+ print('警告: layout-constants.json 缺 layoutSlots.pageTypes(页型布局 IR 单源)')
536
538
  ok = False
537
539
  else:
538
540
  try:
539
- slots_doc = json.loads(slots_path.read_text(encoding='utf-8'))
540
541
  slots_pages = slots_doc.get('pageTypes') or {}
541
542
  for pt_name, pt_def in slots_pages.items():
542
543
  if pt_name not in schema_types:
543
- print(f'警告: layout_slots 页型 {pt_name!r} 不在 schema.pageTypes')
544
+ print(f'警告: layoutSlots 页型 {pt_name!r} 不在 schema.pageTypes')
544
545
  ok = False
545
546
  slot_list = pt_def.get('slots') or []
546
547
  if not slot_list:
547
- print(f'警告: layout_slots 页型 {pt_name!r} 无槽位定义')
548
+ print(f'警告: layoutSlots 页型 {pt_name!r} 无槽位定义')
548
549
  ok = False
549
550
  continue
550
551
  ids = [s.get('id') for s in slot_list]
551
552
  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
+ print(f'警告: layoutSlots 页型 {pt_name!r} 缺 head 或 primary 槽位')
553
554
  ok = False
554
555
  n_slots = len(slots_pages)
555
556
  # 布局 IR 模块存在性(B 通道 require)
@@ -559,7 +560,7 @@ def main() -> int:
559
560
  ok = False
560
561
  else:
561
562
  ir_txt = ir_mod.read_text(encoding='utf-8')
562
- for key in ('regionOf', 'layout_slots.json', 'layout-constants.json'):
563
+ for key in ('regionOf', 'layoutSlots', 'layout-constants.json'):
563
564
  if key not in ir_txt:
564
565
  print(f'警告: lib_layout_regions.js 缺少 {key!r} 引用')
565
566
  ok = False
@@ -603,7 +604,7 @@ process.exit(bad ? 1 : 0);
603
604
  except Exception as exc:
604
605
  print(f'警告: 布局 IR 烟测未执行: {exc}')
605
606
  except json.JSONDecodeError as exc:
606
- print(f'警告: layout_slots.json 解析失败: {exc}')
607
+ print(f'警告: layoutSlots 解析失败: {exc}')
607
608
  ok = False
608
609
  n_slots = 0
609
610
 
@@ -618,14 +619,10 @@ process.exit(bad ? 1 : 0);
618
619
  if ms.get('version') != ver:
619
620
  print(f'警告: model-schema.json version={ms.get("version")!r} ≠ layout-constants.json {ver!r}')
620
621
  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 # ⑩ 已告警
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
629
626
  try:
630
627
  pj_ver = json.loads((ROOT / 'package.json').read_text(encoding='utf-8')).get('version')
631
628
  if not str(pj_ver or '').startswith(f'{ver}.'):
@@ -530,6 +530,35 @@ def title_anchor_info(shapes: list[ET.Element]) -> dict[str, Any] | None:
530
530
  }
531
531
 
532
532
 
533
+
534
+ def chrome_footer_y_in(shapes: list[ET.Element], height: int) -> float | None:
535
+ """页码 chrome y(英寸):只认页码形文本(如 3 / 14),避免把 so-what/图注当页脚。"""
536
+ zones = ((json.loads(Path(__file__).with_name("layout-constants.json").read_text(encoding="utf-8"))
537
+ .get("layoutSystem") or {}).get("zones") or {})
538
+ chrome = zones.get("chromePct") or [18, 24]
539
+ bottom_frac = float(chrome[1] if isinstance(chrome, list) and len(chrome) > 1 else 24) / 100.0
540
+ threshold = int(height * (1.0 - bottom_frac))
541
+ ys: list[float] = []
542
+ pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$|^\s*\d+\s*$")
543
+ for shape in shapes:
544
+ text = (text_content(shape) or "").strip()
545
+ box = shape_bounds(shape)
546
+ if not text or box is None:
547
+ continue
548
+ _x, y, _cx, _cy = box
549
+ if y < threshold:
550
+ continue
551
+ if not pager.match(text):
552
+ continue
553
+ sizes = font_sizes_pt(shape)
554
+ if sizes and max(sizes) > 14:
555
+ continue
556
+ ys.append(y / 914400.0)
557
+ if not ys:
558
+ return None
559
+ return round(sum(ys) / len(ys), 4)
560
+
561
+
533
562
  def anchor_check(shapes: list[ET.Element], slide_no: int, width: int) -> list[dict[str, Any]]:
534
563
  """空间锚点注册(深度模式):标题/页头块的左边距必须对齐版心左边界。
535
564
 
@@ -728,6 +757,7 @@ def inspect_slide(
728
757
  "font_sizes_pt": sorted({round(v, 2) for v in _all_run_sizes(root)}),
729
758
  "text_characters": len(combined_text),
730
759
  "anchor": title_anchor_info(shapes),
760
+ "chrome_footer_y_in": chrome_footer_y_in(shapes, height),
731
761
  }
732
762
  return metrics, warnings
733
763
 
@@ -1105,6 +1135,42 @@ def promote_strict_failures(report: dict[str, Any], deep: bool = False) -> None:
1105
1135
  existing.add(key)
1106
1136
 
1107
1137
 
1138
+
1139
+ def validate_chrome_drift(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
1140
+ """跨页 chrome(页脚/页码)y 一致性:相对中位数漂移超过 chromeDriftIn → WARN。
1141
+
1142
+ 封面/收尾页版式不同(常无页脚或 y 不同)豁免;仅比内容页。严格交付下任意 WARN 会挡
1143
+ smoke,故容差偏宽、只抓明显漂移。
1144
+ """
1145
+ out: list[dict[str, Any]] = []
1146
+ if len(slides) < 4:
1147
+ return out
1148
+ first_n = slides[0].get("slide")
1149
+ last_n = slides[-1].get("slide")
1150
+ ys = [(s.get("slide"), s.get("chrome_footer_y_in"))
1151
+ for s in slides
1152
+ if s.get("chrome_footer_y_in") is not None
1153
+ and s.get("slide") not in (first_n, last_n)]
1154
+ if len(ys) < 3:
1155
+ return out
1156
+ vals = sorted(y for _, y in ys)
1157
+ mid = vals[len(vals) // 2]
1158
+ try:
1159
+ lc = json.loads(Path(__file__).with_name("layout-constants.json").read_text(encoding="utf-8"))
1160
+ max_delta = float(((lc.get("qualityGates") or {}).get("chromeDriftIn") or {}).get("maxAbsDeltaIn") or 0.2)
1161
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
1162
+ max_delta = 0.2
1163
+ drifted = [(n, y) for n, y in ys if abs(float(y) - mid) > max_delta]
1164
+ for n, y in drifted[:4]:
1165
+ out.append(issue(
1166
+ "CHROME_DRIFT",
1167
+ f"第{n}页页脚/页码 y={y:.3f}in 相对中位 {mid:.3f}in 漂移 {abs(y-mid):+.3f}in "
1168
+ f"(容差 {max_delta}in);跨页 chrome 应锁死几何。",
1169
+ slide=n,
1170
+ ))
1171
+ return out
1172
+
1173
+
1108
1174
  def validate_pptx(
1109
1175
  path: str | Path,
1110
1176
  model_path: str | Path | None = None,
@@ -1209,6 +1275,9 @@ def validate_pptx(
1209
1275
  for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "charts", "tables"):
1210
1276
  report["summary"][field] = sum(slide[field] for slide in report["slides"])
1211
1277
 
1278
+ # P1-6 chrome 一致性:页脚/页码 y 相对中位数漂移
1279
+ report["warnings"].extend(validate_chrome_drift(report["slides"]))
1280
+
1212
1281
  # 图片门禁:默认零图片(pictures=0 全原生可编辑);
1213
1282
  # 仅当模型显式声明 section.image / split.right.image 时才允许对应数量的图片落地。
1214
1283
  # 配图占位(image.placeholder)走原生形状,不计入图片数;版式/裁切/多图数量另做硬门禁。