@topmindspace/tms-skills 0.1.2 → 0.1.4
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 +46 -1
- package/README.md +14 -4
- package/bin/tms-skills.js +7 -2
- package/package.json +2 -2
- package/top-ppt-html/README.md +40 -27
- package/top-ppt-html/SKILL.md +20 -16
- package/top-ppt-html/agents/openai.yaml +5 -0
- package/top-ppt-html/assets/pptx-export.js +69 -25
- package/top-ppt-html/assets/style-gallery.html +1 -1
- package/top-ppt-html/assets/templates/architecture.html +69 -25
- package/top-ppt-html/assets/templates/presentation.html +69 -25
- package/top-ppt-html/assets/templates/research.html +69 -25
- package/top-ppt-html/evals/trigger-queries.json +181 -0
- package/top-ppt-html/package.json +4 -3
- package/top-ppt-html/references/chart-decision-tree.md +42 -0
- package/top-ppt-html/references/default-surface.md +3 -2
- package/top-ppt-html/references/illustration-layout.md +39 -0
- package/top-ppt-html/references/modes.md +1 -1
- package/top-ppt-html/references/outline-design.md +24 -0
- package/top-ppt-html/references/page-type-matrix.md +35 -0
- package/top-ppt-html/references/playbook.md +28 -89
- package/top-ppt-html/references/pptx-export.md +1 -1
- package/top-ppt-html/references/presentation-craft.md +11 -2
- package/top-ppt-html/references/tech-design.md +6 -0
- package/top-ppt-html/scripts/audit_docs.py +1 -1
- package/top-ppt-html/scripts/audit_skill.py +43 -4
- package/top-ppt-html/scripts/build_pptx.js +24 -10
- package/top-ppt-html/scripts/check_triggers.py +153 -0
- package/top-ppt-html/scripts/negative_tests.py +10 -0
- package/top-ppt-html/scripts/package_skill.py +17 -1
- package/top-ppt-html/scripts/quality_gate.py +30 -19
- package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
- package/top-ppt-html/scripts/validate_pptx.py +157 -0
- package/top-ppt-html/scripts/validate_report.py +27 -0
|
@@ -87,6 +87,7 @@ INCLUDE = [
|
|
|
87
87
|
'references/*',
|
|
88
88
|
'scripts/*',
|
|
89
89
|
'evals/*',
|
|
90
|
+
'agents/*',
|
|
90
91
|
]
|
|
91
92
|
|
|
92
93
|
# 出包规则(命中即跳过;与 .gitignore 口径一致)
|
|
@@ -120,6 +121,9 @@ REQUIRED = [
|
|
|
120
121
|
'references/layout-grammar.md',
|
|
121
122
|
'references/default-surface.md',
|
|
122
123
|
'references/presentation-craft.md',
|
|
124
|
+
'references/illustration-layout.md',
|
|
125
|
+
'references/page-type-matrix.md',
|
|
126
|
+
'references/chart-decision-tree.md',
|
|
123
127
|
'references/modes.md', 'references/outline-design.md', 'references/design-system.md',
|
|
124
128
|
'references/styles.md', 'references/content-rules.md', 'references/components.md',
|
|
125
129
|
'references/charts.md',
|
|
@@ -145,13 +149,15 @@ REQUIRED = [
|
|
|
145
149
|
'references/infographics-stats.md', 'references/infographics-structure.md',
|
|
146
150
|
'evals/prompts.csv', 'evals/run_evals.py', 'evals/rubric.schema.json',
|
|
147
151
|
'evals/trace.example.json',
|
|
152
|
+
'evals/trigger-queries.json',
|
|
153
|
+
'scripts/check_triggers.py',
|
|
148
154
|
]
|
|
149
155
|
|
|
150
156
|
# 必须达到最小数量的集合(名称含日期,故按数量校验)
|
|
151
157
|
MIN_COUNTS = {
|
|
152
158
|
'assets/examples/*.html': 3, # Batch3 每模式 1 份黄金样张
|
|
153
159
|
'assets/examples/*.model.json': 3,
|
|
154
|
-
'references/*.md':
|
|
160
|
+
'references/*.md': 25, # + page-type-matrix + chart-decision-tree;≥25 防误删
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
|
|
@@ -255,6 +261,16 @@ def main():
|
|
|
255
261
|
return 1
|
|
256
262
|
print(' 校验通过:必需文件齐全 · frontmatter 合规 · 无临时/缓存路径')
|
|
257
263
|
|
|
264
|
+
# P0-1 trigger heuristic gate(廉价、无 LLM)
|
|
265
|
+
trig = ROOT / 'scripts' / 'check_triggers.py'
|
|
266
|
+
if trig.exists():
|
|
267
|
+
import subprocess
|
|
268
|
+
r = subprocess.run([sys.executable, str(trig)], cwd=str(ROOT))
|
|
269
|
+
if r.returncode != 0:
|
|
270
|
+
print(' ✗ trigger coverage 失败(evals/trigger-queries.json)')
|
|
271
|
+
return 1
|
|
272
|
+
print(' ✓ trigger coverage 通过')
|
|
273
|
+
|
|
258
274
|
if check_only:
|
|
259
275
|
print('\n(--check 模式:仅校验,不打包)')
|
|
260
276
|
return 0
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
② 无 LLM 的 rubric 启发式(content/layout/chart/infographic/tone 五维)
|
|
17
17
|
③ --deliver:按 SKILL.md「交付说明」七要素(路径/字节数/模式/风格/篇幅/格式/校验+引用)
|
|
18
18
|
生成结构化交付说明——七要素缺一即 gate FAIL,杜绝手拼遗漏
|
|
19
|
-
|
|
19
|
+
交付时一键跑完;互不依赖的子进程门禁(HTML / PPTX / evals)并行执行以缩短墙钟。
|
|
20
20
|
"""
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
@@ -26,6 +26,7 @@ import re
|
|
|
26
26
|
import subprocess
|
|
27
27
|
import sys
|
|
28
28
|
import time
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
|
|
31
32
|
try:
|
|
@@ -242,33 +243,43 @@ def main() -> int:
|
|
|
242
243
|
print(f'交付质量门禁 · {html.name}')
|
|
243
244
|
print('-' * 60)
|
|
244
245
|
|
|
245
|
-
#
|
|
246
|
+
# ①–③ 互不依赖子进程并行(HTML strict / PPTX strict / evals)
|
|
246
247
|
head_snip = html.read_text(encoding='utf-8')[:2500]
|
|
247
248
|
mode_m = re.search(r'data-mode="([^"]+)"', head_snip)
|
|
248
249
|
mode = mode_m.group(1) if mode_m else 'presentation'
|
|
250
|
+
jobs: list[tuple[str, list[str], str | None]] = []
|
|
251
|
+
# (name, cmd, fixed_note) — fixed_note 非空则覆盖尾部摘要
|
|
249
252
|
vcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_report.py'), str(html), '--strict']
|
|
250
253
|
if mode == 'presentation':
|
|
251
254
|
vcmd.append('--layout-qa')
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
|
|
255
|
-
gate(gate_name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
|
|
256
|
-
|
|
257
|
-
# ② PPTX strict(可选)
|
|
255
|
+
gate_html = 'validate_report --strict' + (' --layout-qa' if mode == 'presentation' else '')
|
|
256
|
+
jobs.append((gate_html, vcmd, None))
|
|
258
257
|
if args.pptx:
|
|
259
|
-
|
|
258
|
+
pcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_pptx.py'), args.pptx, '--strict']
|
|
260
259
|
if args.model:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
gate('validate_pptx --strict', rc == 0, tail[-1] if tail else f'exit {rc}', el)
|
|
265
|
-
|
|
266
|
-
# ③ evals 确定性
|
|
267
|
-
cmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
|
|
260
|
+
pcmd += ['--model=' + args.model]
|
|
261
|
+
jobs.append(('validate_pptx --strict', pcmd, None))
|
|
262
|
+
ecmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
|
|
268
263
|
if args.trace:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
264
|
+
ecmd += ['--trace', args.trace]
|
|
265
|
+
jobs.append(('evals --score', ecmd, '确定性 + 效率检查'))
|
|
266
|
+
|
|
267
|
+
results: dict[str, tuple[int, str, float, str | None]] = {}
|
|
268
|
+
with ThreadPoolExecutor(max_workers=max(1, len(jobs))) as pool:
|
|
269
|
+
futs = {pool.submit(run, cmd): (name, note) for name, cmd, note in jobs}
|
|
270
|
+
for fut in as_completed(futs):
|
|
271
|
+
name, note = futs[fut]
|
|
272
|
+
rc, out, el = fut.result()
|
|
273
|
+
results[name] = (rc, out, el, note)
|
|
274
|
+
|
|
275
|
+
# 按 jobs 声明顺序输出(稳定可读;墙钟已并行)
|
|
276
|
+
for name, _cmd, _note in jobs:
|
|
277
|
+
rc, out, el, note = results[name]
|
|
278
|
+
if note is not None:
|
|
279
|
+
gate(name, rc == 0, note, el)
|
|
280
|
+
else:
|
|
281
|
+
tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
|
|
282
|
+
gate(name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
|
|
272
283
|
|
|
273
284
|
# ④ rubric 启发式
|
|
274
285
|
rubric = heuristic_rubric(html)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Regression gates for usage-feedback defects (validator + static engine checks)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
import validate_pptx as V # noqa: E402
|
|
14
|
+
|
|
15
|
+
EMU = 914400
|
|
16
|
+
NS = V.NS
|
|
17
|
+
fails: list[str] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ok(name: str, cond: bool, detail: str = "") -> None:
|
|
21
|
+
status = "PASS" if cond else "FAIL"
|
|
22
|
+
print(f" [{status}] {name}" + (f" — {detail}" if detail and not cond else ""))
|
|
23
|
+
if not cond:
|
|
24
|
+
fails.append(name)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_shape_bounds_reads_p_xfrm() -> None:
|
|
28
|
+
xml = (
|
|
29
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
30
|
+
f'<p:graphicFrame xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
31
|
+
"<p:xfrm>"
|
|
32
|
+
f'<a:off x="{int(0.6 * EMU)}" y="{int(2.5 * EMU)}"/>'
|
|
33
|
+
f'<a:ext cx="{int(8.5 * EMU)}" cy="{int(3.5 * EMU)}"/>'
|
|
34
|
+
"</p:xfrm>"
|
|
35
|
+
"</p:graphicFrame>"
|
|
36
|
+
)
|
|
37
|
+
el = ET.fromstring(xml)
|
|
38
|
+
box = V.shape_bounds(el)
|
|
39
|
+
ok("1 shape_bounds reads p:xfrm", box is not None and box[2] > 0, f"got {box}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_shape_bounds_still_reads_a_xfrm() -> None:
|
|
43
|
+
xml = (
|
|
44
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
45
|
+
f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
46
|
+
"<p:spPr><a:xfrm>"
|
|
47
|
+
f'<a:off x="{EMU}" y="{EMU}"/>'
|
|
48
|
+
f'<a:ext cx="{2 * EMU}" cy="{EMU}"/>'
|
|
49
|
+
"</a:xfrm></p:spPr></p:sp>"
|
|
50
|
+
)
|
|
51
|
+
el = ET.fromstring(xml)
|
|
52
|
+
box = V.shape_bounds(el)
|
|
53
|
+
ok("1b shape_bounds reads a:xfrm", box is not None and box[0] == EMU)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _text_shape(paras, x=0.6, y=2.5, w=4.0, h=0.6):
|
|
57
|
+
parts = []
|
|
58
|
+
for text, pt in paras:
|
|
59
|
+
parts.append(
|
|
60
|
+
f'<a:p><a:r><a:rPr sz="{int(pt * 100)}"/><a:t>{text}</a:t></a:r></a:p>'
|
|
61
|
+
)
|
|
62
|
+
xml = (
|
|
63
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
64
|
+
f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
65
|
+
"<p:spPr><a:xfrm>"
|
|
66
|
+
f'<a:off x="{int(x * EMU)}" y="{int(y * EMU)}"/>'
|
|
67
|
+
f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
|
|
68
|
+
"</a:xfrm></p:spPr>"
|
|
69
|
+
f'<p:txBody>{"".join(parts)}</p:txBody></p:sp>'
|
|
70
|
+
)
|
|
71
|
+
return ET.fromstring(xml)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_text_overflow_vertical() -> None:
|
|
75
|
+
paras = [(f"这是第{i}段足够长的中文内容用来累计行高", 12.0) for i in range(8)]
|
|
76
|
+
shape = _text_shape(paras, h=0.55)
|
|
77
|
+
issues = V.text_overflow_vertical_check(shape, 1)
|
|
78
|
+
codes = [i["code"] for i in issues]
|
|
79
|
+
ok(
|
|
80
|
+
"2 TEXT_OVERFLOW_VERTICAL fires on multi-segment cumulative",
|
|
81
|
+
"TEXT_OVERFLOW_VERTICAL" in codes,
|
|
82
|
+
f"codes={codes}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_annotation_band_overlap() -> None:
|
|
87
|
+
xml = (
|
|
88
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
89
|
+
f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
90
|
+
"<p:spPr><a:xfrm>"
|
|
91
|
+
f'<a:off x="{int(0.6 * EMU)}" y="{int(5.5 * EMU)}"/>'
|
|
92
|
+
f'<a:ext cx="{int(4 * EMU)}" cy="{int(1.4 * EMU)}"/>'
|
|
93
|
+
"</a:xfrm></p:spPr></p:sp>"
|
|
94
|
+
)
|
|
95
|
+
el = ET.fromstring(xml)
|
|
96
|
+
issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
|
|
97
|
+
codes = [i["code"] for i in issues]
|
|
98
|
+
ok(
|
|
99
|
+
"3 ANNOTATION_BAND_OVERLAP fires when content crushes band",
|
|
100
|
+
"ANNOTATION_BAND_OVERLAP" in codes,
|
|
101
|
+
f"codes={codes}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_font_size_not_snapped() -> None:
|
|
106
|
+
xml = (
|
|
107
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
108
|
+
f'<p:sld xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
109
|
+
"<p:cSld><p:spTree><p:sp><p:txBody>"
|
|
110
|
+
'<a:p><a:r><a:rPr sz="1130"/><a:t>OffLadderSampleTextHere</a:t></a:r></a:p>'
|
|
111
|
+
"</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
|
|
112
|
+
)
|
|
113
|
+
root = ET.fromstring(xml)
|
|
114
|
+
issues = V.font_size_snap_check(root, 1)
|
|
115
|
+
codes = [i["code"] for i in issues]
|
|
116
|
+
ok(
|
|
117
|
+
"4 FONT_SIZE_NOT_SNAPPED fires on off-ladder size",
|
|
118
|
+
"FONT_SIZE_NOT_SNAPPED" in codes,
|
|
119
|
+
f"codes={codes}",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_engine_static() -> None:
|
|
124
|
+
export = (ROOT.parent / "assets" / "pptx-export.js").read_text(encoding="utf-8")
|
|
125
|
+
build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
m = re.search(r"function vbarShapes\([\s\S]*?\n\}", export)
|
|
128
|
+
body = m.group(0) if m else ""
|
|
129
|
+
ok("B vbarShapes handles ec.series", "ec.series" in body and "series.forEach" in body)
|
|
130
|
+
|
|
131
|
+
ok(
|
|
132
|
+
"C A-channel streamgraph reserves legend inside plot",
|
|
133
|
+
"Reserve legend INSIDE" in export and "plotBottom" in export,
|
|
134
|
+
)
|
|
135
|
+
ok(
|
|
136
|
+
"C B-channel infoStreamgraph reserves legend inside plot",
|
|
137
|
+
"Reserve legend INSIDE" in build and "plotBottom" in build,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
ok(
|
|
141
|
+
"D A-channel caption uses capYImg from imageLayoutShapes",
|
|
142
|
+
"capYImg" in export
|
|
143
|
+
and re.search(r"capYImg\s*=\s*imageLayoutShapes", export) is not None,
|
|
144
|
+
)
|
|
145
|
+
ok(
|
|
146
|
+
"D B-channel caption uses capYImg from imageLayoutShapes",
|
|
147
|
+
"capYImg" in build
|
|
148
|
+
and re.search(r"capYImg\s*=\s*imageLayoutShapes", build) is not None,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
cards = re.search(
|
|
152
|
+
r"else if \(type === 'cards'\) \{[\s\S]*?else if \(type === 'split'\)",
|
|
153
|
+
export,
|
|
154
|
+
)
|
|
155
|
+
cards_body = cards.group(0) if cards else ""
|
|
156
|
+
ok(
|
|
157
|
+
"A A-channel cards uses sz: N object props (not bare sz())",
|
|
158
|
+
"sz: 12" in cards_body
|
|
159
|
+
and not re.search(r"(?<![\w.])sz\s*\(\s*12\s*\)", cards_body),
|
|
160
|
+
)
|
|
161
|
+
ok(
|
|
162
|
+
"A modeSize applied in txSp serialization",
|
|
163
|
+
"function modeSize" in export and "modeSize(" in export,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main() -> int:
|
|
168
|
+
print("feedback-gates regression")
|
|
169
|
+
test_shape_bounds_reads_p_xfrm()
|
|
170
|
+
test_shape_bounds_still_reads_a_xfrm()
|
|
171
|
+
test_text_overflow_vertical()
|
|
172
|
+
test_annotation_band_overlap()
|
|
173
|
+
test_font_size_not_snapped()
|
|
174
|
+
test_engine_static()
|
|
175
|
+
print(f"\n{len(fails)} failed" if fails else "\nAll feedback gates PASS")
|
|
176
|
+
return 1 if fails else 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
|
@@ -108,6 +108,9 @@ STRICT_FAILURE_CODES = {
|
|
|
108
108
|
"UNBALANCED_EMPTY_SPACE",
|
|
109
109
|
"UNJUSTIFIED_LARGE_IMAGE",
|
|
110
110
|
"TEXT_OVERFLOW_ESTIMATE",
|
|
111
|
+
"TEXT_OVERFLOW_VERTICAL",
|
|
112
|
+
"ANNOTATION_BAND_OVERLAP",
|
|
113
|
+
"FONT_SIZE_NOT_SNAPPED",
|
|
111
114
|
"TEXT_INCOMPLETE",
|
|
112
115
|
"CHART_SKEW_INVALID",
|
|
113
116
|
"CHART_OVERSIZE",
|
|
@@ -207,9 +210,17 @@ def slide_chart_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
|
207
210
|
|
|
208
211
|
|
|
209
212
|
def shape_bounds(element: ET.Element) -> tuple[int, int, int, int] | None:
|
|
213
|
+
"""Read shape geometry from DrawingML ``a:xfrm`` **or** PresentationML ``p:xfrm``.
|
|
214
|
+
|
|
215
|
+
Native charts live in ``p:graphicFrame``, which carries ``p:xfrm`` (not ``a:xfrm``).
|
|
216
|
+
Skipping ``p:xfrm`` made chart overflow / overlap gates blind.
|
|
217
|
+
"""
|
|
210
218
|
xfrm = element.find(".//a:xfrm", NS)
|
|
219
|
+
if xfrm is None:
|
|
220
|
+
xfrm = element.find(".//p:xfrm", NS)
|
|
211
221
|
if xfrm is None:
|
|
212
222
|
return None
|
|
223
|
+
# p:xfrm and a:xfrm both nest a:off / a:ext
|
|
213
224
|
offset = xfrm.find("a:off", NS)
|
|
214
225
|
extent = xfrm.find("a:ext", NS)
|
|
215
226
|
if offset is None or extent is None:
|
|
@@ -298,6 +309,147 @@ def text_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]
|
|
|
298
309
|
return out
|
|
299
310
|
|
|
300
311
|
|
|
312
|
+
def text_overflow_vertical_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
313
|
+
"""Multi-segment cumulative vertical overflow (TEXT_OVERFLOW_VERTICAL).
|
|
314
|
+
|
|
315
|
+
Per-paragraph area checks miss the case where each line fits individually but
|
|
316
|
+
the *sum* of estimated line heights exceeds the text-frame height.
|
|
317
|
+
"""
|
|
318
|
+
out: list[dict[str, Any]] = []
|
|
319
|
+
box = shape_bounds(shape)
|
|
320
|
+
if box is None:
|
|
321
|
+
return out
|
|
322
|
+
_, _, cx, cy = box
|
|
323
|
+
w_in, h_in = cx / 914400.0, cy / 914400.0
|
|
324
|
+
if w_in <= 0 or h_in <= 0:
|
|
325
|
+
return out
|
|
326
|
+
cfg = _containers_cfg()
|
|
327
|
+
lf = float(cfg.get("lineFactor") or 1.35)
|
|
328
|
+
total_h = 0.0
|
|
329
|
+
segments = 0
|
|
330
|
+
for para in shape.findall(".//a:p", NS):
|
|
331
|
+
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
332
|
+
if not line.strip():
|
|
333
|
+
continue
|
|
334
|
+
sizes = font_sizes_pt(para)
|
|
335
|
+
fz = min(sizes) if sizes else 12.0
|
|
336
|
+
est_w = est_text_width_in(line, fz)
|
|
337
|
+
lines_needed = max(1, int((est_w / max(w_in, 0.01)) + 0.999))
|
|
338
|
+
total_h += lines_needed * (fz / 72.0) * lf
|
|
339
|
+
segments += 1
|
|
340
|
+
# Single-segment cases already covered by TEXT_OVERFLOW_ESTIMATE; this gate
|
|
341
|
+
# targets multi-para cumulative overflow (tolerance mirrors horizontal gate).
|
|
342
|
+
if segments >= 2 and total_h > h_in * TEXT_OVERFLOW_TOLERANCE:
|
|
343
|
+
out.append(issue(
|
|
344
|
+
"TEXT_OVERFLOW_VERTICAL",
|
|
345
|
+
f"多段文本累计高度估算 {total_h:.2f}in > 文本框 {h_in:.2f}in"
|
|
346
|
+
f"({segments} 段,容差 {TEXT_OVERFLOW_TOLERANCE:.0%})——"
|
|
347
|
+
"请拆段/缩字号阶梯/换页,禁止静默截断。",
|
|
348
|
+
slide=slide_no,
|
|
349
|
+
))
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def annotation_band_overlap_check(
|
|
354
|
+
elements: list[ET.Element],
|
|
355
|
+
slide_no: int,
|
|
356
|
+
slide_height_emu: int,
|
|
357
|
+
) -> list[dict[str, Any]]:
|
|
358
|
+
"""Detect primary content crushing into the annotation band.
|
|
359
|
+
|
|
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.
|
|
363
|
+
"""
|
|
364
|
+
out: list[dict[str, Any]] = []
|
|
365
|
+
try:
|
|
366
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
367
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
368
|
+
lay = ((lc.get("pageTypes") or {}).get("layout") or {})
|
|
369
|
+
band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
|
|
370
|
+
band_bot_in = float(lay.get("contentBottom") or 6.9)
|
|
371
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
372
|
+
band_top_in, band_bot_in = 6.4, 6.9
|
|
373
|
+
band_top = int(band_top_in * 914400)
|
|
374
|
+
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
|
|
378
|
+
for el in elements:
|
|
379
|
+
box = shape_bounds(el)
|
|
380
|
+
if box is None:
|
|
381
|
+
continue
|
|
382
|
+
x, y, cx, cy = box
|
|
383
|
+
if cx <= 0 or cy <= 0:
|
|
384
|
+
continue
|
|
385
|
+
bottom = y + cy
|
|
386
|
+
# Legitimate annotation/note content sits entirely inside the band
|
|
387
|
+
if y >= band_top - int(0.02 * 914400):
|
|
388
|
+
continue
|
|
389
|
+
# Page chrome (pager) — tiny shapes near bottom edge
|
|
390
|
+
if y >= pager_floor:
|
|
391
|
+
continue
|
|
392
|
+
overlap = min(bottom, band_bot) - max(y, band_top)
|
|
393
|
+
if overlap >= min_overlap and bottom > band_top:
|
|
394
|
+
out.append(issue(
|
|
395
|
+
"ANNOTATION_BAND_OVERLAP",
|
|
396
|
+
f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
|
|
397
|
+
f"{band_top_in:.2f}in,重叠 {overlap/914400:.2f}in)——"
|
|
398
|
+
"图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
|
|
399
|
+
slide=slide_no,
|
|
400
|
+
))
|
|
401
|
+
# One finding per slide is enough to gate
|
|
402
|
+
break
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
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).
|
|
408
|
+
|
|
409
|
+
fitFont already selects from the ladder; this gate catches callers that
|
|
410
|
+
arithmetic-shift sizes (e.g. fz-1) or hardcode off-ladder values without
|
|
411
|
+
re-snapping through the ladder / modeSize path.
|
|
412
|
+
"""
|
|
413
|
+
out: list[dict[str, Any]] = []
|
|
414
|
+
try:
|
|
415
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
416
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
417
|
+
ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
|
|
418
|
+
allowed = {round(float(v), 2) for v in ladder}
|
|
419
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
420
|
+
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}
|
|
423
|
+
alien: list[float] = []
|
|
424
|
+
for run in root.findall(".//a:r", NS):
|
|
425
|
+
text = "".join(t.text or "" for t in run.findall("a:t", NS))
|
|
426
|
+
if not text.strip():
|
|
427
|
+
continue
|
|
428
|
+
pr = run.find("a:rPr", NS)
|
|
429
|
+
raw = pr.get("sz") if pr is not None else None
|
|
430
|
+
if raw is None:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
pt = int(raw) / 100.0
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
continue
|
|
436
|
+
if round(pt, 2) not in allowed and abs(pt * 2 - round(pt * 2)) > 0.01:
|
|
437
|
+
alien.append(pt)
|
|
438
|
+
elif round(pt, 2) not in allowed:
|
|
439
|
+
# On 0.5 grid but not on declared ladder / display set — still flag
|
|
440
|
+
# when far from any allowed value (>0.26pt)
|
|
441
|
+
if min(abs(pt - a) for a in allowed) > 0.26:
|
|
442
|
+
alien.append(pt)
|
|
443
|
+
if alien:
|
|
444
|
+
out.append(issue(
|
|
445
|
+
"FONT_SIZE_NOT_SNAPPED",
|
|
446
|
+
f"页内出现未对齐字号阶梯的字号 {sorted(set(round(v,2) for v in alien))[:8]}——"
|
|
447
|
+
"fitFont/modeSize 须回落到 containers.fontShrink.ladder(或封面展示档)。",
|
|
448
|
+
slide=slide_no,
|
|
449
|
+
))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
301
453
|
@lru_cache(maxsize=1)
|
|
302
454
|
def _containers_cfg() -> dict[str, Any]:
|
|
303
455
|
"""容器内边距与锚点容差(单源 scripts/layout-constants.json 的 containers / anchorTolerance)。"""
|
|
@@ -628,8 +780,13 @@ def inspect_slide(
|
|
|
628
780
|
|
|
629
781
|
for shape in shapes:
|
|
630
782
|
warnings.extend(text_overflow_check(shape, slide_number))
|
|
783
|
+
warnings.extend(text_overflow_vertical_check(shape, slide_number))
|
|
631
784
|
warnings.extend(container_overflow_check(shape, slide_number))
|
|
632
785
|
|
|
786
|
+
warnings.extend(annotation_band_overlap_check(
|
|
787
|
+
[*shapes, *pictures, *graphic_frames], slide_number, height))
|
|
788
|
+
warnings.extend(font_size_snap_check(root, slide_number))
|
|
789
|
+
|
|
633
790
|
for table in tables:
|
|
634
791
|
warnings.extend(table_checks(table, slide_number))
|
|
635
792
|
|
|
@@ -1215,6 +1215,33 @@ def _check_media(txt, chk, model):
|
|
|
1215
1215
|
chk("模型配图占位 ↔ 正文 .media--ph 对应", False,
|
|
1216
1216
|
f"{n_ph} 页模型声明 image.placeholder 但正文无 .media--ph", level="WARN")
|
|
1217
1217
|
|
|
1218
|
+
# 配图页 caption / so-what(廉价 WARN);近邻主张/导语亦可
|
|
1219
|
+
bands = re.split(r'(?=<section\b)', txt)
|
|
1220
|
+
miss_cap = []
|
|
1221
|
+
near_empty = []
|
|
1222
|
+
for i, b in enumerate(bands):
|
|
1223
|
+
if not re.search(r'class="[^"]*\bmedia\b|<img\b|class="[^"]*media--', b):
|
|
1224
|
+
continue
|
|
1225
|
+
if len(b) < 80:
|
|
1226
|
+
continue
|
|
1227
|
+
has_cap = bool(re.search(
|
|
1228
|
+
r'class="[^"]*(?:fig__cap|media__cap|media__ph|caption|exhibit__src|footnote|so-what|lead)', b)
|
|
1229
|
+
or re.search(r'<figcaption\b|class="[^"]*\bsoWhat\b', b))
|
|
1230
|
+
if not has_cap:
|
|
1231
|
+
miss_cap.append(f'band[{i}]')
|
|
1232
|
+
# 近图过空:有 media 但正文文本极少且无要点/指标(WARN)
|
|
1233
|
+
textish = re.sub(r'<script[\s\S]*?</script>|<style[\s\S]*?</style>|<[^>]+>', ' ', b)
|
|
1234
|
+
textish = re.sub(r'\s+', ' ', textish).strip()
|
|
1235
|
+
has_points = bool(re.search(r'class="[^"]*(?:points|bullets|kpi|metrics|card)', b))
|
|
1236
|
+
if len(textish) < 40 and not has_points and not re.search(r'media--ph', b):
|
|
1237
|
+
near_empty.append(f'band[{i}]')
|
|
1238
|
+
chk("配图页含 caption/图注(IMAGE_CAPTION)", not miss_cap,
|
|
1239
|
+
f"{len(miss_cap)} 处配图区缺 .fig__cap/.media__cap/.footnote/so-what 等图注" if miss_cap else "",
|
|
1240
|
+
level="WARN")
|
|
1241
|
+
chk("配图页近邻过空(IMAGE_NEAR_EMPTY)", not near_empty,
|
|
1242
|
+
f"{len(near_empty)} 处配图页几乎无注解/要点(补 caption 或要点条)" if near_empty else "",
|
|
1243
|
+
level="WARN")
|
|
1244
|
+
|
|
1218
1245
|
|
|
1219
1246
|
# 失败检查项 → 失败模式 / 处置动作 / 精确取码命令。
|
|
1220
1247
|
# 目的:校验失败时直接给出「改什么、按什么顺序改、去哪取代码」,
|