@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.
- package/CHANGELOG.md +139 -134
- package/bin/tms-skills.js +160 -160
- package/package.json +48 -48
- package/top-ppt-html/README.md +254 -254
- package/top-ppt-html/SKILL.md +111 -111
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3873 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3873 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
- package/top-ppt-html/assets/pptx-export.js +1944 -1944
- package/top-ppt-html/assets/style-gallery.html +559 -589
- package/top-ppt-html/assets/templates/architecture.html +3675 -3728
- package/top-ppt-html/assets/templates/engine.css +787 -840
- package/top-ppt-html/assets/templates/presentation.html +3685 -3738
- package/top-ppt-html/assets/templates/research.html +3964 -4017
- package/top-ppt-html/assets/templates/ui.js +520 -520
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-presentation.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/evals/prompts.csv +15 -15
- package/top-ppt-html/evals/rubric.schema.json +25 -25
- package/top-ppt-html/evals/run_evals.py +220 -220
- package/top-ppt-html/evals/trace.example.json +16 -16
- package/top-ppt-html/package-lock.json +175 -0
- package/top-ppt-html/package.json +30 -35
- package/top-ppt-html/references/charts-basic.md +624 -624
- package/top-ppt-html/references/charts-discipline.md +108 -108
- package/top-ppt-html/references/charts-extended.md +482 -482
- package/top-ppt-html/references/charts.md +28 -28
- package/top-ppt-html/references/components-atoms.md +624 -624
- package/top-ppt-html/references/components.md +30 -30
- package/top-ppt-html/references/content-rules.md +490 -490
- package/top-ppt-html/references/design-system-engine.md +235 -235
- package/top-ppt-html/references/design-system.md +471 -478
- package/top-ppt-html/references/failure-modes.md +214 -214
- package/top-ppt-html/references/high-fidelity.md +127 -127
- package/top-ppt-html/references/icons.md +397 -397
- package/top-ppt-html/references/industry-benchmark.md +105 -105
- package/top-ppt-html/references/infographics-stats.md +308 -308
- package/top-ppt-html/references/infographics-structure.md +226 -226
- package/top-ppt-html/references/infographics.md +43 -43
- package/top-ppt-html/references/layout-grammar.md +315 -315
- package/top-ppt-html/references/layouts-architecture.md +108 -108
- package/top-ppt-html/references/layouts-combo.md +600 -600
- package/top-ppt-html/references/layouts-research.md +160 -160
- package/top-ppt-html/references/modes.md +254 -254
- package/top-ppt-html/references/outline-design.md +275 -275
- package/top-ppt-html/references/playbook.md +266 -266
- package/top-ppt-html/references/pptx-export.md +209 -209
- package/top-ppt-html/references/reform-plan.md +252 -252
- package/top-ppt-html/references/styles.md +336 -370
- package/top-ppt-html/references/tech-design.md +138 -138
- package/top-ppt-html/scripts/audit_css.py +109 -109
- package/top-ppt-html/scripts/audit_docs.py +176 -176
- package/top-ppt-html/scripts/audit_skill.py +220 -220
- package/top-ppt-html/scripts/audit_styles.py +293 -351
- package/top-ppt-html/scripts/build_examples.py +2276 -2276
- package/top-ppt-html/scripts/build_pptx.js +2380 -2380
- package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
- package/top-ppt-html/scripts/checks_html.py +127 -127
- package/top-ppt-html/scripts/cross_verify.py +294 -294
- package/top-ppt-html/scripts/env_probe.py +158 -158
- package/top-ppt-html/scripts/extract_model.py +210 -210
- package/top-ppt-html/scripts/extract_snippet.py +374 -374
- package/top-ppt-html/scripts/gen_channel_a.js +214 -214
- package/top-ppt-html/scripts/layout-constants.json +3309 -3377
- package/top-ppt-html/scripts/layout_slots.json +830 -830
- package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
- package/top-ppt-html/scripts/measure_height.py +178 -178
- package/top-ppt-html/scripts/model-schema.json +547 -547
- package/top-ppt-html/scripts/negative_tests.py +307 -307
- package/top-ppt-html/scripts/package_skill.py +291 -291
- package/top-ppt-html/scripts/prepare_images.py +341 -341
- package/top-ppt-html/scripts/probe_image_export.py +188 -188
- package/top-ppt-html/scripts/quality_gate.py +301 -301
- package/top-ppt-html/scripts/regression.py +307 -308
- package/top-ppt-html/scripts/render_compare.py +275 -275
- package/top-ppt-html/scripts/render_from_model.py +698 -698
- package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
- package/top-ppt-html/scripts/section-file-map.json +104 -104
- package/top-ppt-html/scripts/sync_runtime.py +662 -662
- package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
- package/top-ppt-html/scripts/validate_report.py +1456 -1456
|
@@ -1,294 +1,294 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""TopPPT HTML · 双通道交叉验证(可选依赖 python-pptx):
|
|
4
|
-
① A 通道(手写 OOXML)与 B 通道(pptxgenjs)产物均可被 python-pptx 严格解析
|
|
5
|
-
② 两通道逐页文本集合一致(同一模型 → 同一内容;段落换行表示差异已归一)
|
|
6
|
-
归一化:B 通道带数据的图表为原生 chart part(可编辑数据)——图表类别标签并入
|
|
7
|
-
文本集合、纯数值 token(含百分数)两端过滤(A 通道形状版的数值标签 vs B 通道 chart 数据值)
|
|
8
|
-
③ 文本溢出启发式:文本估算宽 vs shape 宽 × 可容行数
|
|
9
|
-
④ B 通道原生图表数值 ↔ 模型数值(类别标签一致不代表数值一致;数值错乱只能这里拓)
|
|
10
|
-
⑤ 字号比例尺同源:B 的字号必须是 A 字号集的子集(防 modeSize()/sz() 后处理漂移)
|
|
11
|
-
用法:先 node scripts/gen_channel_a.js 生成 A 通道产物,再 python scripts/cross_verify.py
|
|
12
|
-
改 pptx-export.js 序列化骨架后必跑(见 references/pptx-export.md 双裁判教训)。
|
|
13
|
-
"""
|
|
14
|
-
import json
|
|
15
|
-
import re
|
|
16
|
-
import sys
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
20
|
-
# 依赖缺失不在导入期退出——本模块的核对函数会被 negative_tests.py 直接复用
|
|
21
|
-
try:
|
|
22
|
-
from pptx import Presentation
|
|
23
|
-
except ImportError:
|
|
24
|
-
Presentation = None
|
|
25
|
-
|
|
26
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
27
|
-
A_DIR = ROOT / 'dist' / 'regression-a' # A 通道(页面直导引擎)
|
|
28
|
-
B_DIR = ROOT / 'dist' / 'regression' # B 通道(pptxgenjs)
|
|
29
|
-
MODEL_DIR = ROOT / 'assets' / 'examples'
|
|
30
|
-
|
|
31
|
-
# 文本度量单源(与 build_pptx.js / validate_pptx.py 同值)——三侧各自硬编码会让
|
|
32
|
-
# 溢出判定在不同裁判间飘移,出现“一侧报溢出、一侧说没事”。
|
|
33
|
-
try:
|
|
34
|
-
_TM = (json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
|
|
35
|
-
.get('containers', {}).get('textMetrics', {}))
|
|
36
|
-
except (OSError, json.JSONDecodeError):
|
|
37
|
-
_TM = {}
|
|
38
|
-
LINE_FACTOR = float(_TM.get('lineFactor') or 1.45)
|
|
39
|
-
EM_ASCII_RATIO = float(_TM.get('emAsciiRatio') or 0.52)
|
|
40
|
-
|
|
41
|
-
# 形态变换类图表:模型里的负值在图上是「向下的正高度条」,按绝对值比对
|
|
42
|
-
SIGNLESS_CHARTS = {'waterfall', 'gauge', 'funnel', 'bullet'}
|
|
43
|
-
|
|
44
|
-
# 纯数值 token(含千分位/小数/百分号/常见单位后缀)——图表数据标签在 A 通道是
|
|
45
|
-
# 文本(值+unit,如 "9pt"),B 通道进 chart part;比对前两端过滤。类别标签(如 "2026Q3")
|
|
46
|
-
# 不匹配此模式,仍参与一致性检查。
|
|
47
|
-
NUMERIC_TOKEN = re.compile(
|
|
48
|
-
r'^[+-]?[\d.,]+\s*(?:%|‰|pt|pct|pp|TB|GB|MB|KB|k|w|万|亿|元|倍|个|人|天|家|次|项|年'
|
|
49
|
-
r'|条|笔|台|套|份|款|张|篇|页|件|例|场|轮|艘|辆|吨|米|秒|分|时|档|级)?$')
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def slide_texts(prs):
|
|
53
|
-
"""每页文本集合(逐行拆分——A 通道段落 \\n 与 B 通道 breakLine \\n\\n 表示差异归一;
|
|
54
|
-
忽略空白、页码与纯数值 token;B 通道原生图表的类别标签并入集合)。"""
|
|
55
|
-
pages = []
|
|
56
|
-
for slide in prs.slides:
|
|
57
|
-
texts = []
|
|
58
|
-
for shape in slide.shapes:
|
|
59
|
-
if shape.has_text_frame:
|
|
60
|
-
for line in shape.text_frame.text.splitlines():
|
|
61
|
-
t = line.strip()
|
|
62
|
-
if t and not NUMERIC_TOKEN.match(t):
|
|
63
|
-
texts.append(t)
|
|
64
|
-
elif shape.has_table:
|
|
65
|
-
for row in shape.table.rows:
|
|
66
|
-
for cell in row.cells:
|
|
67
|
-
for line in cell.text.splitlines():
|
|
68
|
-
t = line.strip()
|
|
69
|
-
if t and not NUMERIC_TOKEN.match(t):
|
|
70
|
-
texts.append(t)
|
|
71
|
-
elif getattr(shape, 'has_chart', False):
|
|
72
|
-
# B 通道原生数据图表——类别标签(实质内容)并入;数据值已按纯数值过滤。
|
|
73
|
-
# 类别标签同样过 NUMERIC_TOKEN——A 通道文本统一过滤,两端语义对称
|
|
74
|
-
# (否则 gauge 的 '76%'、stack 的年份 '2023' 这类数值型类别会造成单边误报)。
|
|
75
|
-
try:
|
|
76
|
-
cats = list(shape.chart.plots[0].categories)
|
|
77
|
-
texts.extend(str(c).strip() for c in cats
|
|
78
|
-
if str(c).strip() and not NUMERIC_TOKEN.match(str(c).strip()))
|
|
79
|
-
except Exception:
|
|
80
|
-
pass
|
|
81
|
-
pages.append(sorted(set(texts))) # 集合语义去重(B 端 chart categories 与自绘图例可能重复)
|
|
82
|
-
return pages
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _model_chart(sec):
|
|
86
|
-
"""取章节页的图表对象(口径与 validate_pptx._section_chart 一致)。"""
|
|
87
|
-
st = sec.get('type') or ''
|
|
88
|
-
if st in {'bar', 'donut', 'exhibit', 'halftable'}:
|
|
89
|
-
c = sec.get('chart') or {}
|
|
90
|
-
elif st == 'split':
|
|
91
|
-
c = sec.get('right') or {}
|
|
92
|
-
if (c.get('type') or 'bar') == 'table':
|
|
93
|
-
return None
|
|
94
|
-
else:
|
|
95
|
-
return None
|
|
96
|
-
return c if isinstance(c, dict) and c.get('labels') and c.get('values') else None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _model_values(chart):
|
|
100
|
-
"""模型声明的全部数值(单系列 values 或多系列 series[].values)。"""
|
|
101
|
-
out = []
|
|
102
|
-
if isinstance(chart.get('series'), list) and chart['series']:
|
|
103
|
-
for se in chart['series']:
|
|
104
|
-
out += [v for v in (se.get('values') or []) if isinstance(v, (int, float))]
|
|
105
|
-
else:
|
|
106
|
-
out += [v for v in (chart.get('values') or []) if isinstance(v, (int, float))]
|
|
107
|
-
return out
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def chart_data_verify(prs, model, name):
|
|
111
|
-
"""B 通道原生 chart part 的数值必须覆盖模型声明值。
|
|
112
|
-
|
|
113
|
-
只做「模型值 ⊆ 图表值」的包含判定,不做严格相等——waterfall 的累计基座、
|
|
114
|
-
gauge 的补角这类派生系列是合法的额外数据,严格相等会制造假阳性。
|
|
115
|
-
形态变换类图表(waterfall 把 -3 画成高度 3 的悬浮条)按绝对值比对。
|
|
116
|
-
"""
|
|
117
|
-
issues = []
|
|
118
|
-
secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
|
|
119
|
-
first = 3 if model.get('agenda') else 2
|
|
120
|
-
slides = list(prs.slides)
|
|
121
|
-
for i, sec in enumerate(secs):
|
|
122
|
-
chart = _model_chart(sec)
|
|
123
|
-
if chart is None:
|
|
124
|
-
continue
|
|
125
|
-
idx = first + i - 1
|
|
126
|
-
if idx >= len(slides):
|
|
127
|
-
break
|
|
128
|
-
pool = []
|
|
129
|
-
for shape in slides[idx].shapes:
|
|
130
|
-
if not getattr(shape, 'has_chart', False):
|
|
131
|
-
continue
|
|
132
|
-
try:
|
|
133
|
-
for plot in shape.chart.plots:
|
|
134
|
-
for se in plot.series:
|
|
135
|
-
pool += [v for v in se.values if v is not None]
|
|
136
|
-
except Exception:
|
|
137
|
-
pass
|
|
138
|
-
if not pool:
|
|
139
|
-
continue # 该页是形状还原通道,数值走数据表/备注,另有门禁
|
|
140
|
-
ctype = str(chart.get('type') or 'bar').lower()
|
|
141
|
-
signless = ctype in SIGNLESS_CHARTS
|
|
142
|
-
if signless:
|
|
143
|
-
pool = [abs(p) for p in pool]
|
|
144
|
-
missing = []
|
|
145
|
-
for v in _model_values(chart):
|
|
146
|
-
target = abs(v) if signless else v
|
|
147
|
-
if not any(abs(target - p) <= max(0.01, abs(target) * 0.001) for p in pool):
|
|
148
|
-
missing.append(v)
|
|
149
|
-
if missing:
|
|
150
|
-
issues.append(f'{name} 第{idx + 1}页 {ctype} 图表数值与模型不符,'
|
|
151
|
-
f'缺 {missing[:5]}(图表实际含 {sorted(set(pool))[:6]})')
|
|
152
|
-
return issues
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def font_sizes(prs):
|
|
156
|
-
"""全文文本框出现过的字号集合(pt)。"""
|
|
157
|
-
out = set()
|
|
158
|
-
for slide in prs.slides:
|
|
159
|
-
for sh in slide.shapes:
|
|
160
|
-
if not sh.has_text_frame:
|
|
161
|
-
continue
|
|
162
|
-
for p in sh.text_frame.paragraphs:
|
|
163
|
-
for r in p.runs:
|
|
164
|
-
if r.font.size and r.text.strip():
|
|
165
|
-
out.add(round(r.font.size.pt, 2))
|
|
166
|
-
return out
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def overflow_estimate(prs, name):
|
|
170
|
-
"""启发式:文本估算宽(CJK 字符按 1em、ASCII 按 emAsciiRatio)vs shape 宽 × 可容行数。"""
|
|
171
|
-
issues = []
|
|
172
|
-
for si, slide in enumerate(prs.slides, 1):
|
|
173
|
-
for shape in slide.shapes:
|
|
174
|
-
if not shape.has_text_frame or not shape.width or not shape.height:
|
|
175
|
-
continue
|
|
176
|
-
for para in shape.text_frame.paragraphs:
|
|
177
|
-
line = ''.join(r.text for r in para.runs)
|
|
178
|
-
if not line.strip():
|
|
179
|
-
continue
|
|
180
|
-
sz = None
|
|
181
|
-
for r in para.runs:
|
|
182
|
-
if r.font.size:
|
|
183
|
-
sz = r.font.size.pt
|
|
184
|
-
break
|
|
185
|
-
sz = sz or 12
|
|
186
|
-
emu_w = int(shape.width)
|
|
187
|
-
in_w = emu_w / 914400
|
|
188
|
-
# 每行可容字符宽(英寸)→ em 数
|
|
189
|
-
est_w = sum(1.0 if ord(c) > 0x2E80 else EM_ASCII_RATIO for c in line) * sz / 72
|
|
190
|
-
lines_avail = max(1, int(int(shape.height) / 914400 / (sz / 72 * LINE_FACTOR)))
|
|
191
|
-
if est_w > in_w * lines_avail * 1.18: # 18% 容差
|
|
192
|
-
issues.append(f'{name} 第{si}页 "{line[:14]}…" 估算{est_w:.1f}in > 容量{in_w*lines_avail:.1f}in')
|
|
193
|
-
return issues
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def main() -> int:
|
|
197
|
-
if Presentation is None:
|
|
198
|
-
print('python-pptx 未安装')
|
|
199
|
-
return 2
|
|
200
|
-
fails = []
|
|
201
|
-
a_files = sorted(A_DIR.glob('*.pptx'))
|
|
202
|
-
print(f'A 通道产物 {len(a_files)} 个 · B 通道产物 {len(sorted(B_DIR.glob("*.pptx")))} 个\n')
|
|
203
|
-
all_overflow = []
|
|
204
|
-
|
|
205
|
-
for ap in a_files:
|
|
206
|
-
bp = B_DIR / ap.name
|
|
207
|
-
name = ap.stem
|
|
208
|
-
# ① 可解析性
|
|
209
|
-
try:
|
|
210
|
-
prs_a = Presentation(str(ap))
|
|
211
|
-
except Exception as e:
|
|
212
|
-
fails.append(f'{name}: A 通道 python-pptx 解析失败 {e}')
|
|
213
|
-
continue
|
|
214
|
-
if not bp.exists():
|
|
215
|
-
fails.append(f'{name}: B 通道产物缺失')
|
|
216
|
-
continue
|
|
217
|
-
try:
|
|
218
|
-
prs_b = Presentation(str(bp))
|
|
219
|
-
except Exception as e:
|
|
220
|
-
fails.append(f'{name}: B 通道 python-pptx 解析失败 {e}')
|
|
221
|
-
continue
|
|
222
|
-
# ② 逐页文本一致性(v9:A/B 不必逐字全等——A 预览为形状近似;
|
|
223
|
-
# 要求「标题+主文本 ≥80% 交集覆盖」,杜绝截断/串页,又不因图表标签形态差误杀)
|
|
224
|
-
ta, tb = slide_texts(prs_a), slide_texts(prs_b)
|
|
225
|
-
if len(ta) != len(tb):
|
|
226
|
-
fails.append(f'{name}: 页数不一致 A={len(ta)} B={len(tb)}')
|
|
227
|
-
continue
|
|
228
|
-
min_cov = 0.80
|
|
229
|
-
try:
|
|
230
|
-
min_cov = float((json.loads((ROOT / 'scripts' / 'layout-constants.json')
|
|
231
|
-
.read_text(encoding='utf-8'))
|
|
232
|
-
.get('qualityGates') or {}).get('roundtripMin') or 0.8)
|
|
233
|
-
except Exception:
|
|
234
|
-
pass
|
|
235
|
-
diff_pages = []
|
|
236
|
-
for i, (x, y) in enumerate(zip(ta, tb), 1):
|
|
237
|
-
sx, sy = set(x), set(y)
|
|
238
|
-
if not sx and not sy:
|
|
239
|
-
continue
|
|
240
|
-
inter = sx & sy
|
|
241
|
-
cov = len(inter) / max(1, max(len(sx), len(sy)))
|
|
242
|
-
if cov < min_cov:
|
|
243
|
-
diff_pages.append((i, cov, sorted(sx - sy)[:3], sorted(sy - sx)[:3]))
|
|
244
|
-
if diff_pages:
|
|
245
|
-
for i, cov, only_a, only_b in diff_pages[:3]:
|
|
246
|
-
fails.append(f'{name}: 第{i}页文本覆盖 {cov:.0%} <{min_cov:.0%} '
|
|
247
|
-
f'A独有{only_a} B独有{only_b}')
|
|
248
|
-
# ③ 溢出启发式(对两通道都跑)
|
|
249
|
-
all_overflow += overflow_estimate(prs_a, name + '/A')
|
|
250
|
-
all_overflow += overflow_estimate(prs_b, name + '/B')
|
|
251
|
-
# ④ B 通道原生图表数值 ↔ 模型(A 通道是形状还原,不产 chart part)
|
|
252
|
-
chart_note = ''
|
|
253
|
-
mp = MODEL_DIR / f'{name}.model.json'
|
|
254
|
-
if mp.exists():
|
|
255
|
-
try:
|
|
256
|
-
model = json.loads(mp.read_text(encoding='utf-8'))
|
|
257
|
-
except json.JSONDecodeError as e:
|
|
258
|
-
fails.append(f'{name}: 模型不可解析 {e}')
|
|
259
|
-
model = None
|
|
260
|
-
if model:
|
|
261
|
-
data_issues = chart_data_verify(prs_b, model, name)
|
|
262
|
-
fails += data_issues
|
|
263
|
-
chart_note = ' 图表数值=' + ('PASS' if not data_issues else f'DIFF({len(data_issues)})')
|
|
264
|
-
status = 'PASS' if not diff_pages else f'LOWCOV p{[d[0] for d in diff_pages]}'
|
|
265
|
-
# ⑤ 字号比例尺同源:B 的字号必须都来自 A 同一张表。
|
|
266
|
-
# 反向不成立——A 把图表渲染成形状,标签字号会多出来(B 的在 chart part 内)。
|
|
267
|
-
fa, fb = font_sizes(prs_a), font_sizes(prs_b)
|
|
268
|
-
alien = sorted(fb - fa)
|
|
269
|
-
if alien:
|
|
270
|
-
fails.append(f'{name}: B 通道出现 A 没有的字号 {alien}'
|
|
271
|
-
f'(两通道应同走 modeTypeScale;modeSize() 与 sz() 的钳制/取整须一致)')
|
|
272
|
-
sz_note = ' 字号比例尺=' + ('PASS' if not alien else 'DRIFT')
|
|
273
|
-
print(f'{name}: A可解析✓ B可解析✓ 页数={len(ta)} 逐页文本={status}{chart_note}{sz_note}')
|
|
274
|
-
|
|
275
|
-
print('\n── 溢出启发式(check-overflow · 18% 容差)──')
|
|
276
|
-
if all_overflow:
|
|
277
|
-
for i in all_overflow[:20]:
|
|
278
|
-
print(' [WARN]', i)
|
|
279
|
-
else:
|
|
280
|
-
print(' 两通道全部通过(无文本超容 shape)')
|
|
281
|
-
|
|
282
|
-
print('\n' + '=' * 50)
|
|
283
|
-
if fails:
|
|
284
|
-
print('交叉验证失败:')
|
|
285
|
-
for f in fails:
|
|
286
|
-
print(' [FAIL]', f)
|
|
287
|
-
return 1
|
|
288
|
-
print('双通道交叉验证通过:python-pptx 第三方裁判确认 A/B 产物均可解析、'
|
|
289
|
-
'逐页文本一致、原生图表数值与模型一致')
|
|
290
|
-
return 0
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if __name__ == '__main__':
|
|
294
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""TopPPT HTML · 双通道交叉验证(可选依赖 python-pptx):
|
|
4
|
+
① A 通道(手写 OOXML)与 B 通道(pptxgenjs)产物均可被 python-pptx 严格解析
|
|
5
|
+
② 两通道逐页文本集合一致(同一模型 → 同一内容;段落换行表示差异已归一)
|
|
6
|
+
归一化:B 通道带数据的图表为原生 chart part(可编辑数据)——图表类别标签并入
|
|
7
|
+
文本集合、纯数值 token(含百分数)两端过滤(A 通道形状版的数值标签 vs B 通道 chart 数据值)
|
|
8
|
+
③ 文本溢出启发式:文本估算宽 vs shape 宽 × 可容行数
|
|
9
|
+
④ B 通道原生图表数值 ↔ 模型数值(类别标签一致不代表数值一致;数值错乱只能这里拓)
|
|
10
|
+
⑤ 字号比例尺同源:B 的字号必须是 A 字号集的子集(防 modeSize()/sz() 后处理漂移)
|
|
11
|
+
用法:先 node scripts/gen_channel_a.js 生成 A 通道产物,再 python scripts/cross_verify.py
|
|
12
|
+
改 pptx-export.js 序列化骨架后必跑(见 references/pptx-export.md 双裁判教训)。
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
20
|
+
# 依赖缺失不在导入期退出——本模块的核对函数会被 negative_tests.py 直接复用
|
|
21
|
+
try:
|
|
22
|
+
from pptx import Presentation
|
|
23
|
+
except ImportError:
|
|
24
|
+
Presentation = None
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
27
|
+
A_DIR = ROOT / 'dist' / 'regression-a' # A 通道(页面直导引擎)
|
|
28
|
+
B_DIR = ROOT / 'dist' / 'regression' # B 通道(pptxgenjs)
|
|
29
|
+
MODEL_DIR = ROOT / 'assets' / 'examples'
|
|
30
|
+
|
|
31
|
+
# 文本度量单源(与 build_pptx.js / validate_pptx.py 同值)——三侧各自硬编码会让
|
|
32
|
+
# 溢出判定在不同裁判间飘移,出现“一侧报溢出、一侧说没事”。
|
|
33
|
+
try:
|
|
34
|
+
_TM = (json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
|
|
35
|
+
.get('containers', {}).get('textMetrics', {}))
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
_TM = {}
|
|
38
|
+
LINE_FACTOR = float(_TM.get('lineFactor') or 1.45)
|
|
39
|
+
EM_ASCII_RATIO = float(_TM.get('emAsciiRatio') or 0.52)
|
|
40
|
+
|
|
41
|
+
# 形态变换类图表:模型里的负值在图上是「向下的正高度条」,按绝对值比对
|
|
42
|
+
SIGNLESS_CHARTS = {'waterfall', 'gauge', 'funnel', 'bullet'}
|
|
43
|
+
|
|
44
|
+
# 纯数值 token(含千分位/小数/百分号/常见单位后缀)——图表数据标签在 A 通道是
|
|
45
|
+
# 文本(值+unit,如 "9pt"),B 通道进 chart part;比对前两端过滤。类别标签(如 "2026Q3")
|
|
46
|
+
# 不匹配此模式,仍参与一致性检查。
|
|
47
|
+
NUMERIC_TOKEN = re.compile(
|
|
48
|
+
r'^[+-]?[\d.,]+\s*(?:%|‰|pt|pct|pp|TB|GB|MB|KB|k|w|万|亿|元|倍|个|人|天|家|次|项|年'
|
|
49
|
+
r'|条|笔|台|套|份|款|张|篇|页|件|例|场|轮|艘|辆|吨|米|秒|分|时|档|级)?$')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def slide_texts(prs):
|
|
53
|
+
"""每页文本集合(逐行拆分——A 通道段落 \\n 与 B 通道 breakLine \\n\\n 表示差异归一;
|
|
54
|
+
忽略空白、页码与纯数值 token;B 通道原生图表的类别标签并入集合)。"""
|
|
55
|
+
pages = []
|
|
56
|
+
for slide in prs.slides:
|
|
57
|
+
texts = []
|
|
58
|
+
for shape in slide.shapes:
|
|
59
|
+
if shape.has_text_frame:
|
|
60
|
+
for line in shape.text_frame.text.splitlines():
|
|
61
|
+
t = line.strip()
|
|
62
|
+
if t and not NUMERIC_TOKEN.match(t):
|
|
63
|
+
texts.append(t)
|
|
64
|
+
elif shape.has_table:
|
|
65
|
+
for row in shape.table.rows:
|
|
66
|
+
for cell in row.cells:
|
|
67
|
+
for line in cell.text.splitlines():
|
|
68
|
+
t = line.strip()
|
|
69
|
+
if t and not NUMERIC_TOKEN.match(t):
|
|
70
|
+
texts.append(t)
|
|
71
|
+
elif getattr(shape, 'has_chart', False):
|
|
72
|
+
# B 通道原生数据图表——类别标签(实质内容)并入;数据值已按纯数值过滤。
|
|
73
|
+
# 类别标签同样过 NUMERIC_TOKEN——A 通道文本统一过滤,两端语义对称
|
|
74
|
+
# (否则 gauge 的 '76%'、stack 的年份 '2023' 这类数值型类别会造成单边误报)。
|
|
75
|
+
try:
|
|
76
|
+
cats = list(shape.chart.plots[0].categories)
|
|
77
|
+
texts.extend(str(c).strip() for c in cats
|
|
78
|
+
if str(c).strip() and not NUMERIC_TOKEN.match(str(c).strip()))
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
pages.append(sorted(set(texts))) # 集合语义去重(B 端 chart categories 与自绘图例可能重复)
|
|
82
|
+
return pages
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _model_chart(sec):
|
|
86
|
+
"""取章节页的图表对象(口径与 validate_pptx._section_chart 一致)。"""
|
|
87
|
+
st = sec.get('type') or ''
|
|
88
|
+
if st in {'bar', 'donut', 'exhibit', 'halftable'}:
|
|
89
|
+
c = sec.get('chart') or {}
|
|
90
|
+
elif st == 'split':
|
|
91
|
+
c = sec.get('right') or {}
|
|
92
|
+
if (c.get('type') or 'bar') == 'table':
|
|
93
|
+
return None
|
|
94
|
+
else:
|
|
95
|
+
return None
|
|
96
|
+
return c if isinstance(c, dict) and c.get('labels') and c.get('values') else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _model_values(chart):
|
|
100
|
+
"""模型声明的全部数值(单系列 values 或多系列 series[].values)。"""
|
|
101
|
+
out = []
|
|
102
|
+
if isinstance(chart.get('series'), list) and chart['series']:
|
|
103
|
+
for se in chart['series']:
|
|
104
|
+
out += [v for v in (se.get('values') or []) if isinstance(v, (int, float))]
|
|
105
|
+
else:
|
|
106
|
+
out += [v for v in (chart.get('values') or []) if isinstance(v, (int, float))]
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def chart_data_verify(prs, model, name):
|
|
111
|
+
"""B 通道原生 chart part 的数值必须覆盖模型声明值。
|
|
112
|
+
|
|
113
|
+
只做「模型值 ⊆ 图表值」的包含判定,不做严格相等——waterfall 的累计基座、
|
|
114
|
+
gauge 的补角这类派生系列是合法的额外数据,严格相等会制造假阳性。
|
|
115
|
+
形态变换类图表(waterfall 把 -3 画成高度 3 的悬浮条)按绝对值比对。
|
|
116
|
+
"""
|
|
117
|
+
issues = []
|
|
118
|
+
secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
|
|
119
|
+
first = 3 if model.get('agenda') else 2
|
|
120
|
+
slides = list(prs.slides)
|
|
121
|
+
for i, sec in enumerate(secs):
|
|
122
|
+
chart = _model_chart(sec)
|
|
123
|
+
if chart is None:
|
|
124
|
+
continue
|
|
125
|
+
idx = first + i - 1
|
|
126
|
+
if idx >= len(slides):
|
|
127
|
+
break
|
|
128
|
+
pool = []
|
|
129
|
+
for shape in slides[idx].shapes:
|
|
130
|
+
if not getattr(shape, 'has_chart', False):
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
for plot in shape.chart.plots:
|
|
134
|
+
for se in plot.series:
|
|
135
|
+
pool += [v for v in se.values if v is not None]
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
if not pool:
|
|
139
|
+
continue # 该页是形状还原通道,数值走数据表/备注,另有门禁
|
|
140
|
+
ctype = str(chart.get('type') or 'bar').lower()
|
|
141
|
+
signless = ctype in SIGNLESS_CHARTS
|
|
142
|
+
if signless:
|
|
143
|
+
pool = [abs(p) for p in pool]
|
|
144
|
+
missing = []
|
|
145
|
+
for v in _model_values(chart):
|
|
146
|
+
target = abs(v) if signless else v
|
|
147
|
+
if not any(abs(target - p) <= max(0.01, abs(target) * 0.001) for p in pool):
|
|
148
|
+
missing.append(v)
|
|
149
|
+
if missing:
|
|
150
|
+
issues.append(f'{name} 第{idx + 1}页 {ctype} 图表数值与模型不符,'
|
|
151
|
+
f'缺 {missing[:5]}(图表实际含 {sorted(set(pool))[:6]})')
|
|
152
|
+
return issues
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def font_sizes(prs):
|
|
156
|
+
"""全文文本框出现过的字号集合(pt)。"""
|
|
157
|
+
out = set()
|
|
158
|
+
for slide in prs.slides:
|
|
159
|
+
for sh in slide.shapes:
|
|
160
|
+
if not sh.has_text_frame:
|
|
161
|
+
continue
|
|
162
|
+
for p in sh.text_frame.paragraphs:
|
|
163
|
+
for r in p.runs:
|
|
164
|
+
if r.font.size and r.text.strip():
|
|
165
|
+
out.add(round(r.font.size.pt, 2))
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def overflow_estimate(prs, name):
|
|
170
|
+
"""启发式:文本估算宽(CJK 字符按 1em、ASCII 按 emAsciiRatio)vs shape 宽 × 可容行数。"""
|
|
171
|
+
issues = []
|
|
172
|
+
for si, slide in enumerate(prs.slides, 1):
|
|
173
|
+
for shape in slide.shapes:
|
|
174
|
+
if not shape.has_text_frame or not shape.width or not shape.height:
|
|
175
|
+
continue
|
|
176
|
+
for para in shape.text_frame.paragraphs:
|
|
177
|
+
line = ''.join(r.text for r in para.runs)
|
|
178
|
+
if not line.strip():
|
|
179
|
+
continue
|
|
180
|
+
sz = None
|
|
181
|
+
for r in para.runs:
|
|
182
|
+
if r.font.size:
|
|
183
|
+
sz = r.font.size.pt
|
|
184
|
+
break
|
|
185
|
+
sz = sz or 12
|
|
186
|
+
emu_w = int(shape.width)
|
|
187
|
+
in_w = emu_w / 914400
|
|
188
|
+
# 每行可容字符宽(英寸)→ em 数
|
|
189
|
+
est_w = sum(1.0 if ord(c) > 0x2E80 else EM_ASCII_RATIO for c in line) * sz / 72
|
|
190
|
+
lines_avail = max(1, int(int(shape.height) / 914400 / (sz / 72 * LINE_FACTOR)))
|
|
191
|
+
if est_w > in_w * lines_avail * 1.18: # 18% 容差
|
|
192
|
+
issues.append(f'{name} 第{si}页 "{line[:14]}…" 估算{est_w:.1f}in > 容量{in_w*lines_avail:.1f}in')
|
|
193
|
+
return issues
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> int:
|
|
197
|
+
if Presentation is None:
|
|
198
|
+
print('python-pptx 未安装')
|
|
199
|
+
return 2
|
|
200
|
+
fails = []
|
|
201
|
+
a_files = sorted(A_DIR.glob('*.pptx'))
|
|
202
|
+
print(f'A 通道产物 {len(a_files)} 个 · B 通道产物 {len(sorted(B_DIR.glob("*.pptx")))} 个\n')
|
|
203
|
+
all_overflow = []
|
|
204
|
+
|
|
205
|
+
for ap in a_files:
|
|
206
|
+
bp = B_DIR / ap.name
|
|
207
|
+
name = ap.stem
|
|
208
|
+
# ① 可解析性
|
|
209
|
+
try:
|
|
210
|
+
prs_a = Presentation(str(ap))
|
|
211
|
+
except Exception as e:
|
|
212
|
+
fails.append(f'{name}: A 通道 python-pptx 解析失败 {e}')
|
|
213
|
+
continue
|
|
214
|
+
if not bp.exists():
|
|
215
|
+
fails.append(f'{name}: B 通道产物缺失')
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
prs_b = Presentation(str(bp))
|
|
219
|
+
except Exception as e:
|
|
220
|
+
fails.append(f'{name}: B 通道 python-pptx 解析失败 {e}')
|
|
221
|
+
continue
|
|
222
|
+
# ② 逐页文本一致性(v9:A/B 不必逐字全等——A 预览为形状近似;
|
|
223
|
+
# 要求「标题+主文本 ≥80% 交集覆盖」,杜绝截断/串页,又不因图表标签形态差误杀)
|
|
224
|
+
ta, tb = slide_texts(prs_a), slide_texts(prs_b)
|
|
225
|
+
if len(ta) != len(tb):
|
|
226
|
+
fails.append(f'{name}: 页数不一致 A={len(ta)} B={len(tb)}')
|
|
227
|
+
continue
|
|
228
|
+
min_cov = 0.80
|
|
229
|
+
try:
|
|
230
|
+
min_cov = float((json.loads((ROOT / 'scripts' / 'layout-constants.json')
|
|
231
|
+
.read_text(encoding='utf-8'))
|
|
232
|
+
.get('qualityGates') or {}).get('roundtripMin') or 0.8)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
diff_pages = []
|
|
236
|
+
for i, (x, y) in enumerate(zip(ta, tb), 1):
|
|
237
|
+
sx, sy = set(x), set(y)
|
|
238
|
+
if not sx and not sy:
|
|
239
|
+
continue
|
|
240
|
+
inter = sx & sy
|
|
241
|
+
cov = len(inter) / max(1, max(len(sx), len(sy)))
|
|
242
|
+
if cov < min_cov:
|
|
243
|
+
diff_pages.append((i, cov, sorted(sx - sy)[:3], sorted(sy - sx)[:3]))
|
|
244
|
+
if diff_pages:
|
|
245
|
+
for i, cov, only_a, only_b in diff_pages[:3]:
|
|
246
|
+
fails.append(f'{name}: 第{i}页文本覆盖 {cov:.0%} <{min_cov:.0%} '
|
|
247
|
+
f'A独有{only_a} B独有{only_b}')
|
|
248
|
+
# ③ 溢出启发式(对两通道都跑)
|
|
249
|
+
all_overflow += overflow_estimate(prs_a, name + '/A')
|
|
250
|
+
all_overflow += overflow_estimate(prs_b, name + '/B')
|
|
251
|
+
# ④ B 通道原生图表数值 ↔ 模型(A 通道是形状还原,不产 chart part)
|
|
252
|
+
chart_note = ''
|
|
253
|
+
mp = MODEL_DIR / f'{name}.model.json'
|
|
254
|
+
if mp.exists():
|
|
255
|
+
try:
|
|
256
|
+
model = json.loads(mp.read_text(encoding='utf-8'))
|
|
257
|
+
except json.JSONDecodeError as e:
|
|
258
|
+
fails.append(f'{name}: 模型不可解析 {e}')
|
|
259
|
+
model = None
|
|
260
|
+
if model:
|
|
261
|
+
data_issues = chart_data_verify(prs_b, model, name)
|
|
262
|
+
fails += data_issues
|
|
263
|
+
chart_note = ' 图表数值=' + ('PASS' if not data_issues else f'DIFF({len(data_issues)})')
|
|
264
|
+
status = 'PASS' if not diff_pages else f'LOWCOV p{[d[0] for d in diff_pages]}'
|
|
265
|
+
# ⑤ 字号比例尺同源:B 的字号必须都来自 A 同一张表。
|
|
266
|
+
# 反向不成立——A 把图表渲染成形状,标签字号会多出来(B 的在 chart part 内)。
|
|
267
|
+
fa, fb = font_sizes(prs_a), font_sizes(prs_b)
|
|
268
|
+
alien = sorted(fb - fa)
|
|
269
|
+
if alien:
|
|
270
|
+
fails.append(f'{name}: B 通道出现 A 没有的字号 {alien}'
|
|
271
|
+
f'(两通道应同走 modeTypeScale;modeSize() 与 sz() 的钳制/取整须一致)')
|
|
272
|
+
sz_note = ' 字号比例尺=' + ('PASS' if not alien else 'DRIFT')
|
|
273
|
+
print(f'{name}: A可解析✓ B可解析✓ 页数={len(ta)} 逐页文本={status}{chart_note}{sz_note}')
|
|
274
|
+
|
|
275
|
+
print('\n── 溢出启发式(check-overflow · 18% 容差)──')
|
|
276
|
+
if all_overflow:
|
|
277
|
+
for i in all_overflow[:20]:
|
|
278
|
+
print(' [WARN]', i)
|
|
279
|
+
else:
|
|
280
|
+
print(' 两通道全部通过(无文本超容 shape)')
|
|
281
|
+
|
|
282
|
+
print('\n' + '=' * 50)
|
|
283
|
+
if fails:
|
|
284
|
+
print('交叉验证失败:')
|
|
285
|
+
for f in fails:
|
|
286
|
+
print(' [FAIL]', f)
|
|
287
|
+
return 1
|
|
288
|
+
print('双通道交叉验证通过:python-pptx 第三方裁判确认 A/B 产物均可解析、'
|
|
289
|
+
'逐页文本一致、原生图表数值与模型一致')
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == '__main__':
|
|
294
|
+
sys.exit(main())
|