@topmindspace/tms-skills 2.0.1 → 2.1.0

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 (95) hide show
  1. package/CHANGELOG.md +145 -134
  2. package/README.md +1 -1
  3. package/bin/tms-skills.js +160 -160
  4. package/package.json +48 -48
  5. package/top-ppt-html/README.md +254 -254
  6. package/top-ppt-html/SKILL.md +111 -111
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  9. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3926 -3926
  10. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4325 -4325
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
  13. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4325 -4325
  14. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
  15. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  16. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  17. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5527 -5527
  18. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
  19. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5527 -5527
  20. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
  21. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  22. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  23. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5527 -5527
  24. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
  25. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  26. package/top-ppt-html/assets/style-gallery.html +589 -589
  27. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  28. package/top-ppt-html/assets/templates/engine.css +840 -840
  29. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  30. package/top-ppt-html/assets/templates/research.html +4017 -4017
  31. package/top-ppt-html/assets/templates/ui.js +520 -520
  32. package/top-ppt-html/evals/prompts.csv +15 -15
  33. package/top-ppt-html/evals/rubric.schema.json +25 -25
  34. package/top-ppt-html/evals/run_evals.py +220 -220
  35. package/top-ppt-html/evals/trace.example.json +16 -16
  36. package/top-ppt-html/package-lock.json +207 -0
  37. package/top-ppt-html/package.json +35 -35
  38. package/top-ppt-html/references/charts-basic.md +624 -624
  39. package/top-ppt-html/references/charts-discipline.md +108 -108
  40. package/top-ppt-html/references/charts-extended.md +482 -482
  41. package/top-ppt-html/references/charts.md +28 -28
  42. package/top-ppt-html/references/components-atoms.md +624 -624
  43. package/top-ppt-html/references/components.md +30 -30
  44. package/top-ppt-html/references/content-rules.md +490 -490
  45. package/top-ppt-html/references/design-system-engine.md +235 -235
  46. package/top-ppt-html/references/design-system.md +478 -478
  47. package/top-ppt-html/references/failure-modes.md +214 -214
  48. package/top-ppt-html/references/high-fidelity.md +127 -127
  49. package/top-ppt-html/references/icons.md +397 -397
  50. package/top-ppt-html/references/industry-benchmark.md +105 -105
  51. package/top-ppt-html/references/infographics-stats.md +308 -308
  52. package/top-ppt-html/references/infographics-structure.md +226 -226
  53. package/top-ppt-html/references/infographics.md +43 -43
  54. package/top-ppt-html/references/layout-grammar.md +315 -315
  55. package/top-ppt-html/references/layouts-architecture.md +108 -108
  56. package/top-ppt-html/references/layouts-combo.md +600 -600
  57. package/top-ppt-html/references/layouts-research.md +160 -160
  58. package/top-ppt-html/references/modes.md +254 -254
  59. package/top-ppt-html/references/outline-design.md +275 -275
  60. package/top-ppt-html/references/playbook.md +266 -266
  61. package/top-ppt-html/references/pptx-export.md +209 -209
  62. package/top-ppt-html/references/reform-plan.md +252 -252
  63. package/top-ppt-html/references/styles.md +370 -370
  64. package/top-ppt-html/references/tech-design.md +138 -138
  65. package/top-ppt-html/scripts/audit_css.py +109 -109
  66. package/top-ppt-html/scripts/audit_docs.py +176 -176
  67. package/top-ppt-html/scripts/audit_skill.py +220 -220
  68. package/top-ppt-html/scripts/audit_styles.py +351 -351
  69. package/top-ppt-html/scripts/build_examples.py +2276 -2276
  70. package/top-ppt-html/scripts/build_pptx.js +2380 -2380
  71. package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
  72. package/top-ppt-html/scripts/checks_html.py +127 -127
  73. package/top-ppt-html/scripts/cross_verify.py +294 -294
  74. package/top-ppt-html/scripts/env_probe.py +158 -158
  75. package/top-ppt-html/scripts/extract_model.py +210 -210
  76. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  77. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  78. package/top-ppt-html/scripts/layout-constants.json +3377 -3377
  79. package/top-ppt-html/scripts/layout_slots.json +830 -830
  80. package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
  81. package/top-ppt-html/scripts/measure_height.py +178 -178
  82. package/top-ppt-html/scripts/model-schema.json +547 -547
  83. package/top-ppt-html/scripts/negative_tests.py +307 -307
  84. package/top-ppt-html/scripts/package_skill.py +291 -291
  85. package/top-ppt-html/scripts/prepare_images.py +341 -341
  86. package/top-ppt-html/scripts/probe_image_export.py +188 -188
  87. package/top-ppt-html/scripts/quality_gate.py +301 -301
  88. package/top-ppt-html/scripts/regression.py +308 -308
  89. package/top-ppt-html/scripts/render_compare.py +275 -275
  90. package/top-ppt-html/scripts/render_from_model.py +698 -698
  91. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  92. package/top-ppt-html/scripts/section-file-map.json +104 -104
  93. package/top-ppt-html/scripts/sync_runtime.py +662 -662
  94. package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
  95. 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())