@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,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())