@topmindspace/tms-skills 0.1.16 → 0.1.18

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 (39) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.en.md +3 -3
  3. package/README.md +3 -3
  4. package/package.json +1 -1
  5. package/top-ppt-html/README.en.md +2 -2
  6. package/top-ppt-html/README.md +1 -1
  7. package/top-ppt-html/SKILL.md +2 -2
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +122 -48
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +122 -48
  10. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +122 -48
  11. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +126 -52
  12. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +3 -3
  13. package/top-ppt-html/assets/pptx-export.js +118 -45
  14. package/top-ppt-html/assets/style-gallery.html +19 -3
  15. package/top-ppt-html/assets/templates/architecture.html +122 -48
  16. package/top-ppt-html/assets/templates/engine.css +3 -2
  17. package/top-ppt-html/assets/templates/presentation.html +122 -48
  18. package/top-ppt-html/assets/templates/research.html +122 -48
  19. package/top-ppt-html/package-lock.json +2 -2
  20. package/top-ppt-html/package.json +1 -1
  21. package/top-ppt-html/references/chart-decision-tree.md +26 -25
  22. package/top-ppt-html/references/components-atoms.md +5 -4
  23. package/top-ppt-html/references/content-rules.md +3 -1
  24. package/top-ppt-html/references/failure-modes.md +29 -3
  25. package/top-ppt-html/references/layouts-combo.md +1 -1
  26. package/top-ppt-html/references/modes.md +1 -1
  27. package/top-ppt-html/references/page-type-matrix.md +10 -6
  28. package/top-ppt-html/references/pptx-export.md +3 -3
  29. package/top-ppt-html/scripts/build_pptx.js +101 -42
  30. package/top-ppt-html/scripts/cross_verify.py +84 -1
  31. package/top-ppt-html/scripts/extract_model.py +5 -0
  32. package/top-ppt-html/scripts/layout-constants.json +42 -2
  33. package/top-ppt-html/scripts/lib_layout_regions.js +6 -3
  34. package/top-ppt-html/scripts/model-schema.json +1 -0
  35. package/top-ppt-html/scripts/render_from_model.py +133 -17
  36. package/top-ppt-html/scripts/sync_runtime.py +19 -3
  37. package/top-ppt-html/scripts/test_feedback_gates.py +73 -0
  38. package/top-ppt-html/scripts/validate_pptx.py +148 -0
  39. package/top-ppt-html/scripts/validate_report.py +24 -0
@@ -34,6 +34,7 @@ ROOT = Path(__file__).resolve().parent.parent
34
34
  TPL = ROOT / 'assets' / 'templates'
35
35
  LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
36
36
  PAGE_TO_PRESET = ((LC.get('layoutSystem') or {}).get('pageToPreset') or {})
37
+ IMAGE_SPEC = LC.get('imageSpec') or {}
37
38
 
38
39
  CONTENT_RE = re.compile(
39
40
  r'(<!-- __TOPPPT_CONTENT_START__ -->)[\s\S]*?(<!-- __TOPPPT_CONTENT_END__ -->)')
@@ -102,6 +103,49 @@ def wrap(i: int, ptype: str, body: str, sec: dict, extra: str = '') -> str:
102
103
  f' </div>\n</section>')
103
104
 
104
105
 
106
+ # ── 语义图标库(高频取码 · 与 references/icons.md 同源;PPTX 侧由 accent 方块路标对应)──
107
+ _SVG_OPEN = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" '
108
+ 'stroke-linecap="round" stroke-linejoin="round">')
109
+ ICONS: dict[str, str] = {
110
+ '增长': _SVG_OPEN + '<path d="M22 7l-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/></svg>',
111
+ '下降': _SVG_OPEN + '<path d="M22 17l-8.5-8.5-5 5L2 7"/><path d="M16 17h6v-6"/></svg>',
112
+ '数据': _SVG_OPEN + '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>',
113
+ '图表': _SVG_OPEN + '<path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6" rx="1"/><rect x="12" y="8" width="3" height="10" rx="1"/><rect x="17" y="4" width="3" height="14" rx="1"/></svg>',
114
+ '趋势': _SVG_OPEN + '<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/><path d="M15 7h4v4"/></svg>',
115
+ '占比': _SVG_OPEN + '<path d="M21.2 15.9A10 10 0 1 1 8 2.8"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>',
116
+ '表格': _SVG_OPEN + '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>',
117
+ '效率': _SVG_OPEN + '<path d="M12 15l3.5-5.5"/><path d="M20.2 15a8.5 8.5 0 1 0-16.4 0"/></svg>',
118
+ '成果': _SVG_OPEN + '<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/></svg>',
119
+ '安全': _SVG_OPEN + '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>',
120
+ '权限': _SVG_OPEN + '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
121
+ '检查': _SVG_OPEN + '<circle cx="12" cy="12" r="10"/><path d="M8 12.5l2.5 2.5L16 9.5"/></svg>',
122
+ '风险': _SVG_OPEN + '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>',
123
+ '团队': _SVG_OPEN + '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
124
+ '流程': _SVG_OPEN + '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>',
125
+ '计划': _SVG_OPEN + '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>',
126
+ '智能': _SVG_OPEN + '<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/></svg>',
127
+ '洞察': _SVG_OPEN + '<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>',
128
+ '工具': _SVG_OPEN + '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
129
+ '清单': _SVG_OPEN + '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>',
130
+ }
131
+ _ICONS_ORDER = list(ICONS.keys())
132
+
133
+
134
+ def pick_icon(title: str = '', idx: int = 0) -> str:
135
+ """按标题语义挑图标;未命中则按序轮换(保持同屏同家族、不堆砌)。"""
136
+ t = str(title or '')
137
+ for key, svg in ICONS.items():
138
+ if key in t:
139
+ return svg
140
+ return ICONS[_ICONS_ORDER[idx % len(_ICONS_ORDER)]]
141
+
142
+
143
+ def card_head(title: str, idx: int = 0) -> str:
144
+ """卡片头:图标路标 + 标题(与 scaffold 的 .card__hd + .card__ico 同源)。"""
145
+ return (f'<div class="card__hd"><div class="card__ico">{pick_icon(title, idx)}</div>'
146
+ f'<h3 class="t-h3">{esc(title)}</h3></div>')
147
+
148
+
105
149
  def pts_list(points) -> str:
106
150
  if not points:
107
151
  return '<ul class="ul"><li>(待填)</li></ul>'
@@ -130,12 +174,12 @@ def r_points(i, sec):
130
174
  pts = sec.get('points') or []
131
175
  half = (len(pts) + 1) // 2 or 1
132
176
  col1, col2 = pts[:half], pts[half:]
133
- def card(title, items):
134
- return (f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
177
+ def card(title, items, idx=0):
178
+ return (f' <div class="card">{card_head(title, idx)}\n'
135
179
  f' {pts_list(items)}</div>')
136
180
  body = (' <div class="grid g-2 rv a-start">\n'
137
- + card('要点一', col1 or pts[:1]) + '\n'
138
- + card('要点二', col2 or [['', '(第二组要点)']]) + '\n'
181
+ + card('要点一', col1 or pts[:1], 0) + '\n'
182
+ + card('要点二', col2 or [['', '(第二组要点)']], 1) + '\n'
139
183
  + ' </div>\n')
140
184
  if sec.get('metrics'):
141
185
  body += metrics_row(sec['metrics'])
@@ -277,11 +321,11 @@ def r_cards(i, sec):
277
321
  n = int(sec.get('columns') or min(3, max(1, len(cards))))
278
322
  cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
279
323
  blocks = []
280
- for cd in cards:
324
+ for ci, cd in enumerate(cards):
281
325
  title = cd.get('title') if isinstance(cd, dict) else (cd[0] if cd else '')
282
326
  points = cd.get('points') if isinstance(cd, dict) else ([['', cd[1]]] if isinstance(cd, (list, tuple)) and len(cd) > 1 else [])
283
327
  blocks.append(
284
- f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
328
+ f' <div class="card">{card_head(str(title), ci)}\n'
285
329
  f' {pts_list(points)}</div>')
286
330
  body = f' <div class="grid {cls} rv a-start">\n' + '\n'.join(blocks) + '\n </div>\n'
287
331
  return wrap(i, 'cards', body, sec)
@@ -302,9 +346,12 @@ def r_split(i, sec):
302
346
  if t == 'image':
303
347
  im = el.get('image') or {}
304
348
  cap = im.get('caption') or ''
305
- return (' <figure class="media media--r4-3 media--ph">'
306
- '<div class="media__ph"><b>配图占位</b><span>建议 1200×900px</span></div></figure>'
307
- + (f'\n <div class="media__cap--below">{esc(cap)}</div>' if cap else ''))
349
+ src = im.get('src') or ''
350
+ ph = bool(im.get('placeholder')) or not src
351
+ fit = im.get('fit') or (IMAGE_SPEC or {}).get('fitDefault') or 'cover'
352
+ fig = _media_figure(src, 'half', fit, '', placeholder=ph,
353
+ ratio_cls=(IMAGE_SPEC or {}).get('ratioCssClass', {}).get('half') or 'media--r4-3')
354
+ return fig + (f'\n <div class="media__cap--below">{esc(cap)}</div>' if cap else '')
308
355
  if t == 'points' or el.get('points') and t not in ('bar', 'line', 'donut', 'hbar'):
309
356
  return f' <div class="stack gap-4">{pts_list(el.get("points"))}</div>'
310
357
  return (f' <div class="fig"><div class="fig__cap">{esc(el.get("cap") or "图表")}</div>\n'
@@ -324,10 +371,14 @@ def r_comparison(i, sec):
324
371
  body = (' <div class="grid g-half g-2 rv a-start">\n'
325
372
  + panel(sec.get('left')) + '\n' + panel(sec.get('right'), True) + '\n'
326
373
  + ' </div>\n')
327
- if sec.get('verdict'):
374
+ has_verdict = bool(sec.get('verdict'))
375
+ if has_verdict:
328
376
  body += (f' <div class="sowhat rv"><span class="sowhat__v">{cite(sec["verdict"])}</span></div>\n')
329
377
  sec = dict(sec)
330
378
  sec.pop('verdict', None)
379
+ # R2:verdict 与 soWhat 共用 .sowhat 槽位——verdict 已渲染时移除 soWhat,禁止同槽双条
380
+ if has_verdict:
381
+ sec.pop('soWhat', None)
331
382
  return wrap(i, 'comparison', body, sec)
332
383
 
333
384
 
@@ -342,17 +393,82 @@ def r_quote(i, sec):
342
393
  return wrap(i, 'quote', body, sec, ' band--accent band--fit')
343
394
 
344
395
 
396
+ def _img_ph_label(layout: str, multi: bool = False) -> str:
397
+ """占位标签串——与 PPTX imgPlaceholderLabel 同源(imageSpec 常量拼接,cross_verify 逐页比对)。"""
398
+ spec = IMAGE_SPEC or {}
399
+ label = spec.get('placeholderLabel') or '配图占位'
400
+ if multi:
401
+ return label
402
+ size = (spec.get('recommendedSizePx') or {}).get(layout)
403
+ if not size:
404
+ return label
405
+ prefix = spec.get('placeholderHintPrefix') or '建议 '
406
+ suffix = spec.get('placeholderHintSuffix') or 'px'
407
+ return f'{label} · {prefix}{size}{suffix}'
408
+
409
+
410
+ def _media_figure(src: str, layout: str, fit: str, caption: str = '',
411
+ placeholder: bool = False, multi: bool = False, ratio_cls: str = '') -> str:
412
+ """单张图 / 占位框(与 PPTX addImageEl 同源:比例锁定 + fit + 占位三要素标签)。"""
413
+ spec = IMAGE_SPEC or {}
414
+ ratio_map = spec.get('ratioCssClass') or {}
415
+ rc = ratio_cls or ratio_map.get(layout) or 'media--r3-1'
416
+ fit_cls = ' media--contain' if str(fit or '').lower() == 'contain' else ''
417
+ if placeholder or not src:
418
+ ph_text = _img_ph_label(layout, multi)
419
+ # 与 PPTX 同串:bold=配图占位,span=建议 …px(单串合并输出,保证 A/B 文本一致)
420
+ parts = ph_text.split(' · ', 1)
421
+ b = esc(parts[0])
422
+ span = esc(parts[1]) if len(parts) > 1 else ''
423
+ inner = f'<div class="media__ph"><b>{b}</b><span>{span}</span></div>'
424
+ return f'<figure class="media {rc} media--ph">{inner}</figure>'
425
+ alt = caption or '配图'
426
+ img = f'<img src="{esc(src)}" alt="{esc(alt)}">'
427
+ cap_html = f'<figcaption class="media__cap--below">{cite(caption)}</figcaption>' if caption else ''
428
+ return f'<figure class="media {rc}{fit_cls}">{img}{cap_html}</figure>'
429
+
430
+
345
431
  def r_image(i, sec):
346
432
  im = sec.get('image') or {}
347
433
  layout = (im.get('layout') or 'full').lower()
348
- ratio = {'half': 'r4-3', 'bleed': 'r21-9', 'grid': 'r4-3', 'compare': 'r4-3',
349
- 'wall': 'r1-1', 'full': 'r3-1'}.get(layout, 'r3-1')
434
+ fit = im.get('fit') or (IMAGE_SPEC or {}).get('fitDefault') or 'cover'
350
435
  cap = im.get('caption') or ''
351
- media = (f' <figure class="media media--{ratio} media--ph rv">'
352
- f'<div class="media__ph"><b>配图占位</b><span>{esc(im.get("hint") or "建议尺寸见 imageSpec")}</span></div>'
353
- f'</figure>\n')
354
- if cap:
355
- media += f' <p class="media__src">{cite(cap)}</p>\n'
436
+ items = im.get('items') or []
437
+ multi_layouts = (IMAGE_SPEC or {}).get('multiLayouts') or ['grid', 'compare', 'wall']
438
+
439
+ def one(src, placeholder=False, multi=False, ratio_cls=''):
440
+ return _media_figure(src, layout, fit, cap if not multi else '',
441
+ placeholder=placeholder, multi=multi, ratio_cls=ratio_cls)
442
+
443
+ if layout in multi_layouts and items:
444
+ # 多图版式:grid / compare / wall(与 PPTX imageLayoutShapes 同源)
445
+ cells = []
446
+ ratio_map = (IMAGE_SPEC or {}).get('ratioCssClass') or {}
447
+ for it in items[:6]:
448
+ if not isinstance(it, dict):
449
+ it = {'src': str(it)}
450
+ it_src = it.get('src') or ''
451
+ it_ph = bool(it.get('placeholder')) or not it_src
452
+ it_cap = it.get('caption') or ''
453
+ fig = one(it_src, placeholder=it_ph, multi=True,
454
+ ratio_cls=ratio_map.get(layout) or 'media--r4-3')
455
+ if it_cap:
456
+ fig += f' <p class="media__src">{cite(it_cap)}</p>\n'
457
+ cells.append(fig)
458
+ n = len(cells)
459
+ if layout == 'compare':
460
+ grid_cls = 'media-compare'
461
+ elif layout == 'wall':
462
+ grid_cls = 'media-wall'
463
+ else:
464
+ grid_cls = f'media-grid media-grid--{min(4, max(2, n))}'
465
+ media = f' <div class="{grid_cls} rv">\n' + '\n'.join(cells) + '\n </div>\n'
466
+ else:
467
+ src = im.get('src') or ''
468
+ ph = bool(im.get('placeholder')) or not src
469
+ media = one(src, placeholder=ph)
470
+ if cap:
471
+ media += f' <p class="media__src">{cite(cap)}</p>\n'
356
472
  body = media
357
473
  if layout == 'half' and sec.get('points'):
358
474
  body = (' <div class="grid g-side rv a-start">\n' + media +
@@ -199,6 +199,15 @@ function regionOf(pageType, slotId, opts) {
199
199
  return { x: MX, y: P.heroY || 2.9, w: CW * (P.heroW || 0.52), h: P.heroH || 1.6,
200
200
  dividerX: P.dividerX, metricY0: P.metricY0, metricRowH: P.metricRowH };
201
201
  }
202
+ /* R3:kpi 支撑指标列——落在 dividerX 右侧(默认全宽返回会压住 hero) */
203
+ if (type === 'kpi' && slot === 'metrics') {
204
+ var dxK = P.dividerX != null ? P.dividerX : 0.56;
205
+ return {
206
+ x: MX + CW * dxK + 0.35, y: P.metricY0 || 2.95,
207
+ w: CW * (1 - dxK) - 0.5,
208
+ h: Math.max(0.6, bottomDefault - (P.metricY0 || 2.95)),
209
+ };
210
+ }
202
211
  if (type === 'comparison' && (slot === 'left' || slot === 'primary' || slot === 'right')) {
203
212
  var cg = P.panelGap || 0.4, pw = (CW - cg) / 2;
204
213
  if (slot === 'right') return { x: MX + pw + cg, y: P.panelY || 2.55, w: pw, h: P.panelH || 3.4 };
@@ -231,15 +240,22 @@ function regionOf(pageType, slotId, opts) {
231
240
  }
232
241
  if (type === 'image' && slot === 'primary') {
233
242
  var iy2 = Math.max(P.y || 2.3, opts.top || 0);
234
- return { x: MX, y: iy2, w: CW, h: Math.max(1.2, Math.min(P.h || 4.2, (opts.bottom != null ? opts.bottom : bottomDefault) - iy2)) };
243
+ /* R2:有图注时图片不得越过 contentBottomWithNote(图注落注释带,属 annotation self) */
244
+ var iBottom = (opts.bottom != null) ? opts.bottom
245
+ : (opts.caption ? (L.contentBottomWithNote || 6.4) : bottomDefault);
246
+ return { x: MX, y: iy2, w: CW, h: Math.max(1.2, Math.min(P.h || 4.2, iBottom - iy2)) };
235
247
  }
236
248
  if ((type === 'halftable') && (slot === 'left' || slot === 'primary')) {
237
249
  var rs = PT.research || {};
238
- return { x: MX, y: topDefault, w: CW * (rs.halfTableW || 0.52), h: Math.max(1, bottomDefault - topDefault) };
250
+ var ht = opts.top != null ? opts.top : topDefault;
251
+ var hb = opts.bottom != null ? opts.bottom : bottomDefault;
252
+ return { x: MX, y: ht, w: CW * (rs.halfTableW || 0.52), h: Math.max(1, hb - ht) };
239
253
  }
240
254
  if ((type === 'halftable') && (slot === 'right' || slot === 'secondary')) {
241
255
  var rs2 = PT.research || {};
242
- return { x: MX + CW * (rs2.halfChartX || 0.57), y: topDefault, w: CW * (rs2.halfChartW || 0.43), h: Math.max(1, bottomDefault - topDefault) };
256
+ var ht2 = opts.top != null ? opts.top : topDefault;
257
+ var hb2 = opts.bottom != null ? opts.bottom : bottomDefault;
258
+ return { x: MX + CW * (rs2.halfChartX || 0.57), y: ht2, w: CW * (rs2.halfChartW || 0.43), h: Math.max(1, hb2 - ht2) };
243
259
  }
244
260
  if (type === 'matrix' && slot === 'primary') {
245
261
  var rm = PT.research || {};
@@ -345,6 +345,77 @@ def test_engine_static() -> None:
345
345
  )
346
346
 
347
347
 
348
+ def _sp(x: float, y: float, w: float, h: float, text: str = "T") -> ET.Element:
349
+ """构造带 xfrm + 文本的 p:sp 测试件。"""
350
+ xml = (
351
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
352
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
353
+ f'<p:spPr><a:xfrm><a:off x="{int(x * EMU)}" y="{int(y * EMU)}"/>'
354
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/></a:xfrm></p:spPr>'
355
+ f'<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>'
356
+ "</p:sp>"
357
+ )
358
+ return ET.fromstring(xml)
359
+
360
+
361
+ def test_element_overlap() -> None:
362
+ """R6: 两个内容框明显叠印必须 fire;纯包含(卡片+内文)不 fire。"""
363
+ a = _sp(1.0, 2.0, 2.0, 1.0, "这是一个足够长的正文标题占位符")
364
+ b = _sp(2.0, 2.3, 2.0, 1.0, "另一段同样足够长的正文内容占位") # 与 A 重叠 1.0×0.7 = 0.7 in²
365
+ issues = V.element_overlap_check([a, b], 1, int(13.333 * EMU), int(7.5 * EMU))
366
+ codes = [i["code"] for i in issues]
367
+ ok(
368
+ "6a ELEMENT_OVERLAP fires on side-by-side crush",
369
+ "ELEMENT_OVERLAP" in codes,
370
+ f"codes={codes}",
371
+ )
372
+ outer = _sp(1.0, 2.0, 4.0, 2.0, "卡片容器标题占位")
373
+ inner = _sp(1.2, 2.2, 1.0, 0.4, "内文")
374
+ issues2 = V.element_overlap_check([outer, inner], 1, int(13.333 * EMU), int(7.5 * EMU))
375
+ ok(
376
+ "6b containment (card+inner) does not fire ELEMENT_OVERLAP",
377
+ "ELEMENT_OVERLAP" not in [i["code"] for i in issues2],
378
+ )
379
+ # 两个短标签/数值(图表轴标签、指标值)不算叠印
380
+ lab = _sp(1.0, 3.0, 1.0, 0.3, "已规模化")
381
+ val = _sp(1.2, 3.15, 0.8, 0.3, "18%")
382
+ issues3 = V.element_overlap_check([lab, val], 1, int(13.333 * EMU), int(7.5 * EMU))
383
+ ok(
384
+ "6f short label+value pairs do not fire ELEMENT_OVERLAP",
385
+ "ELEMENT_OVERLAP" not in [i["code"] for i in issues3],
386
+ )
387
+
388
+
389
+ def test_layout_fill() -> None:
390
+ """R6: 宽/高填充低于硬下限须 fire;结构页豁免。"""
391
+ def _box(x: float, y: float, w: float, h: float) -> tuple[int, int, int, int]:
392
+ return (int(x * EMU), int(y * EMU), int(w * EMU), int(h * EMU))
393
+ # 只占左侧窄条:宽填充 ~15%
394
+ narrow = [_box(0.6, 2.0, 1.5, 3.0)]
395
+ issues = V.layout_fill_check(narrow, 3, int(13.333 * EMU), int(7.5 * EMU), is_structural=False)
396
+ codes = [i["code"] for i in issues]
397
+ ok(
398
+ "6c LAYOUT_FILL fires on underfill content page",
399
+ "LAYOUT_FILL" in codes,
400
+ f"codes={codes}",
401
+ )
402
+ ok(
403
+ "6d structural page exempt from LAYOUT_FILL",
404
+ "LAYOUT_FILL" not in [
405
+ i["code"]
406
+ for i in V.layout_fill_check(narrow, 2, int(13.333 * EMU), int(7.5 * EMU), is_structural=True)
407
+ ],
408
+ )
409
+ wide = [_box(0.6, 1.5, 12.0, 5.5)] # 宽 90% / 高 73%,落在 research 目标带 70–85 内
410
+ ok(
411
+ "6e full-width content page passes LAYOUT_FILL",
412
+ "LAYOUT_FILL" not in [
413
+ i["code"]
414
+ for i in V.layout_fill_check(wide, 3, int(13.333 * EMU), int(7.5 * EMU), is_structural=False)
415
+ ],
416
+ )
417
+
418
+
348
419
  def main() -> int:
349
420
  print("feedback-gates regression")
350
421
  test_shape_bounds_reads_p_xfrm()
@@ -356,6 +427,8 @@ def main() -> int:
356
427
  test_annotation_band_fires_when_note_present()
357
428
  test_annotation_band_reports_all_invaders()
358
429
  test_annotation_band_so_what_band_top()
430
+ test_element_overlap()
431
+ test_layout_fill()
359
432
  test_chrome_footer_ignores_bare_integers()
360
433
  test_chart_bottom_no_short_circuit()
361
434
  test_font_size_h2_17_allowed()
@@ -117,6 +117,9 @@ STRICT_FAILURE_CODES = {
117
117
  "LAYOUT_MULTI_FOCUS",
118
118
  "LAYOUT_ALIGN_DRIFT",
119
119
  "LAYOUT_LABEL_COLLIDE",
120
+ # R6:元素两两重叠 + 版心填充率(本次诊断盲区)
121
+ "ELEMENT_OVERLAP",
122
+ "LAYOUT_FILL",
120
123
  }
121
124
  # 深度模式专属校验码(--deep 时并入 STRICT_FAILURE_CODES;默认不跑,保持轻量)
122
125
  DEEP_STRICT_CODES = {
@@ -509,6 +512,143 @@ def annotation_band_overlap_check(
509
512
  return out
510
513
 
511
514
 
515
+ # ── R6:元素两两重叠(本次诊断 31 处节点叠印 + 2 处整条结论叠印均漏检) ──
516
+ # 面积阈值 0.05 in²;同页 >3 处升为硬失败。chrome/背景/注释带自身豁免。
517
+ ELEMENT_OVERLAP_MIN_AREA_IN2 = 0.05
518
+ ELEMENT_OVERLAP_MAX_PER_SLIDE = 3
519
+
520
+
521
+ def _is_full_bleed_or_bg(box: tuple[int, int, int, int], width: int, height: int) -> bool:
522
+ """整页/大面积底衬:不参与两两重叠判定。"""
523
+ x, y, cx, cy = box
524
+ return cx * cy >= 0.85 * width * height
525
+
526
+
527
+ def element_overlap_check(
528
+ elements: list[ET.Element],
529
+ slide_no: int,
530
+ width: int,
531
+ height: int,
532
+ ) -> list[dict[str, Any]]:
533
+ """元素两两重叠(ELEMENT_OVERLAP)。
534
+
535
+ 收集所有可测元素的包围盒,跳过整页背景 / 页码 chrome / 注释带自身
536
+ (tr:soWhat / tr:footnote / tr:band),以及页头区(眉题/标题/装饰线,y < 2.0in)
537
+ ——页头元素上下紧邻是版式语义,不是叠印。只对正文区(y ≥ 2.0in)元素做两两相交判定。
538
+ 同页超过 ELEMENT_OVERLAP_MAX_PER_SLIDE 处时在末条 issue 标注「密集叠印」。
539
+ """
540
+ out: list[dict[str, Any]] = []
541
+ boxes: list[tuple[tuple[int, int, int, int], str]] = []
542
+ min_area = int(ELEMENT_OVERLAP_MIN_AREA_IN2 * 914400 * 914400)
543
+ head_limit = int(2.0 * 914400) # 页头区下界:眉题 0.48 / 标题 0.82 / 装饰线 1.72
544
+ for el in elements:
545
+ box = shape_bounds(el)
546
+ if box is None:
547
+ continue
548
+ if _is_full_bleed_or_bg(box, width, height):
549
+ continue
550
+ x, y, cx, cy = box
551
+ # 页头/页码 chrome 不参与正文叠印判定
552
+ if y + cy <= head_limit:
553
+ continue
554
+ oname = (_shape_object_name(el) or "")
555
+ # 注释带自身(结论条/来源/待核实条)不参与互撞——它们共用槽位由引擎互斥
556
+ if any(k in oname for k in ("soWhat", "footnote", "tr:band", "tr:flag")):
557
+ continue
558
+ text = text_content(el).strip()
559
+ label = (text[:14] + "…") if len(text) > 14 else (text or oname or "shape")
560
+ boxes.append((box, label))
561
+
562
+ hits = 0
563
+ for i in range(len(boxes)):
564
+ (ax, ay, aw, ah), la = boxes[i]
565
+ for j in range(i + 1, len(boxes)):
566
+ (bx, by, bw, bh), lb = boxes[j]
567
+ ox = min(ax + aw, bx + bw) - max(ax, bx)
568
+ oy = min(ay + ah, by + bh) - max(ay, by)
569
+ if ox <= 0 or oy <= 0:
570
+ continue
571
+ area = ox * oy
572
+ if area < min_area:
573
+ continue
574
+ # 纯包含(一方完全罩住另一方)多半是卡片+内文,不算叠印
575
+ if (ax >= bx and ay >= by and ax + aw <= bx + bw and ay + ah <= by + bh) or \
576
+ (bx >= ax and by >= ay and bx + bw <= ax + aw and by + bh <= ay + ah):
577
+ continue
578
+ # 短标签 + 长正文共处一卡(title+body 紧排)不算叠印:两者水平对齐且纵向仅微碰
579
+ short_long = (len(la) <= 12) != (len(lb) <= 12)
580
+ v_touch = oy < int(0.12 * 914400) # 纵向重叠 <0.12in 视为基线紧排
581
+ if short_long and v_touch:
582
+ continue
583
+ # 两个都是短标签/数值(图表轴标签、指标值、图例文字):数据展示的常规紧排,不是叠印
584
+ if len(la) <= 12 and len(lb) <= 12:
585
+ continue
586
+ hits += 1
587
+ out.append(issue(
588
+ "ELEMENT_OVERLAP",
589
+ f"元素叠印:「{la}」∩「{lb}」重叠 {area / (914400 * 914400):.2f}in²"
590
+ f"(≥{ELEMENT_OVERLAP_MIN_AREA_IN2}in²)——请错开坐标或合并为同一文本框。",
591
+ slide=slide_no,
592
+ ))
593
+ if hits > ELEMENT_OVERLAP_MAX_PER_SLIDE:
594
+ out.append(issue(
595
+ "ELEMENT_OVERLAP",
596
+ f"同页元素叠印 {hits} 处(>{ELEMENT_OVERLAP_MAX_PER_SLIDE})——几何算法与内容量脱钩,需重构布局。",
597
+ slide=slide_no,
598
+ ))
599
+ return out
600
+
601
+
602
+ def layout_fill_check(
603
+ bounds: list[tuple[int, int, int, int]],
604
+ slide_no: int,
605
+ width: int,
606
+ height: int,
607
+ is_structural: bool = False,
608
+ ) -> list[dict[str, Any]]:
609
+ """版心填充率(LAYOUT_FILL):宽、高分别算,低于下限告警。
610
+
611
+ structural 页(封面/大纲/收尾/引用)豁免——它们本来就是留白页。
612
+ 目标带取 layout-constants.json 的 layoutSystem.fillTarget(按模式),
613
+ 这里用「包围盒占画布比」的宽高分量做保守近似。
614
+ """
615
+ out: list[dict[str, Any]] = []
616
+ if is_structural or not bounds:
617
+ return out
618
+ try:
619
+ lc_path = Path(__file__).resolve().parent / "layout-constants.json"
620
+ lc = json.loads(lc_path.read_text(encoding="utf-8"))
621
+ ft = ((lc.get("layoutSystem") or {}).get("fillTarget") or {})
622
+ # 默认取 research 带(最常见研究报告)
623
+ target = ft.get("research") or [70, 85]
624
+ fill_min = float(target[0]) / 100.0
625
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
626
+ fill_min = 0.55
627
+
628
+ left = min(x for x, _, _, _ in bounds)
629
+ top = min(y for _, y, _, _ in bounds)
630
+ right = max(x + cx for x, _, cx, _ in bounds)
631
+ bottom = max(y + cy for _, y, _, cy in bounds)
632
+ w_fill = max(0.0, (right - left) / max(width, 1))
633
+ h_fill = max(0.0, (bottom - top) / max(height, 1))
634
+ # 硬下限 0.55(诊断口径),目标带下限作 WARN
635
+ hard_min = 0.55
636
+ if w_fill < hard_min or h_fill < hard_min:
637
+ out.append(issue(
638
+ "LAYOUT_FILL",
639
+ f"版心填充不足(宽 {w_fill:.0%} / 高 {h_fill:.0%},硬下限 {hard_min:.0%})——"
640
+ "多栏请按版心推导列宽,低密度页补从件(指标/小图/对照表)。",
641
+ slide=slide_no,
642
+ ))
643
+ elif w_fill < fill_min or h_fill < fill_min:
644
+ out.append(issue(
645
+ "LAYOUT_FILL",
646
+ f"版心填充偏低(宽 {w_fill:.0%} / 高 {h_fill:.0%},目标下限 {fill_min:.0%})。",
647
+ slide=slide_no,
648
+ ))
649
+ return out
650
+
651
+
512
652
 
513
653
  def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]]:
514
654
  """Font sizes must sit on the declared type scale / fontShrink ladder.
@@ -913,6 +1053,9 @@ def inspect_slide(
913
1053
 
914
1054
  warnings.extend(annotation_band_overlap_check(
915
1055
  [*shapes, *pictures, *graphic_frames], slide_number, height, width))
1056
+ # R6:元素两两重叠(节点叠印 / 结论条叠印漏检主因)
1057
+ warnings.extend(element_overlap_check(
1058
+ [*shapes, *pictures, *graphic_frames], slide_number, width, height))
916
1059
  warnings.extend(font_size_snap_check(root, slide_number))
917
1060
 
918
1061
  for table in tables:
@@ -986,6 +1129,11 @@ def inspect_slide(
986
1129
  issue("EMPTY_OR_UNMEASURABLE_SLIDE", "页面没有任何可测量的原生元素。", slide=slide_number)
987
1130
  )
988
1131
 
1132
+ # R6:版心填充率(宽/高分别算;封面/大纲/收尾等结构页豁免)
1133
+ _struct_text = combined_text[:200] if combined_text else ""
1134
+ _is_struct = bool(re.search(r"(报告大纲|AGENDA|下一步|参考资料|封面)", _struct_text)) or slide_number <= 2
1135
+ warnings.extend(layout_fill_check(bounds, slide_number, width, height, is_structural=_is_struct))
1136
+
989
1137
  if len(all_elements) <= 1 and not pictures:
990
1138
  warnings.append(
991
1139
  issue("LOW_CONTENT_DENSITY", "页面元素 ≤1 个;检查信息密度。", slide=slide_number)
@@ -217,6 +217,20 @@ def _check_charts(txt, chk):
217
217
  chk("图表最小尺寸·建议(通宽图 viewBox 高)", not small_note,
218
218
  "; ".join(small_note) if small_note else "", level="WARN")
219
219
 
220
+ # R5:PPTX 还原度——low 图型在 PPTX 显著退化,仅提示不卡门禁(HTML 交付合法;
221
+ # PPTX 交付时在选型决策树里改 bar/line/hbar 或 dataTable=inline)
222
+ fidelity_map = (LC.get('charts') or {}).get('fidelityMap') or {}
223
+ if fidelity_map:
224
+ low_used = []
225
+ for t in chart_tags:
226
+ m = re.search(r'data-chart="([^"]+)"', t)
227
+ if m and fidelity_map.get(m.group(1)) == 'low':
228
+ low_used.append(m.group(1))
229
+ if low_used:
230
+ chk("图表 PPTX 还原度提示(low 图型 PPTX 会退化)", True,
231
+ f"低还原度: {sorted(set(low_used))}——HTML 合法;PPTX 建议改 bar/line/hbar 或 dataTable=inline",
232
+ level="WARN")
233
+
220
234
 
221
235
  CARRIERS = checks_html.CARRIERS # 单源 scripts/checks_html.py
222
236
 
@@ -1675,6 +1689,16 @@ def main():
1675
1689
  r'<ol\b[^>]*class="[^"]*\bagenda\b[^"]*\bagenda--2col\b', txt))
1676
1690
  chk(f"Agenda >{AGENDA_SINGLE_MAX} 条时已用 agenda--2col 双列",
1677
1691
  has_2col, f"{n_items} 条未双列(大纲很多→两排/两列,禁单列撑爆一屏)", level="WARN")
1692
+ # R4:Agenda 容量契约——条数超上限须分页/章节级大纲;单条标题限长
1693
+ ag_cfg = (LC.get('contentQuality') or {}).get('agenda') or {}
1694
+ split_max = int(ag_cfg.get('splitMax') or 16)
1695
+ title_max = int(ag_cfg.get('titleMaxChars') or 36)
1696
+ chk(f"Agenda 条数 ≤{split_max}(超出请拆上/下篇或章节级大纲)",
1697
+ n_items <= split_max,
1698
+ f"{n_items} 条超上限——PPTX 已自动分页,HTML 会撑出一屏,建议改章节级大纲 6–8 条")
1699
+ long_titles = re.findall(r'class="agenda__t"[^>]*>([^<]{%d,})' % (title_max + 1), txt)
1700
+ chk(f"Agenda 单条标题 ≤{title_max} 字", not long_titles,
1701
+ f"{len(long_titles)} 条超长:{long_titles[:3]}…" if long_titles else "", level="WARN")
1678
1702
 
1679
1703
  # ── 主标题粗体 ──
1680
1704
  chk("主标题粗体 --fw-title/--fw-display",