@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.
- package/CHANGELOG.md +47 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/package.json +1 -1
- package/top-ppt-html/README.en.md +2 -2
- package/top-ppt-html/README.md +1 -1
- package/top-ppt-html/SKILL.md +2 -2
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +122 -48
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +122 -48
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +122 -48
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +126 -52
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +3 -3
- package/top-ppt-html/assets/pptx-export.js +118 -45
- package/top-ppt-html/assets/style-gallery.html +19 -3
- package/top-ppt-html/assets/templates/architecture.html +122 -48
- package/top-ppt-html/assets/templates/engine.css +3 -2
- package/top-ppt-html/assets/templates/presentation.html +122 -48
- package/top-ppt-html/assets/templates/research.html +122 -48
- package/top-ppt-html/package-lock.json +2 -2
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/references/chart-decision-tree.md +26 -25
- package/top-ppt-html/references/components-atoms.md +5 -4
- package/top-ppt-html/references/content-rules.md +3 -1
- package/top-ppt-html/references/failure-modes.md +29 -3
- package/top-ppt-html/references/layouts-combo.md +1 -1
- package/top-ppt-html/references/modes.md +1 -1
- package/top-ppt-html/references/page-type-matrix.md +10 -6
- package/top-ppt-html/references/pptx-export.md +3 -3
- package/top-ppt-html/scripts/build_pptx.js +101 -42
- package/top-ppt-html/scripts/cross_verify.py +84 -1
- package/top-ppt-html/scripts/extract_model.py +5 -0
- package/top-ppt-html/scripts/layout-constants.json +42 -2
- package/top-ppt-html/scripts/lib_layout_regions.js +6 -3
- package/top-ppt-html/scripts/model-schema.json +1 -0
- package/top-ppt-html/scripts/render_from_model.py +133 -17
- package/top-ppt-html/scripts/sync_runtime.py +19 -3
- package/top-ppt-html/scripts/test_feedback_gates.py +73 -0
- package/top-ppt-html/scripts/validate_pptx.py +148 -0
- 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"
|
|
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"
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|