@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 +13 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/top-ppt-html/README.md +1 -1
- package/top-ppt-html/SKILL.md +2 -2
- package/top-ppt-html/package-lock.json +2 -2
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/scripts/smoke_pptx.sh +41 -2
- package/top-ppt-html/scripts/test_feedback_gates.py +93 -0
- package/top-ppt-html/scripts/validate_pptx.py +121 -17
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.
|
|
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.
|
|
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.
|
|
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
package/top-ppt-html/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**让 idea 飞,好想法被看见。** 面向**演讲与正式场合**的高品质演示文稿技能:把主张、调研、分析、架构方案,生成为**优雅、美观、大气**的可翻页 HTML + **版式保真可编辑 PPTX**。核心工艺是版式、排版、色彩与内容组织——克制图标/图表配文,而非 gadget 堆砌或咨询 dump 默认。
|
|
4
4
|
|
|
5
5
|
- 技能标识:`top-ppt-html`(安装/打包目录名与此一致);品牌名:**TopPPT HTML**
|
|
6
|
-
- 版本:**v0.1.
|
|
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
|
|
package/top-ppt-html/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.1.6",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"pptxgenjs": "^4.0.0"
|
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
387
|
-
if y >= band_top - int(0.02 * 914400):
|
|
470
|
+
if _is_chrome_or_bg(y, cy, cx):
|
|
388
471
|
continue
|
|
389
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
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:
|