@topmindspace/tms-skills 0.1.6 → 0.1.8

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 (34) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +48 -69
  3. package/package.json +1 -1
  4. package/top-ppt-html/README.md +25 -6
  5. package/top-ppt-html/SKILL.md +6 -6
  6. package/top-ppt-html/agents/openai.yaml +1 -1
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +10 -5
  8. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +10 -5
  9. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +10 -5
  10. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +4016 -0
  11. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +315 -0
  12. package/top-ppt-html/assets/pptx-export.js +9 -4
  13. package/top-ppt-html/assets/showcase/showcase-cover.png +0 -0
  14. package/top-ppt-html/assets/showcase/showcase-gates.png +0 -0
  15. package/top-ppt-html/assets/showcase/showcase-modes.png +0 -0
  16. package/top-ppt-html/assets/showcase/showcase-split.png +0 -0
  17. package/top-ppt-html/assets/showcase/style-business-blue-cover.png +0 -0
  18. package/top-ppt-html/assets/showcase/style-graphite-cover.png +0 -0
  19. package/top-ppt-html/assets/showcase/style-mckinsey-cover.png +0 -0
  20. package/top-ppt-html/assets/templates/architecture.html +10 -5
  21. package/top-ppt-html/assets/templates/presentation.html +10 -5
  22. package/top-ppt-html/assets/templates/research.html +10 -5
  23. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  24. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  25. package/top-ppt-html/assets/theme-overview.png +0 -0
  26. package/top-ppt-html/package-lock.json +2 -2
  27. package/top-ppt-html/package.json +1 -1
  28. package/top-ppt-html/references/playbook.md +1 -1
  29. package/top-ppt-html/scripts/build_examples.py +4 -3
  30. package/top-ppt-html/scripts/build_pptx.js +12 -5
  31. package/top-ppt-html/scripts/package_skill.py +4 -3
  32. package/top-ppt-html/scripts/smoke_pptx.sh +1 -0
  33. package/top-ppt-html/scripts/test_feedback_gates.py +96 -0
  34. package/top-ppt-html/scripts/validate_pptx.py +60 -37
@@ -209,6 +209,98 @@ def test_font_size_not_snapped() -> None:
209
209
  )
210
210
 
211
211
 
212
+ def test_annotation_band_reports_all_invaders() -> None:
213
+ """D9: do not break after the first ANNOTATION_BAND_OVERLAP on a slide."""
214
+ def sp(y, h, w, text):
215
+ return ET.fromstring(
216
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
217
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
218
+ "<p:spPr><a:xfrm>"
219
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
220
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
221
+ "</a:xfrm></p:spPr>"
222
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
223
+ "</p:sp>"
224
+ )
225
+ els = [
226
+ sp(5.0, 1.2, 4.0, "PlotSeriesA"),
227
+ sp(5.2, 1.1, 4.0, "PlotSeriesB"),
228
+ sp(6.05, 0.55, 12.0, "SO WHAT locked path"),
229
+ ]
230
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
231
+ codes = [i["code"] for i in issues]
232
+ ok(
233
+ "3e D9 reports all invaders (not just first)",
234
+ codes.count("ANNOTATION_BAND_OVERLAP") >= 2,
235
+ f"codes={codes}",
236
+ )
237
+
238
+
239
+ def test_annotation_band_so_what_band_top() -> None:
240
+ """D11: with so-what present, invasions in (6.05, 6.40] must fire."""
241
+ def sp(y, h, w, text):
242
+ return ET.fromstring(
243
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
244
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
245
+ "<p:spPr><a:xfrm>"
246
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
247
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
248
+ "</a:xfrm></p:spPr>"
249
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
250
+ "</p:sp>"
251
+ )
252
+ els = [
253
+ # Body ends at 6.30 — inside (soWhatY=6.05, contentBottomWithNote=6.40]
254
+ sp(5.0, 1.3, 4.0, "LegendCrushMid"),
255
+ sp(6.05, 0.55, 12.0, "SO WHAT platform first"),
256
+ ]
257
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
258
+ codes = [i["code"] for i in issues]
259
+ msgs = " ".join(i.get("message") or "" for i in issues)
260
+ ok(
261
+ "3f D11 band_top=soWhatY catches invasion in (6.05, 6.40]",
262
+ "ANNOTATION_BAND_OVERLAP" in codes and "6.05" in msgs,
263
+ f"codes={codes} msgs={msgs[:120]}",
264
+ )
265
+
266
+
267
+ def test_chrome_footer_ignores_bare_integers() -> None:
268
+ """CHROME_DRIFT FP: bare year/exhibit numbers must not count as pager."""
269
+ def sp(y, text):
270
+ return ET.fromstring(
271
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
272
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
273
+ "<p:spPr><a:xfrm>"
274
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
275
+ f'<a:ext cx="{int(1.0 * EMU)}" cy="{int(0.3 * EMU)}"/>'
276
+ "</a:xfrm></p:spPr>"
277
+ f'<p:txBody><a:p><a:r><a:rPr sz="1100"/><a:t>{text}</a:t></a:r></a:p></p:txBody>'
278
+ "</p:sp>"
279
+ )
280
+ shapes = [sp(6.14, "2024"), sp(6.14, "06"), sp(7.0, "3 / 14")]
281
+ y = V.chrome_footer_y_in(shapes, int(7.5 * EMU))
282
+ ok(
283
+ "5 chrome_footer_y_in uses N / M only (ignores bare ints)",
284
+ y is not None and abs(y - 7.0) < 0.05,
285
+ f"got {y}",
286
+ )
287
+
288
+
289
+ def test_chart_bottom_no_short_circuit() -> None:
290
+ """D8: chartBottom must Math.min soWhat/footnote — no footnote-first return."""
291
+ build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
292
+ m = re.search(r"function chartBottom\([\s\S]*?\n\}", build)
293
+ body = m.group(0) if m else ""
294
+ ok(
295
+ "D8 chartBottom uses Math.min (no footnote short-circuit)",
296
+ "Math.min" in body
297
+ and "hasFootnote) return" not in body
298
+ and "CONTENT_BOTTOM_NOTE" in body,
299
+ f"body={body[:200]!r}",
300
+ )
301
+
302
+
303
+
212
304
  def test_engine_static() -> None:
213
305
  export = (ROOT.parent / "assets" / "pptx-export.js").read_text(encoding="utf-8")
214
306
  build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
@@ -262,6 +354,10 @@ def main() -> int:
262
354
  test_annotation_band_ignores_full_bleed_bg()
263
355
  test_annotation_band_allows_content_without_note()
264
356
  test_annotation_band_fires_when_note_present()
357
+ test_annotation_band_reports_all_invaders()
358
+ test_annotation_band_so_what_band_top()
359
+ test_chrome_footer_ignores_bare_integers()
360
+ test_chart_bottom_no_short_circuit()
265
361
  test_font_size_h2_17_allowed()
266
362
  test_font_size_not_snapped()
267
363
  test_engine_static()
@@ -362,18 +362,20 @@ def annotation_band_overlap_check(
362
362
  soWhatY ≈ 6.05 · contentBottomWithNote ≈ 6.4 · footnote/note ≈ 6.55 ·
363
363
  contentBottom ≈ 6.9 · pager ≈ 7.0
364
364
 
365
- Semantics:
365
+ Semantics (0.1.7 · D9/D11):
366
366
  - Full-bleed backgrounds, full-height accent strips, and pager/footer
367
367
  chrome are never "content crushing the band".
368
368
  - so-what / 口径 / 来源 bars that live in the annotation zone are the
369
369
  band itself — not invaders.
370
- - When the slide actually uses an annotation (so-what/source), body
371
- content that starts above the band and overlaps
372
- [contentBottomWithNote, contentBottom] fires ANNOTATION_BAND_OVERLAP
373
- (streamgraph legend / plot crush cases).
370
+ - Crush = starts ABOVE band_top and overlaps down into the band.
371
+ Shapes whose top is already at/below band_top are in-band chrome.
372
+ - When so-what is present, band_top = soWhatY so invasions in
373
+ (soWhatY, contentBottomWithNote] are reported (D11). Footnote/source
374
+ only keeps band_top = contentBottomWithNote.
374
375
  - When no annotation is present, main content may use up to
375
376
  contentBottom; only a severe invasion (≥0.35in into the nominal
376
377
  withNote band) still fires as a safety net.
378
+ - Report ALL invaders per slide (D9) — do not break after the first.
377
379
  """
378
380
  out: list[dict[str, Any]] = []
379
381
  try:
@@ -382,14 +384,16 @@ def annotation_band_overlap_check(
382
384
  lay = ((lc.get("pageTypes") or {}).get("layout") or {})
383
385
  exhibit = ((lc.get("pageTypes") or {}).get("exhibit") or {})
384
386
  note = ((lc.get("pageTypes") or {}).get("note") or {})
385
- band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
387
+ nominal_band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
386
388
  band_bot_in = float(lay.get("contentBottom") or 6.9)
387
389
  so_what_y_in = float(exhibit.get("soWhatY") or 6.05)
388
390
  note_y_in = float(note.get("y") or exhibit.get("footnoteY") or 6.55)
389
391
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
390
- band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
392
+ nominal_band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
391
393
 
394
+ band_top_in = nominal_band_top_in
392
395
  band_top = int(band_top_in * 914400)
396
+ nominal_band_top = int(nominal_band_top_in * 914400)
393
397
  band_bot = int(band_bot_in * 914400)
394
398
  so_what_y = int(so_what_y_in * 914400)
395
399
  note_y = int(note_y_in * 914400)
@@ -397,12 +401,14 @@ def annotation_band_overlap_check(
397
401
  slide_h = int(slide_height_emu)
398
402
  slide_area = max(slide_w * slide_h, 1)
399
403
  pager_floor = int(min(band_bot_in + 0.05, slide_h / 914400.0 - 0.05) * 914400)
400
- min_overlap = int(0.10 * 914400) # 0.10in — ignore sub-tenth rounding kiss
401
- severe_overlap = int(0.35 * 914400) # safety net when no annotation detected
404
+ min_overlap = int(0.10 * 914400)
405
+ severe_overlap = int(0.35 * 914400)
402
406
  full_bleed_area = 0.85
403
407
  full_height_frac = 0.90
408
+ # Label-like prefixes only — body text containing「来源」must not count (agenda FP)
404
409
  ann_pat = re.compile(
405
- r"(结论|SO\s*WHAT|口径|来源|数据来源|Source\b|Note\b|footnote)",
410
+ r"(?:^|\n)\s*(结论|SO\s*WHAT|口径\s*[::]?|来源\s*[::]|数据来源\s*[::]|"
411
+ r"Source\s*[::]|Note\s*[::]|footnote|注\s*[::])",
406
412
  re.IGNORECASE,
407
413
  )
408
414
 
@@ -420,23 +426,31 @@ def annotation_band_overlap_check(
420
426
  return True
421
427
  return False
422
428
 
429
+ def _is_bar_like(cy: int, cx: int) -> bool:
430
+ return cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w)
431
+
432
+ def _is_note_like(y: int, cy: int, text: str) -> bool:
433
+ return y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400) and bool(text)
434
+
435
+ def _at_so_what_slot(y: int) -> bool:
436
+ # Engine locks so-what to soWhatY; agenda trailing rows sit lower (~+0.12)
437
+ return abs(y - so_what_y) <= int(0.08 * 914400)
438
+
423
439
  def _is_annotation_self(y: int, cy: int, cx: int, text: str) -> bool:
424
440
  """so-what / footnote / source row living in the annotation zone."""
425
- if y >= so_what_y - int(0.08 * 914400) and (y + cy) <= band_bot + int(0.2 * 914400):
426
- if ann_pat.search(text):
441
+ if _at_so_what_slot(y) and cy <= int(0.85 * 914400):
442
+ # Fill rect, accent strip, or SO WHAT text — all live in the slot
443
+ if ann_pat.search(text) or _is_bar_like(cy, cx) or cx <= int(0.15 * 914400):
427
444
  return True
428
- # Wide short bar in so-what band (bar fill behind 结论 text)
429
- if cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w):
430
- return True
431
- if y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400):
445
+ if _is_note_like(y, cy, text):
432
446
  return True
433
- # Entirely inside the nominal withNote band
434
- if y >= band_top - int(0.02 * 914400):
447
+ # Entirely inside the nominal withNote band (6.40 口径)
448
+ if y >= nominal_band_top - int(0.02 * 914400):
435
449
  return True
436
450
  return False
437
451
 
438
- # Pass 1: does this slide reserve an annotation?
439
452
  has_annotation = False
453
+ has_so_what = False
440
454
  for el in elements:
441
455
  box = shape_bounds(el)
442
456
  if box is None:
@@ -446,19 +460,24 @@ def annotation_band_overlap_check(
446
460
  continue
447
461
  if _is_chrome_or_bg(y, cy, cx):
448
462
  continue
449
- if _is_annotation_self(y, cy, cx, _text(el)):
450
- # Only count as "has annotation" when it looks like so-what/source,
451
- # not merely any shape whose top is inside the band.
452
- txt = _text(el)
453
- if (
454
- ann_pat.search(txt)
455
- or (y >= so_what_y - int(0.08 * 914400) and cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w))
456
- or (y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400) and txt)
457
- ):
458
- has_annotation = True
459
- break
460
-
461
- # Pass 2: body invaders
463
+ txt = _text(el)
464
+ note_like = _is_note_like(y, cy, txt)
465
+ labeled = bool(ann_pat.search(txt))
466
+ bar = _is_bar_like(cy, cx)
467
+ # Fill rect sits on soWhatY (±0.02); labeled SO WHAT text may sit slightly below
468
+ on_fill = abs(y - so_what_y) <= int(0.02 * 914400) and bar
469
+ labeled_slot = labeled and _at_so_what_slot(y) and cy <= int(0.85 * 914400)
470
+ if note_like:
471
+ has_annotation = True
472
+ elif labeled_slot or on_fill:
473
+ has_annotation = True
474
+ has_so_what = True
475
+
476
+ if has_so_what:
477
+ band_top_in = min(nominal_band_top_in, so_what_y_in)
478
+ band_top = int(band_top_in * 914400)
479
+
480
+ # Pass 2: body invaders that START ABOVE the band and crush down (D9: all of them)
462
481
  for el in elements:
463
482
  box = shape_bounds(el)
464
483
  if box is None:
@@ -471,10 +490,14 @@ def annotation_band_overlap_check(
471
490
  continue
472
491
  if _is_annotation_self(y, cy, cx, _text(el)):
473
492
  continue
474
- overlap = min(bottom, band_bot) - max(y, band_top)
475
- if overlap < min_overlap or bottom <= band_top:
493
+ # Crush-from-above only: top must sit above band_top
494
+ if y >= band_top - int(0.02 * 914400):
495
+ continue
496
+ if bottom <= band_top:
497
+ continue
498
+ overlap = min(bottom, band_bot) - band_top
499
+ if overlap < min_overlap:
476
500
  continue
477
- # Enforce when annotation present, or when invasion is severe anyway
478
501
  if has_annotation or overlap >= severe_overlap:
479
502
  out.append(issue(
480
503
  "ANNOTATION_BAND_OVERLAP",
@@ -483,7 +506,6 @@ def annotation_band_overlap_check(
483
506
  "图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
484
507
  slide=slide_no,
485
508
  ))
486
- break
487
509
  return out
488
510
 
489
511
 
@@ -795,7 +817,8 @@ def chrome_footer_y_in(shapes: list[ET.Element], height: int) -> float | None:
795
817
  bottom_frac = float(chrome[1] if isinstance(chrome, list) and len(chrome) > 1 else 24) / 100.0
796
818
  threshold = int(height * (1.0 - bottom_frac))
797
819
  ys: list[float] = []
798
- pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$|^\s*\d+\s*$")
820
+ # Engine footer is always "N / M"; bare integers are chart/exhibit labels (CHROME_DRIFT FP)
821
+ pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$")
799
822
  for shape in shapes:
800
823
  text = (text_content(shape) or "").strip()
801
824
  box = shape_bounds(shape)