@topmindspace/tms-skills 0.3.1 → 0.3.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 +172 -0
- package/README.en.md +6 -76
- package/README.md +7 -75
- package/package.json +1 -1
- package/top-ppt-html/README.en.md +19 -20
- package/top-ppt-html/README.md +36 -90
- package/top-ppt-html/SKILL.md +65 -60
- package/top-ppt-html/references/charts.md +0 -15
- package/top-ppt-html/references/components.md +0 -18
- package/top-ppt-html/references/design-system.md +2 -2
- package/top-ppt-html/references/icons.md +2 -2
- package/top-ppt-html/references/infographics.md +1 -5
- package/top-ppt-html/references/modes.md +4 -4
- package/top-ppt-html/references/tech-design.md +3 -3
- package/top-ppt-html/scripts/build_examples.py +4 -4
- package/top-ppt-html/scripts/build_pptx.js +240 -59
- package/top-ppt-html/scripts/check_triggers.py +10 -2
- package/top-ppt-html/scripts/extract_model.py +16 -5
- package/top-ppt-html/scripts/gen_channel_a.js +55 -10
- package/top-ppt-html/scripts/hydrate_charts.py +68 -13
- package/top-ppt-html/scripts/negative_tests.py +1 -1
- package/top-ppt-html/scripts/prepare_images.py +1 -1
- package/top-ppt-html/scripts/recommend_layout.py +26 -3
- package/top-ppt-html/scripts/render_from_model.py +78 -13
- package/top-ppt-html/scripts/scaffold_report.py +43 -3
- package/top-ppt-html/scripts/sync_runtime.py +32 -4
- package/top-ppt-html/scripts/test_feedback_gates.py +24 -0
- package/top-ppt-html/scripts/validate_pptx.py +10 -1
- package/top-ppt-html/scripts/validate_report.py +43 -7
- package/topmind-cover/README.en.md +11 -25
- package/topmind-cover/README.md +11 -25
- package/topmind-cover/SKILL.md +49 -38
- package/topmind-cover/assets/examples/README.md +15 -26
- package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
- package/topmind-cover/assets/examples/big-type.png +0 -0
- package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
- package/topmind-cover/assets/examples/brand-launch.png +0 -0
- package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
- package/topmind-cover/assets/examples/gan-huo.png +0 -0
- package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
- package/topmind-cover/assets/examples/ip-fun.png +0 -0
- package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
- package/topmind-cover/assets/examples/magazine.png +0 -0
- package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
- package/topmind-cover/assets/examples/minimal.png +0 -0
- package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
- package/topmind-cover/assets/examples/news-flash.png +0 -0
- package/topmind-cover/assets/examples/overview.png +0 -0
- package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
- package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
- package/topmind-cover/references/cover-styles.md +235 -264
- package/topmind-cover/references/impact-language.md +53 -0
- package/topmind-wechat-post/README.en.md +49 -1
- package/topmind-wechat-post/README.md +48 -2
- package/topmind-wechat-post/SKILL.md +35 -37
- package/topmind-wechat-post/references/element-spec.md +1 -1
- package/topmind-wechat-post/references/known-pits.md +2 -3
- package/topmind-wechat-post/references/typography-rules.md +1 -1
- package/topmind-wechat-post/references/workflow.md +3 -3
- package/topmind-wechat-post/references/writing-quality.md +8 -32
- package/topmind-wechat-post/scripts/md2wechat.py +117 -41
- package/topmind-wechat-post/scripts/negative_tests.py +32 -0
- package/topmind-x-article/README.en.md +40 -1
- package/topmind-x-article/README.md +42 -4
- package/topmind-x-article/SKILL.md +11 -5
- package/topmind-x-article/references/x-format.md +13 -10
- package/topmind-x-article/scripts/md2x.py +220 -40
|
@@ -195,20 +195,65 @@ function zipStore(files) {
|
|
|
195
195
|
--model=<file> [--out=<dir>]:对任意模型跑 A 通道,供回归探针(split 组合页)与
|
|
196
196
|
cross_verify.py 复用同一套「预览即交付」双裁判。 */
|
|
197
197
|
const argv = process.argv.slice(2);
|
|
198
|
-
|
|
199
|
-
const
|
|
198
|
+
/* 取 --name=value 参数值(路径里含 '=' 时 split('=')[1] 会截断,用 slice 精确取) */
|
|
199
|
+
const _argVal = (name) => {
|
|
200
|
+
const a = argv.find((x) => x.startsWith(name + '='));
|
|
201
|
+
return a ? a.slice(name.length + 1) : undefined;
|
|
202
|
+
};
|
|
203
|
+
const modelArg = _argVal('--model');
|
|
204
|
+
const outArg = _argVal('--out');
|
|
200
205
|
const OUT_DIR = outArg ? path.resolve(outArg) : OUT;
|
|
201
|
-
|
|
206
|
+
/* 输出目录延迟到首次成功写盘前再建:模型读取失败时不留下空目录(原子性) */
|
|
207
|
+
let outDirReady = false;
|
|
208
|
+
function ensureOutDir() {
|
|
209
|
+
if (outDirReady) return;
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
console.error('[gen_channel_a] FAIL: 无法创建输出目录 ' + OUT_DIR + ':' + (e && e.message));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
outDirReady = true;
|
|
217
|
+
}
|
|
202
218
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
let jobs;
|
|
220
|
+
try {
|
|
221
|
+
jobs = modelArg
|
|
222
|
+
? [[path.resolve(modelArg), path.basename(modelArg).replace(/\.model\.json$/, '.pptx')]]
|
|
223
|
+
: fs.readdirSync(EX).filter((f) => f.endsWith('.model.json')).sort()
|
|
224
|
+
.map((f) => [path.join(EX, f), f.replace('.model.json', '.pptx')]);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error('[gen_channel_a] FAIL: 无法列出模型目录 ' + (modelArg || EX) + ':' + (e && e.message));
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
207
229
|
|
|
230
|
+
let failed = 0;
|
|
208
231
|
for (const [src, name] of jobs) {
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
let model;
|
|
233
|
+
try {
|
|
234
|
+
model = JSON.parse(fs.readFileSync(src, 'utf-8'));
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error('[gen_channel_a] FAIL: 模型读取失败 ' + src + ':' + (e && e.message));
|
|
237
|
+
failed++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
let bytes;
|
|
241
|
+
try {
|
|
242
|
+
bytes = buildPptx(model);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error('[gen_channel_a] FAIL: A 通道组装失败 ' + src + ':' + (e && e.message));
|
|
245
|
+
failed++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
211
248
|
const out = path.join(OUT_DIR, name);
|
|
212
|
-
|
|
249
|
+
try {
|
|
250
|
+
ensureOutDir();
|
|
251
|
+
fs.writeFileSync(out, Buffer.from(bytes));
|
|
252
|
+
} catch (e) {
|
|
253
|
+
console.error('[gen_channel_a] FAIL: 写入失败 ' + out + ':' + (e && e.message));
|
|
254
|
+
failed++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
213
257
|
console.log('A-channel:', path.basename(out), Math.round(bytes.length / 1024) + 'KB');
|
|
214
258
|
}
|
|
259
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -7,12 +7,12 @@ Used after render_from_model.py — follows extract_snippet chart patterns
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import json
|
|
11
10
|
import re
|
|
12
11
|
import sys
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
from render_from_model import extract_model_span
|
|
15
|
+
|
|
16
16
|
PLACEHOLDER_SVG_RE = re.compile(
|
|
17
17
|
r'<svg class="chart" data-chart="([^"]+)" viewBox="0 0 560 220">\s*'
|
|
18
18
|
r'<!--[\s\S]*?-->\s*'
|
|
@@ -259,10 +259,41 @@ def svg_waterfall(labels, values, unit='') -> str:
|
|
|
259
259
|
+ '\n '.join(parts) + '\n </svg>')
|
|
260
260
|
|
|
261
261
|
|
|
262
|
-
|
|
262
|
+
class ChartDataError(ValueError):
|
|
263
|
+
"""图表数据非法:调用方应捕获并输出干净 FAIL(不打 Traceback)。"""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _coerce_values(values, ctx: str) -> list:
|
|
267
|
+
out = []
|
|
268
|
+
for i, v in enumerate(values):
|
|
269
|
+
try:
|
|
270
|
+
f = float(v)
|
|
271
|
+
except (TypeError, ValueError):
|
|
272
|
+
raise ChartDataError(f'{ctx}: values[{i}] 非数字({str(v)[:40]!r}),无法绘制')
|
|
273
|
+
if f != f or f in (float('inf'), float('-inf')):
|
|
274
|
+
raise ChartDataError(f'{ctx}: values[{i}] 非有限数字,无法绘制')
|
|
275
|
+
out.append(f)
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def validate_payload(chart: dict, ctx: str) -> tuple:
|
|
280
|
+
"""校验图表数据:空数据/长度不一致/非数字一律干净报错,禁静默截断。"""
|
|
263
281
|
ct = (chart.get('type') or 'bar').lower()
|
|
264
282
|
labels = chart.get('labels') or []
|
|
265
283
|
values = chart.get('values') or []
|
|
284
|
+
if not labels or not values:
|
|
285
|
+
raise ChartDataError(
|
|
286
|
+
f'{ctx}: labels/values 为空(labels={len(labels)} values={len(values)}),无法绘制 {ct} 图')
|
|
287
|
+
if len(labels) != len(values):
|
|
288
|
+
raise ChartDataError(
|
|
289
|
+
f'{ctx}: labels({len(labels)}) 与 values({len(values)}) 长度不一致,'
|
|
290
|
+
f'拒绝静默截断({ct} 图)')
|
|
291
|
+
return list(labels), _coerce_values(values, ctx)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_svg(chart: dict, ctx: str = '') -> str:
|
|
295
|
+
ct = (chart.get('type') or 'bar').lower()
|
|
296
|
+
labels, values = validate_payload(chart, ctx or f'图表({ct})')
|
|
266
297
|
unit = chart.get('unit') or ''
|
|
267
298
|
max_v = chart.get('max')
|
|
268
299
|
if ct == 'hbar':
|
|
@@ -279,16 +310,17 @@ def build_svg(chart: dict) -> str:
|
|
|
279
310
|
|
|
280
311
|
|
|
281
312
|
def iter_chart_payloads(model: dict):
|
|
282
|
-
"""Yield chart
|
|
283
|
-
for sec in model.get('sections') or []:
|
|
313
|
+
"""Yield (chart dict, context str) in document order (sections + split sides)."""
|
|
314
|
+
for si, sec in enumerate(model.get('sections') or []):
|
|
284
315
|
st = (sec.get('type') or '').lower()
|
|
316
|
+
sctx = f'第{si + 1}节[{sec.get("title") or st}]'
|
|
285
317
|
if st in ('bar', 'donut', 'exhibit', 'halftable') and sec.get('chart'):
|
|
286
318
|
ch = dict(sec['chart'])
|
|
287
319
|
if st == 'donut':
|
|
288
320
|
ch.setdefault('type', 'donut')
|
|
289
321
|
elif not ch.get('type'):
|
|
290
322
|
ch['type'] = 'bar'
|
|
291
|
-
yield ch
|
|
323
|
+
yield ch, f'{sctx} 主图表'
|
|
292
324
|
for side in ('left', 'right'):
|
|
293
325
|
el = sec.get(side)
|
|
294
326
|
if isinstance(el, dict) and el.get('labels') and el.get('values') is not None:
|
|
@@ -300,7 +332,7 @@ def iter_chart_payloads(model: dict):
|
|
|
300
332
|
'max': el.get('max'),
|
|
301
333
|
'centerLabel': el.get('centerLabel'),
|
|
302
334
|
}
|
|
303
|
-
yield ch
|
|
335
|
+
yield ch, f'{sctx} {side}区图表'
|
|
304
336
|
|
|
305
337
|
|
|
306
338
|
def hydrate(html: str, model: dict) -> str:
|
|
@@ -311,7 +343,8 @@ def hydrate(html: str, model: dict) -> str:
|
|
|
311
343
|
nonlocal idx
|
|
312
344
|
if idx >= len(payloads):
|
|
313
345
|
return m.group(0)
|
|
314
|
-
|
|
346
|
+
ch, ctx = payloads[idx]
|
|
347
|
+
svg = build_svg(ch, ctx)
|
|
315
348
|
idx += 1
|
|
316
349
|
return svg
|
|
317
350
|
|
|
@@ -324,13 +357,35 @@ def main() -> int:
|
|
|
324
357
|
print('usage: _hydrate_charts.py <report.html>')
|
|
325
358
|
return 2
|
|
326
359
|
path = Path(sys.argv[1])
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
360
|
+
try:
|
|
361
|
+
html = path.read_text(encoding='utf-8')
|
|
362
|
+
except FileNotFoundError:
|
|
363
|
+
print(f'FAIL: 文件不存在 {path}')
|
|
364
|
+
return 1
|
|
365
|
+
except UnicodeDecodeError as e:
|
|
366
|
+
print(f'FAIL: 文件不是合法 UTF-8 {path}({e})')
|
|
367
|
+
return 1
|
|
368
|
+
except OSError as e:
|
|
369
|
+
print(f'FAIL: 读取失败 {path}({e})')
|
|
370
|
+
return 1
|
|
371
|
+
# 括号感知抽取(模型字符串含 `};` 时正则会提前截断;非法 JSON 走干净 FAIL)
|
|
372
|
+
try:
|
|
373
|
+
span = extract_model_span(html)
|
|
374
|
+
except SystemExit as e:
|
|
375
|
+
print(f'FAIL: {e}')
|
|
376
|
+
return 1
|
|
377
|
+
if not span:
|
|
330
378
|
print('FAIL: no REPORT_MODEL')
|
|
331
379
|
return 1
|
|
332
|
-
model =
|
|
333
|
-
|
|
380
|
+
model = span[2]
|
|
381
|
+
if not isinstance(model, dict):
|
|
382
|
+
print(f'FAIL: REPORT_MODEL 根节点必须是对象(得到 {type(model).__name__})')
|
|
383
|
+
return 1
|
|
384
|
+
try:
|
|
385
|
+
new_html, n_ph, used, total = hydrate(html, model)
|
|
386
|
+
except ChartDataError as e:
|
|
387
|
+
print(f'FAIL: 图表数据非法——{e}')
|
|
388
|
+
return 1
|
|
334
389
|
path.write_text(new_html, encoding='utf-8')
|
|
335
390
|
left = new_html.count('见模型数据')
|
|
336
391
|
print(f'hydrated placeholders={n_ph} used_payloads={used}/{total} remaining_placeholder={left}')
|
|
@@ -319,7 +319,7 @@ def main() -> int:
|
|
|
319
319
|
else:
|
|
320
320
|
print(' [SKIP] layout-qa 负例(scaffold 未产出 data-skel)')
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
ok = _pptx_notes_case() and ok
|
|
323
323
|
ok = _chart_data_case() and ok
|
|
324
324
|
ok = _title_pattern_case() and ok
|
|
325
325
|
ok = _font_scale_case() and ok
|
|
@@ -273,7 +273,7 @@ def main() -> int:
|
|
|
273
273
|
lock_cls = (SPEC.get('ratioCssClass') or {}).get(args.layout) or f'media--r{ratio_class(w, h)}'
|
|
274
274
|
html_bits.append(
|
|
275
275
|
f'<figure class="media {lock_cls}{fit_cls} rv">\n'
|
|
276
|
-
f' <img src="{url
|
|
276
|
+
f' <img src="{url}" '
|
|
277
277
|
f'alt="{src.stem}">\n'
|
|
278
278
|
f' <figcaption class="media__cap--below">图:{src.stem}({w}×{h} · {ratio_label(w, h)})</figcaption>\n'
|
|
279
279
|
f'</figure>')
|
|
@@ -350,15 +350,38 @@ def main() -> int:
|
|
|
350
350
|
|
|
351
351
|
if args.stdin:
|
|
352
352
|
raw = sys.stdin.read()
|
|
353
|
-
|
|
353
|
+
try:
|
|
354
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
355
|
+
except json.JSONDecodeError as e:
|
|
356
|
+
print(f'错误:--stdin 不是合法 JSON: {e}')
|
|
357
|
+
return 2
|
|
358
|
+
if not isinstance(data, dict):
|
|
359
|
+
print('错误:--stdin 根节点必须是对象')
|
|
360
|
+
return 2
|
|
354
361
|
mode = mode or data.get('mode')
|
|
355
362
|
intents = intents or list(data.get('intents') or [])
|
|
356
363
|
pages = pages if pages is not None else data.get('pages')
|
|
357
364
|
model = data.get('model') or data if 'sections' in data else None
|
|
358
365
|
|
|
359
366
|
if args.from_model:
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
try:
|
|
368
|
+
model = json.loads(Path(args.from_model).read_text(encoding='utf-8'))
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
print(f'错误:--from-model 文件不存在 {args.from_model}')
|
|
371
|
+
return 2
|
|
372
|
+
except UnicodeDecodeError as e:
|
|
373
|
+
print(f'错误:--from-model 不是合法 UTF-8 {args.from_model}: {e}')
|
|
374
|
+
return 2
|
|
375
|
+
except OSError as e:
|
|
376
|
+
print(f'错误:无法读取 --from-model {args.from_model}: {e}')
|
|
377
|
+
return 2
|
|
378
|
+
except json.JSONDecodeError as e:
|
|
379
|
+
print(f'错误:--from-model 不是合法 JSON {args.from_model}: {e}')
|
|
380
|
+
return 2
|
|
381
|
+
mode = mode or (model.get('mode') if isinstance(model, dict) else None)
|
|
382
|
+
if model is not None and not isinstance(model, dict):
|
|
383
|
+
print(f'错误:--from-model 根节点必须是对象(得到 {type(model).__name__})')
|
|
384
|
+
return 2
|
|
362
385
|
|
|
363
386
|
if not mode:
|
|
364
387
|
ap.error('--mode is required (or provide via --from-model/--stdin)')
|
|
@@ -38,7 +38,43 @@ IMAGE_SPEC = LC.get('imageSpec') or {}
|
|
|
38
38
|
|
|
39
39
|
CONTENT_RE = re.compile(
|
|
40
40
|
r'(<!-- __TOPPPT_CONTENT_START__ -->)[\s\S]*?(<!-- __TOPPPT_CONTENT_END__ -->)')
|
|
41
|
-
MODEL_RE = re.compile(r'window\.REPORT_MODEL
|
|
41
|
+
MODEL_RE = re.compile(r'window\.REPORT_MODEL\s*=\s*')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _strict_decoder():
|
|
45
|
+
"""标准 JSON 解码器:拒绝 NaN/Infinity(与 Node JSON.parse 同口径)。"""
|
|
46
|
+
def _reject_const(x):
|
|
47
|
+
raise ValueError(f'非法 JSON 常量 {x}')
|
|
48
|
+
return json.JSONDecoder(parse_constant=_reject_const)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_model_span(txt: str):
|
|
52
|
+
"""括号感知抽取 window.REPORT_MODEL:返回 (start, end, model)。
|
|
53
|
+
|
|
54
|
+
start/end 是 JSON 文本在 txt 中的起止下标(含首尾花括号,不含末尾分号)。
|
|
55
|
+
用 JSONDecoder.raw_decode 而非正则 `\\{[\\s\\S]*?\\}`——模型字符串里若含 `};`
|
|
56
|
+
(如标题含代码片段),正则会提前截断导致解析失败或 --inplace 写坏文件。
|
|
57
|
+
"""
|
|
58
|
+
m = MODEL_RE.search(txt)
|
|
59
|
+
if not m:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
model, end = _strict_decoder().raw_decode(txt, m.end())
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
raise SystemExit(f'REPORT_MODEL 不是合法 JSON: {e}(必须双引号/无尾逗号/无注释)')
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
raise SystemExit(f'REPORT_MODEL 不是合法 JSON: {e}(必须双引号/无尾逗号/无注释)')
|
|
67
|
+
return m.start(), end, model
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def replace_model_json(txt: str, model: dict) -> str:
|
|
71
|
+
"""把 txt 内 REPORT_MODEL 的 JSON 原子替换为 model(起止下标精确定位,不误伤正文)。"""
|
|
72
|
+
span = extract_model_span(txt)
|
|
73
|
+
if not span:
|
|
74
|
+
raise SystemExit('未找到 window.REPORT_MODEL')
|
|
75
|
+
start, end, _ = span
|
|
76
|
+
return (txt[:start] + 'window.REPORT_MODEL = '
|
|
77
|
+
+ json.dumps(model, ensure_ascii=False, indent=2) + ';' + txt[end:])
|
|
42
78
|
|
|
43
79
|
|
|
44
80
|
def esc(s) -> str:
|
|
@@ -508,6 +544,12 @@ def r_diagram(i, sec):
|
|
|
508
544
|
if isinstance(nd, dict):
|
|
509
545
|
nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd.get("t") or "")}</div>'
|
|
510
546
|
f'<div class="arch__nd">{esc(nd.get("d") or "")}</div></div>')
|
|
547
|
+
elif isinstance(nd, (list, tuple)):
|
|
548
|
+
# [标题, 注解] 数组形态(scaffold_report 发射;不处理会原样打出 "['a', 'b']")
|
|
549
|
+
t = nd[0] if len(nd) > 0 else ""
|
|
550
|
+
d = nd[1] if len(nd) > 1 else ""
|
|
551
|
+
nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(t)}</div>'
|
|
552
|
+
f'<div class="arch__nd">{esc(d)}</div></div>')
|
|
511
553
|
else:
|
|
512
554
|
nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd)}</div></div>')
|
|
513
555
|
rows.append(
|
|
@@ -838,13 +880,30 @@ def render_body(model: dict) -> str:
|
|
|
838
880
|
|
|
839
881
|
|
|
840
882
|
def load_model(path: Path) -> dict:
|
|
841
|
-
|
|
883
|
+
try:
|
|
884
|
+
txt = path.read_text(encoding='utf-8')
|
|
885
|
+
except FileNotFoundError:
|
|
886
|
+
raise SystemExit(f'模型文件不存在: {path}')
|
|
887
|
+
except UnicodeDecodeError as e:
|
|
888
|
+
raise SystemExit(f'模型文件不是合法 UTF-8: {path}({e})')
|
|
889
|
+
except OSError as e:
|
|
890
|
+
raise SystemExit(f'模型文件读取失败: {path}({e})')
|
|
842
891
|
if path.suffix.lower() == '.json':
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
892
|
+
try:
|
|
893
|
+
model = _strict_decoder().decode(txt)
|
|
894
|
+
except json.JSONDecodeError as e:
|
|
895
|
+
raise SystemExit(f'模型不是合法 JSON: {path}({e})')
|
|
896
|
+
except ValueError as e:
|
|
897
|
+
raise SystemExit(f'模型不是合法 JSON: {path}({e})')
|
|
898
|
+
if not isinstance(model, dict):
|
|
899
|
+
raise SystemExit(f'模型根节点必须是对象: {path}(得到 {type(model).__name__})')
|
|
900
|
+
return model
|
|
901
|
+
span = extract_model_span(txt)
|
|
902
|
+
if not span:
|
|
846
903
|
raise SystemExit('未找到 window.REPORT_MODEL')
|
|
847
|
-
|
|
904
|
+
if not isinstance(span[2], dict):
|
|
905
|
+
raise SystemExit(f'REPORT_MODEL 根节点必须是对象(得到 {type(span[2]).__name__})')
|
|
906
|
+
return span[2]
|
|
848
907
|
|
|
849
908
|
|
|
850
909
|
def _strip_refs_nav(html: str, body: str) -> str:
|
|
@@ -879,11 +938,14 @@ def main() -> int:
|
|
|
879
938
|
return 2
|
|
880
939
|
t = CONTENT_RE.sub(
|
|
881
940
|
r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
|
|
882
|
-
#
|
|
883
|
-
t =
|
|
884
|
-
json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
|
|
941
|
+
# 同步模型(确保与渲染源一致;span 精确定位,模型字符串含 `};` 也不写坏)
|
|
942
|
+
t = replace_model_json(t, model)
|
|
885
943
|
t = _strip_refs_nav(t, body)
|
|
886
|
-
|
|
944
|
+
try:
|
|
945
|
+
src.write_text(t, encoding='utf-8')
|
|
946
|
+
except OSError as e:
|
|
947
|
+
print(f'错误:--inplace 写回失败 {src}({e})', file=sys.stderr)
|
|
948
|
+
return 1
|
|
887
949
|
print(f'已按模型重渲染正文:{src}({len(body)} 字符)')
|
|
888
950
|
print(f' 下一步: python scripts/validate_report.py "{src}" --strict')
|
|
889
951
|
return 0
|
|
@@ -899,11 +961,14 @@ def main() -> int:
|
|
|
899
961
|
print('错误:模板缺少 CONTENT 标记', file=sys.stderr)
|
|
900
962
|
return 2
|
|
901
963
|
t = CONTENT_RE.sub(r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
|
|
902
|
-
t =
|
|
903
|
-
json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
|
|
964
|
+
t = replace_model_json(t, model)
|
|
904
965
|
t = _strip_refs_nav(t, body)
|
|
905
966
|
out = Path(args.out or (src.with_suffix('.html')))
|
|
906
|
-
|
|
967
|
+
try:
|
|
968
|
+
out.write_text(t, encoding='utf-8')
|
|
969
|
+
except OSError as e:
|
|
970
|
+
print(f'错误:输出写入失败 {out}({e})', file=sys.stderr)
|
|
971
|
+
return 1
|
|
907
972
|
print(f'已从模型生成:{out} sections={len(model.get("sections") or [])}')
|
|
908
973
|
print(f' 下一步: python scripts/validate_report.py "{out}" --strict')
|
|
909
974
|
return 0
|
|
@@ -930,11 +930,47 @@ def main() -> int:
|
|
|
930
930
|
t = tpl_path.read_text(encoding='utf-8')
|
|
931
931
|
|
|
932
932
|
style = args.style or TPL_DEFAULT_STYLE[mode]
|
|
933
|
+
# 风格早校验:未知风格现在拦下(干净报错),而不是生成一份后续 validate 才判 FAIL 的 HTML
|
|
934
|
+
_lc_styles = None
|
|
935
|
+
try:
|
|
936
|
+
_lc = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
|
|
937
|
+
_lc_styles = sorted(k for k in (_lc.get('styles') or {}) if not str(k).startswith('$'))
|
|
938
|
+
except (OSError, ValueError):
|
|
939
|
+
_lc_styles = None # 单源读不到时不拦(校验门禁兜底)
|
|
940
|
+
if _lc_styles and style not in _lc_styles:
|
|
941
|
+
print(f'错误:未知风格 {style!r}(可选:{" / ".join(_lc_styles)})')
|
|
942
|
+
return 2
|
|
933
943
|
theme = args.theme or ('dark' if style == 'graphite-dark' else 'light')
|
|
934
|
-
n = args.sections if args.sections else {'presentation': 8, 'research': 10, 'architecture': 3}[mode]
|
|
944
|
+
n = args.sections if args.sections is not None else {'presentation': 8, 'research': 10, 'architecture': 3}[mode]
|
|
945
|
+
if n < 0:
|
|
946
|
+
print(f'错误:--sections 必须 ≥ 0(得到 {n})')
|
|
947
|
+
return 2
|
|
935
948
|
plan = None
|
|
936
949
|
if args.plan:
|
|
937
|
-
|
|
950
|
+
plan_path = Path(args.plan)
|
|
951
|
+
try:
|
|
952
|
+
plan_text = plan_path.read_text(encoding='utf-8')
|
|
953
|
+
except FileNotFoundError:
|
|
954
|
+
print(f'错误:--plan 文件不存在 {plan_path}')
|
|
955
|
+
return 2
|
|
956
|
+
except UnicodeDecodeError as e:
|
|
957
|
+
print(f'错误:--plan 不是合法 UTF-8 {plan_path}: {e}')
|
|
958
|
+
return 2
|
|
959
|
+
except OSError as e:
|
|
960
|
+
print(f'错误:无法读取 --plan {plan_path}: {e}')
|
|
961
|
+
return 2
|
|
962
|
+
try:
|
|
963
|
+
plan = json.loads(plan_text)
|
|
964
|
+
except json.JSONDecodeError as e:
|
|
965
|
+
print(f'错误:--plan 不是合法 JSON {plan_path}: {e}')
|
|
966
|
+
return 2
|
|
967
|
+
if not isinstance(plan, list):
|
|
968
|
+
print(f'错误:--plan 根节点必须是数组(得到 {type(plan).__name__})')
|
|
969
|
+
return 2
|
|
970
|
+
bad_items = [k for k, it in enumerate(plan) if not isinstance(it, dict)]
|
|
971
|
+
if bad_items:
|
|
972
|
+
print(f'错误:--plan 第 {", ".join(str(k + 1) for k in bad_items)} 项不是对象(每项应为 {{"type","eyebrow","title"}})')
|
|
973
|
+
return 2
|
|
938
974
|
n = len(plan)
|
|
939
975
|
pages = build_plan(mode, n, plan, preset=args.preset)
|
|
940
976
|
|
|
@@ -1053,7 +1089,11 @@ def main() -> int:
|
|
|
1053
1089
|
|
|
1054
1090
|
out = Path(args.out)
|
|
1055
1091
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1056
|
-
|
|
1092
|
+
try:
|
|
1093
|
+
out.write_text(t, encoding='utf-8')
|
|
1094
|
+
except OSError as e:
|
|
1095
|
+
print(f'错误:输出写入失败 {out}({e})', file=sys.stderr)
|
|
1096
|
+
return 1
|
|
1057
1097
|
print(f'已生成骨架:{out} {len(t)} 字符')
|
|
1058
1098
|
print(f' 模式 {mode} · 风格 {style} · 主题 {theme} · 内容页 {len(pages)}'
|
|
1059
1099
|
f'({"含" if show_agenda else "省略"} Agenda'
|
|
@@ -298,7 +298,11 @@ def build_schema_block(ms: dict) -> str:
|
|
|
298
298
|
|
|
299
299
|
|
|
300
300
|
def inject(path: Path, pat: re.Pattern, repl: str, label: str) -> bool:
|
|
301
|
-
|
|
301
|
+
try:
|
|
302
|
+
src = path.read_text(encoding='utf-8')
|
|
303
|
+
except OSError as e:
|
|
304
|
+
print(f'错误: 无法读取 {path.name}: {e}')
|
|
305
|
+
return False
|
|
302
306
|
if not pat.search(src):
|
|
303
307
|
print(f'错误: {path.name} 缺少 {label} 标记块')
|
|
304
308
|
return False
|
|
@@ -310,15 +314,39 @@ def main() -> int:
|
|
|
310
314
|
if not LC.exists():
|
|
311
315
|
print(f'错误: 找不到 {LC}')
|
|
312
316
|
return 2
|
|
313
|
-
|
|
317
|
+
try:
|
|
318
|
+
lc = json.loads(LC.read_text(encoding='utf-8'))
|
|
319
|
+
except UnicodeDecodeError as e:
|
|
320
|
+
print(f'错误: {LC.name} 不是合法 UTF-8: {e}')
|
|
321
|
+
return 2
|
|
322
|
+
except json.JSONDecodeError as e:
|
|
323
|
+
print(f'错误: {LC.name} 不是合法 JSON: {e}')
|
|
324
|
+
return 2
|
|
325
|
+
except OSError as e:
|
|
326
|
+
print(f'错误: 无法读取 {LC}: {e}')
|
|
327
|
+
return 2
|
|
314
328
|
if not MS.exists():
|
|
315
329
|
print(f'错误: 找不到 {MS}(DSL schema 单源)')
|
|
316
330
|
return 2
|
|
317
|
-
|
|
331
|
+
try:
|
|
332
|
+
ms = json.loads(MS.read_text(encoding='utf-8'))
|
|
333
|
+
except UnicodeDecodeError as e:
|
|
334
|
+
print(f'错误: {MS.name} 不是合法 UTF-8: {e}')
|
|
335
|
+
return 2
|
|
336
|
+
except json.JSONDecodeError as e:
|
|
337
|
+
print(f'错误: {MS.name} 不是合法 JSON: {e}')
|
|
338
|
+
return 2
|
|
339
|
+
except OSError as e:
|
|
340
|
+
print(f'错误: 无法读取 {MS}: {e}')
|
|
341
|
+
return 2
|
|
318
342
|
ok = True
|
|
319
343
|
|
|
320
344
|
# ① 重新生成 pptx-export.js 常量块 + schema 块
|
|
321
|
-
|
|
345
|
+
try:
|
|
346
|
+
src = PE.read_text(encoding='utf-8')
|
|
347
|
+
except OSError as e:
|
|
348
|
+
print(f'错误: 无法读取 {PE}: {e}')
|
|
349
|
+
return 1
|
|
322
350
|
if not CONST_PAT.search(src):
|
|
323
351
|
print('错误: assets/pptx-export.js 缺少 __TOPPPT_CONSTANTS__ 标记块')
|
|
324
352
|
return 1
|
|
@@ -172,6 +172,29 @@ def test_annotation_band_fires_when_note_present() -> None:
|
|
|
172
172
|
)
|
|
173
173
|
|
|
174
174
|
|
|
175
|
+
def test_annotation_band_textbox_to_content_bottom_no_note() -> None:
|
|
176
|
+
"""Text box framed to contentBottom (6.9) with no annotation is engine-intent
|
|
177
|
+
(empty frame, no ink invasion) — severe safety net must not fire (cf. 3c)."""
|
|
178
|
+
xml = (
|
|
179
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
180
|
+
f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
|
181
|
+
"<p:spPr><a:xfrm>"
|
|
182
|
+
f'<a:off x="{int(0.6 * EMU)}" y="{int(2.35 * EMU)}"/>'
|
|
183
|
+
f'<a:ext cx="{int(6 * EMU)}" cy="{int(4.55 * EMU)}"/>'
|
|
184
|
+
"</a:xfrm></p:spPr>"
|
|
185
|
+
"<p:txBody><a:p><a:r><a:t>发现要点</a:t></a:r></a:p></p:txBody>"
|
|
186
|
+
"</p:sp>"
|
|
187
|
+
)
|
|
188
|
+
el = ET.fromstring(xml)
|
|
189
|
+
issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
|
|
190
|
+
codes = [i["code"] for i in issues]
|
|
191
|
+
ok(
|
|
192
|
+
"3e textbox to 6.90 without annotation does not fire",
|
|
193
|
+
"ANNOTATION_BAND_OVERLAP" not in codes,
|
|
194
|
+
f"codes={codes}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
175
198
|
def test_font_size_h2_17_allowed() -> None:
|
|
176
199
|
"""modeTypeScale h2=17 must be on the allowed set (cover/display whitelist)."""
|
|
177
200
|
xml = (
|
|
@@ -425,6 +448,7 @@ def main() -> int:
|
|
|
425
448
|
test_annotation_band_ignores_full_bleed_bg()
|
|
426
449
|
test_annotation_band_allows_content_without_note()
|
|
427
450
|
test_annotation_band_fires_when_note_present()
|
|
451
|
+
test_annotation_band_textbox_to_content_bottom_no_note()
|
|
428
452
|
test_annotation_band_reports_all_invaders()
|
|
429
453
|
test_annotation_band_so_what_band_top()
|
|
430
454
|
test_element_overlap()
|
|
@@ -418,6 +418,13 @@ def annotation_band_overlap_check(
|
|
|
418
418
|
def _text(el: ET.Element) -> str:
|
|
419
419
|
return (text_content(el) or "").strip()
|
|
420
420
|
|
|
421
|
+
def _has_txbody(el: ET.Element) -> bool:
|
|
422
|
+
# 命名空间无关:是否存在 p:txBody 后代(文本框)
|
|
423
|
+
for d in el.iter():
|
|
424
|
+
if isinstance(d.tag, str) and d.tag.rsplit("}", 1)[-1] == "txBody":
|
|
425
|
+
return True
|
|
426
|
+
return False
|
|
427
|
+
|
|
421
428
|
def _is_chrome_or_bg(y: int, cy: int, cx: int) -> bool:
|
|
422
429
|
if (cx * cy) / slide_area >= full_bleed_area and y <= int(0.05 * 914400):
|
|
423
430
|
return True
|
|
@@ -501,7 +508,9 @@ def annotation_band_overlap_check(
|
|
|
501
508
|
overlap = min(bottom, band_bot) - band_top
|
|
502
509
|
if overlap < min_overlap:
|
|
503
510
|
continue
|
|
504
|
-
|
|
511
|
+
# 无注释页文本框底框到 contentBottom 是引擎有意为之(框内无墨水侵入注释带),
|
|
512
|
+
# severe 安全网仅对非文本形状保留(feedback gate 用例 3:无 txBody 形状仍触发)。
|
|
513
|
+
if has_annotation or (overlap >= severe_overlap and not _has_txbody(el)):
|
|
505
514
|
out.append(issue(
|
|
506
515
|
"ANNOTATION_BAND_OVERLAP",
|
|
507
516
|
f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
|