@topmindspace/tms-skills 0.1.4 → 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 +28 -0
- package/README.md +7 -7
- package/package.json +2 -2
- package/top-ppt-html/README.md +2 -2
- package/top-ppt-html/SKILL.md +8 -5
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +72 -27
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +72 -27
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +72 -27
- package/top-ppt-html/assets/templates/architecture.html +1 -0
- package/top-ppt-html/assets/templates/presentation.html +1 -0
- package/top-ppt-html/assets/templates/research.html +1 -0
- package/top-ppt-html/package-lock.json +2 -2
- package/top-ppt-html/package.json +2 -2
- package/top-ppt-html/references/modes.md +1 -1
- package/top-ppt-html/references/playbook.md +1 -1
- package/top-ppt-html/references/presentation-craft.md +1 -1
- package/top-ppt-html/references/tech-design.md +1 -1
- package/top-ppt-html/scripts/cross_verify.py +1 -1
- package/top-ppt-html/scripts/negative_tests.py +65 -9
- package/top-ppt-html/scripts/render_from_model.py +1 -1
- package/top-ppt-html/scripts/smoke_pptx.sh +41 -2
- package/top-ppt-html/scripts/sync_runtime.py +10 -4
- package/top-ppt-html/scripts/test_feedback_gates.py +93 -0
- package/top-ppt-html/scripts/validate_pptx.py +121 -17
- package/top-ppt-html/scripts/validate_report.py +31 -1
|
@@ -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
|
|
@@ -59,6 +59,7 @@ UI = TPL_DIR / 'ui.js'
|
|
|
59
59
|
TEMPLATES = [TPL_DIR / 'presentation.html',
|
|
60
60
|
TPL_DIR / 'research.html',
|
|
61
61
|
TPL_DIR / 'architecture.html']
|
|
62
|
+
EXAMPLES = sorted((ROOT / 'assets' / 'examples').glob('*.html')) if (ROOT / 'assets' / 'examples').is_dir() else []
|
|
62
63
|
BJ = ROOT / 'scripts' / 'build_pptx.js'
|
|
63
64
|
EM = ROOT / 'scripts' / 'extract_model.py'
|
|
64
65
|
|
|
@@ -336,14 +337,19 @@ def main() -> int:
|
|
|
336
337
|
ui_repl = ('/* __TOPPPT_UI_START__ */\n'
|
|
337
338
|
'/* ══ TopPPT HTML 公共 UI 脚本(assets/templates/ui.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
|
|
338
339
|
+ ui_js + '\n/* __TOPPPT_UI_END__ */')
|
|
340
|
+
# 运行时同版本戳:对注入源 assets/pptx-export.js(rstrip 后)取 sha256[:16],
|
|
341
|
+
# 写入模板内联块,供 validate_report 比对防「模板未 sync」漂移。
|
|
342
|
+
runtime_sha = hashlib.sha256(runtime.encode('utf-8')).hexdigest()[:16]
|
|
339
343
|
runtime_repl = ('/* __TOPPPT_RUNTIME_START__ */\n'
|
|
340
344
|
'/* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
|
|
345
|
+
f'/* __TOPPPT_RUNTIME_SHA__:{runtime_sha} */\n'
|
|
341
346
|
+ runtime + '\n/* __TOPPPT_RUNTIME_END__ */')
|
|
342
347
|
|
|
343
|
-
# ③ 注入三份模式模板
|
|
344
|
-
|
|
348
|
+
# ③ 注入三份模式模板 + 黄金样张(样张也内联运行时,须同戳)
|
|
349
|
+
inject_targets = list(TEMPLATES) + list(EXAMPLES)
|
|
350
|
+
for tpl in inject_targets:
|
|
345
351
|
if not tpl.exists():
|
|
346
|
-
print(f'警告:
|
|
352
|
+
print(f'警告: 模板/样张不存在 {tpl}')
|
|
347
353
|
ok = False
|
|
348
354
|
continue
|
|
349
355
|
ok = inject(tpl, ENGINE_PAT, engine_repl, '__TOPPPT_ENGINE__') and ok
|
|
@@ -649,7 +655,7 @@ process.exit(bad ? 1 : 0);
|
|
|
649
655
|
print(f' model-schema.json sha256[:16] = {ms_digest} · 页型 {n_schema_pt} 种')
|
|
650
656
|
print(f' 风格 token {n_styles} 套 · 三模式独立比例尺 {len([k for k in lc["modeTypeScale"] if not k.startswith("$")])} 套 · 页型几何 {n_pt} 组')
|
|
651
657
|
print(f' 图表登记 {n_charts} 种(原生 {n_native} / 形状 {n_shape}) · 语义字阶 {n_typo} 级 · 12 列网格 {lc["grid"]["columns"]} 列 · 布局 IR {n_slots} 页型')
|
|
652
|
-
print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html(引擎/UI
|
|
658
|
+
print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html + assets/examples/*.html(引擎/UI/运行时内联副本,含 __TOPPPT_RUNTIME_SHA__)')
|
|
653
659
|
print(f' 双端单源引用校验: {"PASS" if ok else "WARN(build_pptx 应 require layout-constants.json;extract_model 应读 model-schema.json;页型四件套与图表登记须完整)"}')
|
|
654
660
|
print(f' 说明: 页型几何 {n_pt} 组(多页型共享几何组) · schema 页型 {n_schema_pt} 种')
|
|
655
661
|
return 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:
|
|
@@ -28,6 +28,8 @@ research / architecture 仍须显式传 --layout-qa(不强制)。
|
|
|
28
28
|
import sys
|
|
29
29
|
import re
|
|
30
30
|
import json
|
|
31
|
+
import hashlib
|
|
32
|
+
from typing import Optional
|
|
31
33
|
from html.parser import HTMLParser
|
|
32
34
|
from pathlib import Path
|
|
33
35
|
|
|
@@ -433,8 +435,22 @@ def _check_type_features(txt, chk, model):
|
|
|
433
435
|
f"{len(missing)} 处: {missing[:4]}" if missing else "", level="WARN")
|
|
434
436
|
|
|
435
437
|
|
|
438
|
+
RUNTIME_SHA_RE = re.compile(r'/\* __TOPPPT_RUNTIME_SHA__:([0-9a-f]{16}) \*/')
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _pptx_export_source_sha() -> Optional[str]:
|
|
442
|
+
"""当前技能包 assets/pptx-export.js 的 sha256[:16](与 sync_runtime 注入口径一致)。"""
|
|
443
|
+
pe = Path(__file__).resolve().parent.parent / 'assets' / 'pptx-export.js'
|
|
444
|
+
try:
|
|
445
|
+
body = pe.read_text(encoding='utf-8').rstrip('\n')
|
|
446
|
+
except OSError:
|
|
447
|
+
return None
|
|
448
|
+
return hashlib.sha256(body.encode('utf-8')).hexdigest()[:16]
|
|
449
|
+
|
|
450
|
+
|
|
436
451
|
def _check_pptx_export(txt, chk):
|
|
437
|
-
"""PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。
|
|
452
|
+
"""PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。
|
|
453
|
+
另:内联运行时必须带 __TOPPPT_RUNTIME_SHA__ 且与当前 pptx-export.js 同源。"""
|
|
438
454
|
has_btn = 'id="pptPreviewBtn"' in txt
|
|
439
455
|
has_model = 'REPORT_MODEL' in txt
|
|
440
456
|
has_runtime = ('g.TopPptHtml = api' in txt) and ('function slidesXml' in txt)
|
|
@@ -445,6 +461,20 @@ def _check_pptx_export(txt, chk):
|
|
|
445
461
|
else:
|
|
446
462
|
chk("含 PPTX 预览按钮与内容模型(建议保留)",
|
|
447
463
|
has_model and has_runtime, "未集成预览按钮/模型", level="WARN")
|
|
464
|
+
# 同版本戳:有运行时块时强制核对(缺戳或漂移 = FAIL)
|
|
465
|
+
if has_runtime or '/* __TOPPPT_RUNTIME_START__ */' in txt:
|
|
466
|
+
m = RUNTIME_SHA_RE.search(txt)
|
|
467
|
+
expected = _pptx_export_source_sha()
|
|
468
|
+
if not m:
|
|
469
|
+
chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", False,
|
|
470
|
+
"内联运行时缺戳;请跑 scripts/sync_runtime.py")
|
|
471
|
+
elif expected is None:
|
|
472
|
+
chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", False,
|
|
473
|
+
"无法读取 assets/pptx-export.js 计算源哈希")
|
|
474
|
+
else:
|
|
475
|
+
got = m.group(1)
|
|
476
|
+
chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", got == expected,
|
|
477
|
+
f"stamp={got} ≠ source={expected}(模板未 sync 或源已改)")
|
|
448
478
|
chk("页面无 PPTX 导出按钮(仅预览 + 提示词)",
|
|
449
479
|
'id="pptxBtn"' not in txt and 'id="pptDownload"' not in txt,
|
|
450
480
|
"残留导出按钮 pptxBtn/pptDownload", level="WARN")
|