@topmindspace/tms-skills 0.1.5 → 0.1.6

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 CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.1.6 — 2026-09-25
2
+
3
+ ### CI / Release 硬化
4
+ - **根因 A**:`validate_pptx` 的 `ANNOTATION_BAND_OVERLAP` 把全幅背景(底边 7.50in)误判为侵入注释带;改为排除 full-bleed 背景 / 全高装饰条 / chrome 区,同时保留对图例/主图压进 so-what 带的真阳性。
5
+ - **根因 A′**:`FONT_SIZE_NOT_SNAPPED` 未收录 `modeTypeScale` 的 **h2=17pt**;改为从 typeScale ∪ 全模式 modeTypeScale ∪ ladder 动态取允许集。
6
+ - **冒烟可读性**:`smoke_pptx.sh` 可靠传播 validate 退出码;失败时 stderr 先打错误码/页码短摘要。
7
+ - **根因 B**:Release `npm publish` 遇已发布/已 staged 的 **E409** 时改为 exit 0(幂等);GitHub Release + 资产仍成功。
8
+ - **DRY**:`scripts/ci_skill_gates.sh` 为 CI/Release 共用门禁;Release 对齐 CI 的 fixture + `smoke_pptx`;CI concurrency cancel-in-progress;npm cache;python-pptx 仅 PPTX 步骤安装。
9
+ - 文档:新增 `docs/ci.md`;版本对齐 **0.1.6**。
10
+
11
+ ### 红线未动
12
+ 反截断、图表多样性地板、Mode A craft、runtime SHA 同版本门禁保持。
13
+
1
14
  ## 0.1.5 — 2026-09-25
2
15
 
3
16
  ### top-ppt-html · defect close-out
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  | 技能 | 版本 | 做什么 |
10
10
  |------|------|--------|
11
- | [`top-ppt-html`](./top-ppt-html/) | 0.1.5 | 优雅大气的正式场合演讲/汇报演示文稿:单文件 HTML + 版式保真可编辑 16:9 PPTX |
11
+ | [`top-ppt-html`](./top-ppt-html/) | 0.1.6 | 优雅大气的正式场合演讲/汇报演示文稿:单文件 HTML + 版式保真可编辑 16:9 PPTX |
12
12
 
13
- 安装器包 [`@topmindspace/tms-skills`](https://www.npmjs.com/package/@topmindspace/tms-skills) 亦为 **0.1.5**(整仓发版:安装器与当前技能同 tag;未来多技能仍可各自演进,默认随仓库 release)。
13
+ 安装器包 [`@topmindspace/tms-skills`](https://www.npmjs.com/package/@topmindspace/tms-skills) 亦为 **0.1.6**(整仓发版:安装器与当前技能同 tag;未来多技能仍可各自演进,默认随仓库 release)。
14
14
 
15
15
  更多技能以仓库根目录下的技能目录加入(每个目录含 `SKILL.md`)。
16
16
 
@@ -30,7 +30,7 @@
30
30
  npx @topmindspace/tms-skills list
31
31
  npx @topmindspace/tms-skills install top-ppt-html
32
32
  npx @topmindspace/tms-skills install top-ppt-html --to ./.claude/skills
33
- npx @topmindspace/tms-skills@0.1.5 install top-ppt-html # 钉版本
33
+ npx @topmindspace/tms-skills@0.1.6 install top-ppt-html # 钉版本
34
34
  ```
35
35
 
36
36
  ### GitHub 直装
@@ -75,7 +75,7 @@ npm install
75
75
  tms-skills/
76
76
  ├─ top-ppt-html/ # 技能(SKILL.md + assets + references + scripts + evals)
77
77
  ├─ bin/tms-skills.js # CLI:list / install
78
- ├─ docs/ # 发布规范
78
+ ├─ docs/ # 发布规范 · CI/Release 说明(ci.md)
79
79
  ├─ scripts/ # 仓库级工具(隐私扫描)
80
80
  ├─ .github/workflows/ # CI + Release
81
81
  ├─ package.json # @topmindspace/tms-skills
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topmindspace/tms-skills",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "TopMindspace 智能体技能 monorepo — 通过 npm / GitHub npx 安装技能(top-ppt-html:优雅大气正式场合演讲/汇报演示文稿等)。",
5
5
  "license": "MIT",
6
6
  "author": "TopMindspace",
@@ -3,7 +3,7 @@
3
3
  **让 idea 飞,好想法被看见。** 面向**演讲与正式场合**的高品质演示文稿技能:把主张、调研、分析、架构方案,生成为**优雅、美观、大气**的可翻页 HTML + **版式保真可编辑 PPTX**。核心工艺是版式、排版、色彩与内容组织——克制图标/图表配文,而非 gadget 堆砌或咨询 dump 默认。
4
4
 
5
5
  - 技能标识:`top-ppt-html`(安装/打包目录名与此一致);品牌名:**TopPPT HTML**
6
- - 版本:**v0.1.5**(与 `@topmindspace/tms-skills@0.1.5` 同 tag)
6
+ - 版本:**v0.1.6**(与 `@topmindspace/tms-skills@0.1.6` 同 tag)
7
7
  - **智能体入口**:`SKILL.md` → `references/playbook.md`(L1)→ L2 按需
8
8
  - **人类维护者**:本 README(安装 / 命令 / 目录);勿把本文件当生成规范
9
9
 
@@ -4,7 +4,7 @@ description: "Use when 用户要做报告、演示、汇报、PPT、slides、dec
4
4
  license: MIT
5
5
  compatibility: "Python 3 stdlib for HTML generation; Node >=18 + pptxgenjs for PPTX; optional playwright for browser regression / theme captures."
6
6
  metadata:
7
- version: "0.1.5"
7
+ version: "0.1.6"
8
8
  author: TopMindspace
9
9
  ---
10
10
  # TopPPT HTML
@@ -121,7 +121,7 @@ metadata:
121
121
 
122
122
  ## 版本口径
123
123
 
124
- 包 semver(现 0.1.5)≠ 布局 schema 线(layout-constants / model-schema 的 version,现 `0.1`);patch 不抬 schema。
124
+ 包 semver(现 0.1.6)≠ 布局 schema 线(layout-constants / model-schema 的 version,现 `0.1`);patch 不抬 schema。
125
125
 
126
126
  ## 环境依赖
127
127
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "top-ppt-html",
9
- "version": "0.1.5",
9
+ "version": "0.1.6",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "pptxgenjs": "^4.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": true,
5
5
  "description": "TopPPT HTML:优雅大气的正式场合演讲/汇报演示文稿。单文件 HTML + 版式保真可编辑 PPTX;布局骨架 P1–P12;模型单写;strict 0/0 交付。",
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@
2
2
  # TopPPT HTML · PPTX 轻量冒烟(Batch 2)
3
3
  # extract_model → build_pptx → validate_pptx --strict --model=
4
4
  # 用法: bash scripts/smoke_pptx.sh [example.html]
5
+ # 失败时打印短摘要(错误码/页码/消息),完整 JSON 落盘到 TMPDIR。
5
6
  set -euo pipefail
6
7
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
8
  cd "$ROOT"
@@ -15,6 +16,7 @@ OUT_DIR="${TMPDIR:-/tmp}/top-ppt-html-smoke"
15
16
  mkdir -p "$OUT_DIR"
16
17
  MODEL="$OUT_DIR/${STEM}.model.json"
17
18
  PPTX="$OUT_DIR/${STEM}.pptx"
19
+ REPORT="$OUT_DIR/${STEM}.validate.json"
18
20
 
19
21
  echo "== smoke_pptx =="
20
22
  echo "HTML $HTML"
@@ -26,5 +28,42 @@ export NODE_PATH="${ROOT}/node_modules${NODE_PATH:+:$NODE_PATH}"
26
28
  node scripts/build_pptx.js "$PPTX" --model="$MODEL"
27
29
  echo "PPTX $PPTX ($(wc -c < "$PPTX") bytes)"
28
30
 
29
- python3 scripts/validate_pptx.py "$PPTX" --strict --model="$MODEL"
30
- echo "smoke_pptx: OK"
31
+ set +e
32
+ python3 scripts/validate_pptx.py "$PPTX" --strict --model="$MODEL" \
33
+ --json-out="$REPORT" >"$OUT_DIR/${STEM}.validate.stdout.json"
34
+ rc=$?
35
+ set -e
36
+
37
+ if [[ "$rc" -ne 0 ]]; then
38
+ echo "smoke_pptx: VALIDATE FAIL (exit $rc)" >&2
39
+ python3 - "$REPORT" "$rc" <<'PY'
40
+ import json, sys
41
+ path, rc = sys.argv[1], sys.argv[2]
42
+ try:
43
+ d = json.loads(open(path, encoding="utf-8").read())
44
+ except Exception as e:
45
+ print(f" (could not read report {path}: {e})", file=sys.stderr)
46
+ sys.exit(int(rc))
47
+ errs = d.get("errors") or []
48
+ warns = d.get("warnings") or []
49
+ print(f" summary: errors={len(errs)} warnings={len(warns)} slides={d.get('summary',{}).get('slide_count')}", file=sys.stderr)
50
+ seen = set()
51
+ for e in errs + warns:
52
+ key = (e.get("code"), e.get("slide"), e.get("message"))
53
+ if key in seen:
54
+ continue
55
+ seen.add(key)
56
+ slide = e.get("slide")
57
+ slide_s = f"slide={slide} " if slide is not None else ""
58
+ msg = (e.get("message") or "")[:160]
59
+ print(f" [{e.get('code')}] {slide_s}{msg}", file=sys.stderr)
60
+ if len(seen) >= 40:
61
+ print(f" … truncated ({len(errs)+len(warns)} total findings)", file=sys.stderr)
62
+ break
63
+ print(f" full JSON: {path}", file=sys.stderr)
64
+ sys.exit(int(rc))
65
+ PY
66
+ fi
67
+
68
+ echo "smoke_pptx: OK (0 errors / 0 warnings)"
69
+ exit 0
@@ -102,6 +102,95 @@ def test_annotation_band_overlap() -> None:
102
102
  )
103
103
 
104
104
 
105
+ def test_annotation_band_ignores_full_bleed_bg() -> None:
106
+ """Full-slide background must not false-positive as band crush."""
107
+ xml = (
108
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
109
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
110
+ "<p:spPr><a:xfrm>"
111
+ f'<a:off x="0" y="0"/>'
112
+ f'<a:ext cx="{int(13.333 * EMU)}" cy="{int(7.5 * EMU)}"/>'
113
+ "</a:xfrm></p:spPr></p:sp>"
114
+ )
115
+ el = ET.fromstring(xml)
116
+ issues = V.annotation_band_overlap_check(
117
+ [el], 1, int(7.5 * EMU), int(13.333 * EMU),
118
+ )
119
+ codes = [i["code"] for i in issues]
120
+ ok(
121
+ "3b full-bleed background does not fire ANNOTATION_BAND_OVERLAP",
122
+ "ANNOTATION_BAND_OVERLAP" not in codes,
123
+ f"codes={codes}",
124
+ )
125
+
126
+
127
+ def test_annotation_band_allows_content_without_note() -> None:
128
+ """Without so-what/source, content may use up to contentBottom (~6.9)."""
129
+ xml = (
130
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
131
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
132
+ "<p:spPr><a:xfrm>"
133
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(5.5 * EMU)}"/>'
134
+ f'<a:ext cx="{int(4 * EMU)}" cy="{int(1.0 * EMU)}"/>'
135
+ "</a:xfrm></p:spPr>"
136
+ "<p:txBody><a:p><a:r><a:t>AgendaCard</a:t></a:r></a:p></p:txBody>"
137
+ "</p:sp>"
138
+ )
139
+ el = ET.fromstring(xml)
140
+ issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
141
+ codes = [i["code"] for i in issues]
142
+ ok(
143
+ "3c content to 6.50 without annotation does not fire",
144
+ "ANNOTATION_BAND_OVERLAP" not in codes,
145
+ f"codes={codes}",
146
+ )
147
+
148
+
149
+ def test_annotation_band_fires_when_note_present() -> None:
150
+ """With so-what bar present, body overlapping the withNote band must fire."""
151
+ def sp(y, h, w, text):
152
+ return ET.fromstring(
153
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
154
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
155
+ "<p:spPr><a:xfrm>"
156
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
157
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
158
+ "</a:xfrm></p:spPr>"
159
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
160
+ "</p:sp>"
161
+ )
162
+ els = [
163
+ sp(5.5, 1.0, 4.0, "LegendSeries"),
164
+ sp(6.05, 0.55, 12.0, "结论 平台化是唯一路径"),
165
+ ]
166
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
167
+ codes = [i["code"] for i in issues]
168
+ ok(
169
+ "3d body crush with so-what present fires ANNOTATION_BAND_OVERLAP",
170
+ "ANNOTATION_BAND_OVERLAP" in codes,
171
+ f"codes={codes}",
172
+ )
173
+
174
+
175
+ def test_font_size_h2_17_allowed() -> None:
176
+ """modeTypeScale h2=17 must be on the allowed set (cover/display whitelist)."""
177
+ xml = (
178
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
179
+ f'<p:sld xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
180
+ "<p:cSld><p:spTree><p:sp><p:txBody>"
181
+ '<a:p><a:r><a:rPr sz="1700"/><a:t>AgendaSectionTitle</a:t></a:r></a:p>'
182
+ "</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
183
+ )
184
+ root = ET.fromstring(xml)
185
+ issues = V.font_size_snap_check(root, 1)
186
+ codes = [i["code"] for i in issues]
187
+ ok(
188
+ "4b FONT_SIZE_NOT_SNAPPED allows intentional 17pt h2",
189
+ "FONT_SIZE_NOT_SNAPPED" not in codes,
190
+ f"codes={codes}",
191
+ )
192
+
193
+
105
194
  def test_font_size_not_snapped() -> None:
106
195
  xml = (
107
196
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
@@ -170,6 +259,10 @@ def main() -> int:
170
259
  test_shape_bounds_still_reads_a_xfrm()
171
260
  test_text_overflow_vertical()
172
261
  test_annotation_band_overlap()
262
+ test_annotation_band_ignores_full_bleed_bg()
263
+ test_annotation_band_allows_content_without_note()
264
+ test_annotation_band_fires_when_note_present()
265
+ test_font_size_h2_17_allowed()
173
266
  test_font_size_not_snapped()
174
267
  test_engine_static()
175
268
  print(f"\n{len(fails)} failed" if fails else "\nAll feedback gates PASS")
@@ -354,27 +354,111 @@ def annotation_band_overlap_check(
354
354
  elements: list[ET.Element],
355
355
  slide_no: int,
356
356
  slide_height_emu: int,
357
+ slide_width_emu: int | None = None,
357
358
  ) -> list[dict[str, Any]]:
358
359
  """Detect primary content crushing into the annotation band.
359
360
 
360
- Annotation band ≈ [contentBottomWithNote, contentBottom] (default 6.4–6.9in).
361
- Shapes that start in the body and extend into the band (e.g. streamgraph
362
- legends stacked below the plot) fire ANNOTATION_BAND_OVERLAP.
361
+ Geometry (layout-constants pageTypes.layout / exhibit / note):
362
+ soWhatY ≈ 6.05 · contentBottomWithNote ≈ 6.4 · footnote/note ≈ 6.55 ·
363
+ contentBottom ≈ 6.9 · pager ≈ 7.0
364
+
365
+ Semantics:
366
+ - Full-bleed backgrounds, full-height accent strips, and pager/footer
367
+ chrome are never "content crushing the band".
368
+ - so-what / 口径 / 来源 bars that live in the annotation zone are the
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).
374
+ - When no annotation is present, main content may use up to
375
+ contentBottom; only a severe invasion (≥0.35in into the nominal
376
+ withNote band) still fires as a safety net.
363
377
  """
364
378
  out: list[dict[str, Any]] = []
365
379
  try:
366
380
  lc_path = Path(__file__).resolve().parent / "layout-constants.json"
367
381
  lc = json.loads(lc_path.read_text(encoding="utf-8"))
368
382
  lay = ((lc.get("pageTypes") or {}).get("layout") or {})
383
+ exhibit = ((lc.get("pageTypes") or {}).get("exhibit") or {})
384
+ note = ((lc.get("pageTypes") or {}).get("note") or {})
369
385
  band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
370
386
  band_bot_in = float(lay.get("contentBottom") or 6.9)
387
+ so_what_y_in = float(exhibit.get("soWhatY") or 6.05)
388
+ note_y_in = float(note.get("y") or exhibit.get("footnoteY") or 6.55)
371
389
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
372
- band_top_in, band_bot_in = 6.4, 6.9
390
+ band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
391
+
373
392
  band_top = int(band_top_in * 914400)
374
393
  band_bot = int(band_bot_in * 914400)
375
- # Ignore footer/page-number chrome near the very bottom
376
- pager_floor = int(min(band_bot_in + 0.05, slide_height_emu / 914400.0 - 0.05) * 914400)
377
- min_overlap = int(0.08 * 914400) # 0.08in
394
+ so_what_y = int(so_what_y_in * 914400)
395
+ note_y = int(note_y_in * 914400)
396
+ slide_w = int(slide_width_emu) if slide_width_emu else int(13.333333 * 914400)
397
+ slide_h = int(slide_height_emu)
398
+ slide_area = max(slide_w * slide_h, 1)
399
+ 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
402
+ full_bleed_area = 0.85
403
+ full_height_frac = 0.90
404
+ ann_pat = re.compile(
405
+ r"(结论|SO\s*WHAT|口径|来源|数据来源|Source\b|Note\b|footnote)",
406
+ re.IGNORECASE,
407
+ )
408
+
409
+ def _text(el: ET.Element) -> str:
410
+ return (text_content(el) or "").strip()
411
+
412
+ def _is_chrome_or_bg(y: int, cy: int, cx: int) -> bool:
413
+ if (cx * cy) / slide_area >= full_bleed_area and y <= int(0.05 * 914400):
414
+ return True
415
+ if cy >= int(full_height_frac * slide_h) and y <= int(0.05 * 914400):
416
+ return True
417
+ if y >= band_bot - int(0.02 * 914400):
418
+ return True
419
+ if y >= pager_floor:
420
+ return True
421
+ return False
422
+
423
+ def _is_annotation_self(y: int, cy: int, cx: int, text: str) -> bool:
424
+ """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):
427
+ 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):
432
+ return True
433
+ # Entirely inside the nominal withNote band
434
+ if y >= band_top - int(0.02 * 914400):
435
+ return True
436
+ return False
437
+
438
+ # Pass 1: does this slide reserve an annotation?
439
+ has_annotation = False
440
+ for el in elements:
441
+ box = shape_bounds(el)
442
+ if box is None:
443
+ continue
444
+ _x, y, cx, cy = box
445
+ if cx <= 0 or cy <= 0:
446
+ continue
447
+ if _is_chrome_or_bg(y, cy, cx):
448
+ 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
378
462
  for el in elements:
379
463
  box = shape_bounds(el)
380
464
  if box is None:
@@ -383,14 +467,15 @@ def annotation_band_overlap_check(
383
467
  if cx <= 0 or cy <= 0:
384
468
  continue
385
469
  bottom = y + cy
386
- # Legitimate annotation/note content sits entirely inside the band
387
- if y >= band_top - int(0.02 * 914400):
470
+ if _is_chrome_or_bg(y, cy, cx):
388
471
  continue
389
- # Page chrome (pager) — tiny shapes near bottom edge
390
- if y >= pager_floor:
472
+ if _is_annotation_self(y, cy, cx, _text(el)):
391
473
  continue
392
474
  overlap = min(bottom, band_bot) - max(y, band_top)
393
- if overlap >= min_overlap and bottom > band_top:
475
+ if overlap < min_overlap or bottom <= band_top:
476
+ continue
477
+ # Enforce when annotation present, or when invasion is severe anyway
478
+ if has_annotation or overlap >= severe_overlap:
394
479
  out.append(issue(
395
480
  "ANNOTATION_BAND_OVERLAP",
396
481
  f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
@@ -398,17 +483,21 @@ def annotation_band_overlap_check(
398
483
  "图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
399
484
  slide=slide_no,
400
485
  ))
401
- # One finding per slide is enough to gate
402
486
  break
403
487
  return out
404
488
 
405
489
 
490
+
406
491
  def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]]:
407
- """Font sizes must sit on the fontShrink ladder (0.5pt snap grid).
492
+ """Font sizes must sit on the declared type scale / fontShrink ladder.
408
493
 
409
494
  fitFont already selects from the ladder; this gate catches callers that
410
495
  arithmetic-shift sizes (e.g. fz-1) or hardcode off-ladder values without
411
496
  re-snapping through the ladder / modeSize path.
497
+
498
+ Allowed set = containers.fontShrink.ladder ∪ typeScale ∪ all modeTypeScale
499
+ roles ∪ a small cover/hero display whitelist. Intentional h2 (17pt) and
500
+ other mode roles must pass; off-ladder 11.3pt etc. must still fail.
412
501
  """
413
502
  out: list[dict[str, Any]] = []
414
503
  try:
@@ -416,10 +505,25 @@ def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]
416
505
  lc = json.loads(lc_path.read_text(encoding="utf-8"))
417
506
  ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
418
507
  allowed = {round(float(v), 2) for v in ladder}
508
+ scale_sources: list[Any] = [lc.get("typeScale") or {}]
509
+ mts = lc.get("modeTypeScale") or {}
510
+ if isinstance(mts, dict):
511
+ scale_sources.extend(v for v in mts.values() if isinstance(v, dict))
512
+ for src in scale_sources:
513
+ if not isinstance(src, dict):
514
+ continue
515
+ for key, val in src.items():
516
+ if str(key).startswith("$"):
517
+ continue
518
+ try:
519
+ allowed.add(round(float(val), 2))
520
+ except (TypeError, ValueError):
521
+ continue
419
522
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
420
523
  allowed = {15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5}
421
- # Also allow common display sizes used by cover/hero (not on shrink ladder)
422
- allowed |= {44, 36, 30, 28, 24, 22, 20, 18, 16, 8.0, 7.5}
524
+ allowed |= {44, 36, 30, 22, 19, 18, 17, 14, 13, 11, 10, 8.5}
525
+ # Cover/hero intermediate display sizes (not every mode lists every step)
526
+ allowed |= {28, 24, 22, 20, 16, 8.0, 7.5}
423
527
  alien: list[float] = []
424
528
  for run in root.findall(".//a:r", NS):
425
529
  text = "".join(t.text or "" for t in run.findall("a:t", NS))
@@ -784,7 +888,7 @@ def inspect_slide(
784
888
  warnings.extend(container_overflow_check(shape, slide_number))
785
889
 
786
890
  warnings.extend(annotation_band_overlap_check(
787
- [*shapes, *pictures, *graphic_frames], slide_number, height))
891
+ [*shapes, *pictures, *graphic_frames], slide_number, height, width))
788
892
  warnings.extend(font_size_snap_check(root, slide_number))
789
893
 
790
894
  for table in tables: