@topmindspace/tms-skills 0.3.1 → 0.3.5

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.en.md +6 -76
  3. package/README.md +7 -75
  4. package/package.json +1 -1
  5. package/top-ppt-html/README.en.md +19 -20
  6. package/top-ppt-html/README.md +36 -90
  7. package/top-ppt-html/SKILL.md +65 -60
  8. package/top-ppt-html/references/charts.md +0 -15
  9. package/top-ppt-html/references/components.md +0 -18
  10. package/top-ppt-html/references/design-system.md +2 -2
  11. package/top-ppt-html/references/icons.md +2 -2
  12. package/top-ppt-html/references/infographics.md +1 -5
  13. package/top-ppt-html/references/modes.md +4 -4
  14. package/top-ppt-html/references/tech-design.md +3 -3
  15. package/top-ppt-html/scripts/build_examples.py +4 -4
  16. package/top-ppt-html/scripts/build_pptx.js +240 -59
  17. package/top-ppt-html/scripts/check_triggers.py +10 -2
  18. package/top-ppt-html/scripts/extract_model.py +16 -5
  19. package/top-ppt-html/scripts/gen_channel_a.js +55 -10
  20. package/top-ppt-html/scripts/hydrate_charts.py +68 -13
  21. package/top-ppt-html/scripts/negative_tests.py +1 -1
  22. package/top-ppt-html/scripts/prepare_images.py +1 -1
  23. package/top-ppt-html/scripts/recommend_layout.py +26 -3
  24. package/top-ppt-html/scripts/render_from_model.py +78 -13
  25. package/top-ppt-html/scripts/scaffold_report.py +43 -3
  26. package/top-ppt-html/scripts/sync_runtime.py +32 -4
  27. package/top-ppt-html/scripts/test_feedback_gates.py +24 -0
  28. package/top-ppt-html/scripts/validate_pptx.py +10 -1
  29. package/top-ppt-html/scripts/validate_report.py +43 -7
  30. package/topmind-cover/README.en.md +4 -17
  31. package/topmind-cover/README.md +5 -18
  32. package/topmind-cover/SKILL.md +28 -35
  33. package/topmind-cover/assets/examples/README.md +15 -26
  34. package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
  35. package/topmind-cover/assets/examples/big-type.png +0 -0
  36. package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
  37. package/topmind-cover/assets/examples/brand-launch.png +0 -0
  38. package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
  39. package/topmind-cover/assets/examples/gan-huo.png +0 -0
  40. package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
  41. package/topmind-cover/assets/examples/ip-fun.png +0 -0
  42. package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
  43. package/topmind-cover/assets/examples/magazine.png +0 -0
  44. package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
  45. package/topmind-cover/assets/examples/minimal.png +0 -0
  46. package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
  47. package/topmind-cover/assets/examples/news-flash.png +0 -0
  48. package/topmind-cover/assets/examples/overview.png +0 -0
  49. package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
  50. package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
  51. package/topmind-cover/references/cover-styles.md +275 -249
  52. package/topmind-wechat-post/README.en.md +49 -1
  53. package/topmind-wechat-post/README.md +48 -2
  54. package/topmind-wechat-post/SKILL.md +35 -37
  55. package/topmind-wechat-post/references/element-spec.md +1 -1
  56. package/topmind-wechat-post/references/known-pits.md +2 -3
  57. package/topmind-wechat-post/references/typography-rules.md +1 -1
  58. package/topmind-wechat-post/references/workflow.md +3 -3
  59. package/topmind-wechat-post/references/writing-quality.md +8 -32
  60. package/topmind-wechat-post/scripts/md2wechat.py +117 -41
  61. package/topmind-wechat-post/scripts/negative_tests.py +32 -0
  62. package/topmind-x-article/README.en.md +40 -1
  63. package/topmind-x-article/README.md +42 -4
  64. package/topmind-x-article/SKILL.md +11 -5
  65. package/topmind-x-article/references/x-format.md +13 -10
  66. 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
- const modelArg = (argv.find((a) => a.startsWith('--model=')) || '').split('=')[1];
199
- const outArg = (argv.find((a) => a.startsWith('--out=')) || '').split('=')[1];
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
- fs.mkdirSync(OUT_DIR, { recursive: true });
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
- const jobs = modelArg
204
- ? [[path.resolve(modelArg), path.basename(modelArg).replace(/\.model\.json$/, '.pptx')]]
205
- : fs.readdirSync(EX).filter((f) => f.endsWith('.model.json')).sort()
206
- .map((f) => [path.join(EX, f), f.replace('.model.json', '.pptx')]);
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
- const model = JSON.parse(fs.readFileSync(src, 'utf-8'));
210
- const bytes = buildPptx(model);
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
- fs.writeFileSync(out, Buffer.from(bytes));
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
- MODEL_RE = re.compile(r'window\.REPORT_MODEL = (\{[\s\S]*?\});')
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
- def build_svg(chart: dict) -> str:
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 dicts in document order (sections + split sides)."""
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
- svg = build_svg(payloads[idx])
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
- html = path.read_text(encoding='utf-8')
328
- m = MODEL_RE.search(html)
329
- if not m:
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 = json.loads(m.group(1))
333
- new_html, n_ph, used, total = hydrate(html, model)
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
- ok = _pptx_notes_case() and ok
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 if len(url) < 400 else url[:60] + "…(见 images-snippets.html 全文)"}" '
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
- data = json.loads(raw) if raw.strip() else {}
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
- model = json.loads(Path(args.from_model).read_text(encoding='utf-8'))
361
- mode = mode or model.get('mode')
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 = \{[\s\S]*?\};')
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
- txt = path.read_text(encoding='utf-8')
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
- return json.loads(txt)
844
- m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
845
- if not m:
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
- return json.loads(m.group(1))
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 = MODEL_RE.sub('window.REPORT_MODEL = ' +
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
- src.write_text(t, encoding='utf-8')
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 = MODEL_RE.sub('window.REPORT_MODEL = ' +
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
- out.write_text(t, encoding='utf-8')
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
- plan = json.loads(Path(args.plan).read_text(encoding='utf-8'))
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
- out.write_text(t, encoding='utf-8')
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
- src = path.read_text(encoding='utf-8')
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
- lc = json.loads(LC.read_text(encoding='utf-8'))
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
- ms = json.loads(MS.read_text(encoding='utf-8'))
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
- src = PE.read_text(encoding='utf-8')
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
- if has_annotation or overlap >= severe_overlap:
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 越过注释带顶 "