@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.
- package/CHANGELOG.md +61 -0
- package/package.json +1 -1
- package/top-ppt-html/README.md +38 -130
- package/top-ppt-html/SKILL.md +34 -19
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-research.png +0 -0
- package/top-ppt-html/assets/theme-overview.png +0 -0
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/references/charts-discipline.md +3 -1
- package/top-ppt-html/references/charts.md +24 -15
- package/top-ppt-html/references/content-rules.md +54 -34
- package/top-ppt-html/references/default-surface.md +61 -0
- package/top-ppt-html/references/design-system.md +2 -2
- package/top-ppt-html/references/failure-modes.md +14 -1
- package/top-ppt-html/references/icons.md +56 -277
- package/top-ppt-html/references/layout-grammar.md +16 -6
- package/top-ppt-html/references/modes.md +16 -11
- package/top-ppt-html/references/playbook.md +31 -13
- package/top-ppt-html/references/pptx-export.md +20 -2
- package/top-ppt-html/references/presentation-craft.md +69 -0
- package/top-ppt-html/references/tech-design.md +5 -5
- package/top-ppt-html/scripts/audit_skill.py +13 -2
- package/top-ppt-html/scripts/build_examples.py +39 -2260
- package/top-ppt-html/scripts/build_pptx.js +6 -0
- package/top-ppt-html/scripts/checks_html.py +39 -0
- package/top-ppt-html/scripts/cross_verify.py +5 -1
- package/top-ppt-html/scripts/extract_snippet.py +2 -2
- package/top-ppt-html/scripts/layout-constants.json +111 -18
- package/top-ppt-html/scripts/lib_layout_regions.js +7 -9
- package/top-ppt-html/scripts/negative_tests.py +127 -1
- package/top-ppt-html/scripts/package_skill.py +13 -10
- package/top-ppt-html/scripts/quality_gate.py +11 -3
- package/top-ppt-html/scripts/recommend_layout.py +384 -0
- package/top-ppt-html/scripts/regression.py +1 -1
- package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
- package/top-ppt-html/scripts/sync_runtime.py +22 -25
- package/top-ppt-html/scripts/validate_pptx.py +69 -0
- package/top-ppt-html/scripts/validate_report.py +309 -10
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
- package/top-ppt-html/references/design-system-engine.md +0 -235
- package/top-ppt-html/references/industry-benchmark.md +0 -105
- package/top-ppt-html/references/reform-plan.md +0 -252
- 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
|
-
|
|
119
|
-
slots = {}
|
|
120
|
-
if
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
532
|
-
slots_path = ROOT / 'scripts' / 'layout_slots.json'
|
|
533
|
+
# ⑩ 页型布局 IR(LC.layoutSlots):高频页型槽位完整性
|
|
533
534
|
n_slots = 0
|
|
534
|
-
|
|
535
|
-
|
|
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'警告:
|
|
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'警告:
|
|
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'警告:
|
|
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', '
|
|
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'警告:
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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)走原生形状,不计入图片数;版式/裁切/多图数量另做硬门禁。
|