@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +172 -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 +11 -25
  31. package/topmind-cover/README.md +11 -25
  32. package/topmind-cover/SKILL.md +49 -38
  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 +235 -264
  52. package/topmind-cover/references/impact-language.md +53 -0
  53. package/topmind-wechat-post/README.en.md +49 -1
  54. package/topmind-wechat-post/README.md +48 -2
  55. package/topmind-wechat-post/SKILL.md +35 -37
  56. package/topmind-wechat-post/references/element-spec.md +1 -1
  57. package/topmind-wechat-post/references/known-pits.md +2 -3
  58. package/topmind-wechat-post/references/typography-rules.md +1 -1
  59. package/topmind-wechat-post/references/workflow.md +3 -3
  60. package/topmind-wechat-post/references/writing-quality.md +8 -32
  61. package/topmind-wechat-post/scripts/md2wechat.py +117 -41
  62. package/topmind-wechat-post/scripts/negative_tests.py +32 -0
  63. package/topmind-x-article/README.en.md +40 -1
  64. package/topmind-x-article/README.md +42 -4
  65. package/topmind-x-article/SKILL.md +11 -5
  66. package/topmind-x-article/references/x-format.md +13 -10
  67. package/topmind-x-article/scripts/md2x.py +220 -40
@@ -58,7 +58,14 @@
58
58
  */
59
59
 
60
60
  /* eslint-disable */
61
- const pptxgen = require('pptxgenjs');
61
+ let pptxgen;
62
+ try {
63
+ pptxgen = require('pptxgenjs');
64
+ } catch (e) {
65
+ console.error('[build_pptx] FAIL: 缺少依赖 pptxgenjs(' + (e && e.message) +
66
+ ')。请在技能目录执行 npm install,或设置 NODE_PATH 指向其 node_modules。');
67
+ process.exit(1);
68
+ }
62
69
  const fs = require('fs');
63
70
  const path = require('path');
64
71
 
@@ -99,7 +106,18 @@ const path = require('path');
99
106
  })();
100
107
 
101
108
  /* ══════════ 版式常量单源(scripts/layout-constants.json · 两通道共用,禁止在此手写常量) ══════════ */
102
- const LC = JSON.parse(fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8'));
109
+ /* 单源损坏(缺失/非 JSON)必须干净 FAIL,不得裸 SyntaxError 堆栈 */
110
+ let LC;
111
+ try {
112
+ LC = JSON.parse(fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8'));
113
+ } catch (e) {
114
+ console.error('[build_pptx] FAIL: 版式常量 layout-constants.json 读取失败(' + ((e && e.message) || e) + ')。请确认技能安装完整。');
115
+ process.exit(1);
116
+ }
117
+ if (!LC || typeof LC !== 'object' || !LC.page || !LC.styles) {
118
+ console.error('[build_pptx] FAIL: layout-constants.json 缺关键顶层键(page/styles),疑似损坏。');
119
+ process.exit(1);
120
+ }
103
121
  const STYLE_PRESETS = LC.styles; // 9 套风格 token(light)
104
122
  const STYLE_PRESETS_DARK = LC.stylesDark || {}; // 9 套风格 token(dark)
105
123
  const STYLE_DATA_COLORS = LC.styleDataColors || {}; // 9 套风格各自的编码色板(light)
@@ -166,9 +184,14 @@ function addIcon(s, name, x, y, size, colorHex) {
166
184
  return false;
167
185
  }
168
186
 
169
- const modelArg = (process.argv.find(a => a.startsWith('--model=')) || '').split('=')[1];
170
- let styleArg = (process.argv.find(a => a.startsWith('--style=')) || '').split('=')[1];
171
- let themeArg = (process.argv.find(a => a.startsWith('--theme=')) || '').split('=')[1];
187
+ /* 取 --name=value 参数值(路径里含 '=' 时 split('=')[1] 会截断,用 slice 精确取) */
188
+ const _argVal = (name) => {
189
+ const a = process.argv.find(x => x.startsWith(name + '='));
190
+ return a ? a.slice(name.length + 1) : undefined;
191
+ };
192
+ const modelArg = _argVal('--model');
193
+ let styleArg = _argVal('--style');
194
+ let themeArg = _argVal('--theme');
172
195
  let STYLE; // 在内容模型加载后确定(模型可带默认 style)
173
196
  let THEME = 'light'; // 亮/暗主题(--theme= 覆盖 > 模型 theme > 默认 light)
174
197
  let MODEL_DIR = null; // 模型文件所在目录(素材图片相对路径以此为锚)
@@ -339,6 +362,10 @@ if (modelArg) {
339
362
  MODEL_DIR = path.dirname(path.resolve(modelArg)); // 素材图片相对路径锚点
340
363
  if (CONTENT.style && !styleArg) styleArg = CONTENT.style; // 模型里的风格作为默认
341
364
  } else {
365
+ /* 未传 --model:按内嵌示例生成演示 PPTX。必须出声——静默产出会让人误以为是真实报告
366
+ (读模型失败时本脚本选择硬中断,此处同理不得静默)。 */
367
+ console.warn('[build_pptx] 警告:未传 --model=<模型.json>,将按内嵌示例内容生成演示 PPTX(非真实报告内容)。');
368
+ console.warn(' 真实交付请先跑 python scripts/extract_model.py <报告.html>,再用 --model=<报告.model.json> 重跑。');
342
369
  CONTENT = {
343
370
  title: '让想法被看见',
344
371
  subtitle: 'TopPPT HTML 报告生成示例 · 16:9 可编辑 PPTX',
@@ -394,6 +421,16 @@ CONTENT = {
394
421
 
395
422
  THEME = (themeArg === 'dark' || themeArg === 'light') ? themeArg
396
423
  : (CONTENT.theme === 'dark' ? 'dark' : 'light');
424
+ /* 模型形状早校验:缺 sections/closing 时后面收尾页会以 TypeError 裸崩(堆栈不可读)。
425
+ 这里给干净 FAIL(schema 顶层必填:title / sections:array / closing.title / closing.points:array)。 */
426
+ if (!Array.isArray(CONTENT.sections) || !CONTENT.sections.length) {
427
+ console.error('[build_pptx] FAIL: 模型缺少 sections 数组(' + (modelArg || '内嵌示例') + '):无法生成章节页。');
428
+ process.exit(1);
429
+ }
430
+ if (!CONTENT.closing || typeof CONTENT.closing !== 'object' || Array.isArray(CONTENT.closing)) {
431
+ console.error('[build_pptx] FAIL: 模型缺少 closing 对象(收尾页必填:closing.title / closing.points)。');
432
+ process.exit(1);
433
+ }
397
434
  STYLE = (THEME === 'dark' ? (STYLE_PRESETS_DARK[styleArg] || STYLE_PRESETS[styleArg])
398
435
  : STYLE_PRESETS[styleArg]) || STYLE_PRESETS['business-blue'];
399
436
  MODE_NAME = CONTENT.mode || 'presentation';
@@ -466,6 +503,38 @@ function assertNoOverlap(tag, a, b) {
466
503
  /* 双形态载荷判定:[[t,d]…] 数组 vs [{t,d,accent}…] 记录。
467
504
  typeof [] === 'object',绝不能用 typeof 区分——否则数组形态的 t/d 被读成 undefined 静默丢字。 */
468
505
  function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
506
+ /* 条目归一化:与 HTML 侧 render_from_model 同口径(数组 | 字典 | 纯字符串)。
507
+ PPTX 分支此前多处只认数组——字典条目会落成 "undefined" 文本,字符串会被按字符切片。
508
+ pointKV: [k,v] | {k|t, v|d} | str(对齐 pts_list)
509
+ metricKV: [v,k] | {v|value, k}(对齐 metrics_row)
510
+ bulletKV: [k,a,t] | {k,a,t}(对齐 r_bullet;缺省保持 undefined 以便调用方判空)
511
+ phaseKV: [label,name,desc,state] | {label,name,d,s}(对齐 r_timeline)
512
+ paraKV: [title,text] | str(对齐 r_twocol;str 为无加粗标题的正文段) */
513
+ function pointKV(p) {
514
+ if (Array.isArray(p)) return [String(p[0] == null ? '' : p[0]), String(p[1] == null ? '' : p[1])];
515
+ if (isRec(p)) return [String(p.k || p.t || ''), String(p.v || p.d || '')];
516
+ return ['', String(p == null ? '' : p)];
517
+ }
518
+ function metricKV(m) {
519
+ if (Array.isArray(m)) return [String(m[0] == null ? '' : m[0]), String(m[1] == null ? '' : m[1])];
520
+ if (isRec(m)) return [String(m.v || m.value || ''), String(m.k || '')];
521
+ return [String(m == null ? '' : m), ''];
522
+ }
523
+ function bulletKV(it) {
524
+ if (Array.isArray(it)) return [it[0], it[1], it[2]];
525
+ if (isRec(it)) return [it.k || '', it.a, it.t];
526
+ return [String(it == null ? '' : it), undefined, undefined];
527
+ }
528
+ function phaseKV(ph) {
529
+ if (Array.isArray(ph)) return [ph[0] || '', ph[1] || '', ph[2] || '', ph[3] || ''];
530
+ if (isRec(ph)) return [ph.label || '', ph.name || '', ph.d || '', ph.s || ''];
531
+ return [String(ph == null ? '' : ph), '', '', ''];
532
+ }
533
+ function paraKV(p) {
534
+ if (Array.isArray(p)) return [p[0] || '', p[1] || ''];
535
+ if (isRec(p)) return [p.title || '', p.text || p.d || ''];
536
+ return ['', String(p == null ? '' : p)];
537
+ }
469
538
  function soWhatBar(s, text) {
470
539
  const R = REGIONS.regionOf('exhibit', 'annotation') ||
471
540
  { x: MX, y: PT.exhibit.soWhatY, w: CW, h: 0.62 };
@@ -678,10 +747,28 @@ function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
678
747
  one(0, by, PW, bh, img, false);
679
748
  capY = by + bh;
680
749
  } else {
681
- const fh = Math.min(ih, CW / ratio);
682
- const fy = iy + Math.max(0, (ih - fh) / 2);
750
+ /* full:图在上、要点在下(HTML half 版式有「图+注」,full 无注;B 通道补齐要点以过保真门禁) */
751
+ const ptsF = (points || []).filter(Boolean);
752
+ const pfRowH = 0.5;
753
+ const pfH = ptsF.length ? Math.min(ptsF.length * pfRowH, Math.max(0, ih - 1.8)) : 0;
754
+ const fh = Math.max(1.0, Math.min(ih - pfH, CW / ratio));
755
+ const fy = iy + Math.max(0, (ih - pfH - fh) / 2);
683
756
  one(MX, fy, CW, fh, img, false);
684
- capY = fy + fh;
757
+ const pfCap = Math.max(0, Math.floor(pfH / pfRowH));
758
+ ptsF.slice(0, pfCap).forEach((pt, i) => {
759
+ const kk = Array.isArray(pt) ? String(pt[0] || '') : String((pt && pt.t) || '');
760
+ const vv = Array.isArray(pt) ? String(pt[1] || '')
761
+ : (pt && typeof pt === 'object' ? String(pt.d || '') : String(pt == null ? '' : pt));
762
+ const ly = fy + fh + 0.08 + i * pfRowH;
763
+ s.addShape('rect', { x: MX, y: ly + pfRowH / 2 - 0.06, w: 0.12, h: 0.12,
764
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
765
+ s.addText([
766
+ { text: kk + (kk && vv ? ' ' : ''), options: { fontSize: sz(12), bold: true, color: STYLE.ink } },
767
+ { text: vv, options: { fontSize: sz(11), color: STYLE.body } },
768
+ ], { x: MX + 0.26, y: ly, w: CW - 0.32, h: pfRowH - 0.04,
769
+ fontFace: STYLE.font, valign: 'middle' });
770
+ });
771
+ capY = fy + fh + pfH;
685
772
  }
686
773
  return capY;
687
774
  }
@@ -703,13 +790,22 @@ function chartBottom(hasSoWhat, hasFootnote) {
703
790
  * chart.series(可选):[{name, values}…] 多系列(dualline / stack 用) */
704
791
  function chartSeries(c) {
705
792
  const labels = (c.labels || []).map(String);
793
+ /* 非数值警告:Number('x') → NaN 会导致图表数据为空;提醒用户检查模型(不中断构建) */
794
+ const warnBad = (vals, where) => {
795
+ const bad = (vals || []).filter(v => typeof v !== 'number' || !isFinite(v));
796
+ if (bad.length) console.warn(`[build_pptx] 警告:${where} 含 ${bad.length} 个非数值(${JSON.stringify(bad.slice(0, 3))}),图表该系列数据将为空`);
797
+ };
706
798
  if (Array.isArray(c.series) && c.series.length) {
707
- return c.series.map((se, i) => ({
708
- name: se.name || ('系列' + (i + 1)),
709
- labels: labels,
710
- values: (se.values || []).map(Number),
711
- }));
799
+ return c.series.map((se, i) => {
800
+ warnBad(se.values, `series[${i}]${se.name ? '(' + se.name + ')' : ''}`);
801
+ return {
802
+ name: se.name || ('系列' + (i + 1)),
803
+ labels: labels,
804
+ values: (se.values || []).map(Number),
805
+ };
806
+ });
712
807
  }
808
+ warnBad(c.values, 'chart.values');
713
809
  return [{ name: '数值', labels: labels, values: (c.values || []).map(Number) }];
714
810
  }
715
811
  /* ══ 图表登记(单源 scripts/layout-constants.json · charts.registry)══
@@ -1816,7 +1912,7 @@ CONTENT.sections.forEach((sec) => {
1816
1912
  const M = PT.metrics;
1817
1913
  const reg = REGIONS.regionOf('metrics', 'primary') ||
1818
1914
  { x: MX, y: M.bandY, w: CW, h: M.cardH };
1819
- const ms = sec.metrics || [];
1915
+ const ms = (sec.metrics || []).map(metricKV);
1820
1916
  const n = Math.max(1, ms.length);
1821
1917
  const g = cols(reg.w, n, M.gap);
1822
1918
  const mw = Math.min(M.maxW, g.w);
@@ -1844,16 +1940,22 @@ CONTENT.sections.forEach((sec) => {
1844
1940
  if (hro[2]) s.addText('▲ ' + hro[2], { x: hreg.x + 0.05, y: hreg.y + 2.1, w: hreg.w - 0.4, h: 0.4,
1845
1941
  fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.accent });
1846
1942
  const kdivX = MX + CW * K.dividerX;
1847
- s.addShape('rect', { x: kdivX, y: K.heroY + 0.15, w: 0.025, h: 3.1,
1943
+ /* R6:竖分隔线高度取原设计值与注释带感知下界的较小者——写死 3.1 会在
1944
+ so-what/来源行并存时越过注释带顶(ANNOTATION_BAND_OVERLAP),而无注释时
1945
+ 不得比原设计更长(回归:smoke business-blue fixture slide 7)。 */
1946
+ const kpiBottom = chartBottom(!!sec.soWhat, !!sec.footnote) - 0.05;
1947
+ s.addShape('rect', { x: kdivX, y: K.heroY + 0.15, w: 0.025,
1948
+ h: Math.min(3.1, Math.max(0.6, kpiBottom - (K.heroY + 0.15))),
1848
1949
  fill: { color: STYLE.line }, line: { type: 'none' } });
1849
- /* 支撑指标行:按可用高度收敛行高并截断,保证不越过 contentBottom */
1950
+ /* 右列:支撑指标行在上、要点在下,共用注释带感知高度;禁越过注释带顶 */
1850
1951
  const mreg = REGIONS.regionOf('kpi', 'metrics') ||
1851
1952
  { x: kdivX + 0.35, y: K.metricY0, w: CW * (1 - K.dividerX) - 0.5, h: CONTENT_BOTTOM - K.metricY0 };
1953
+ const kAvail = Math.max(0.8, kpiBottom - mreg.y);
1852
1954
  const kmets = sec.metrics || [];
1853
- const kCap = Math.max(1, Math.floor((CONTENT_BOTTOM - mreg.y) / K.metricRowH));
1955
+ const kCap = Math.max(1, Math.floor(kAvail / K.metricRowH));
1854
1956
  const kRows = Math.min(kmets.length, kCap);
1855
- const kRowH = fitRowH(CONTENT_BOTTOM - mreg.y, kRows, K.metricRowH, 0.42);
1856
- kmets.slice(0, kRows).forEach((m, i) => {
1957
+ const kRowH = fitRowH(kAvail, kRows, K.metricRowH, 0.42);
1958
+ kmets.slice(0, kRows).map(metricKV).forEach((m, i) => {
1857
1959
  const ky = mreg.y + i * kRowH;
1858
1960
  s.addText(m[0], { x: mreg.x, y: ky, w: mreg.w, h: Math.min(0.55, kRowH * 0.62),
1859
1961
  fontFace: STYLE.fontDisplay, fontSize: sz(22), bold: true, color: STYLE.accent });
@@ -1861,6 +1963,22 @@ CONTENT.sections.forEach((sec) => {
1861
1963
  h: Math.max(0.24, kRowH - Math.min(0.55, kRowH * 0.62)), fontFace: STYLE.font,
1862
1964
  fontSize: sz(10.5), color: STYLE.faint });
1863
1965
  });
1966
+ /* 要点:指标行下方续排,k/v 双色(与 bar 侧栏同口径);无位则静默舍去 */
1967
+ const kpts = (sec.points || []).filter(Boolean);
1968
+ const kpRowH = 0.5;
1969
+ const kpY0 = mreg.y + kRows * kRowH + 0.12;
1970
+ const kpCap = Math.max(0, Math.floor((kpiBottom - kpY0) / kpRowH));
1971
+ kpts.slice(0, kpCap).forEach((p, i) => {
1972
+ const kv = pointKV(p), kk = kv[0], vv = kv[1];
1973
+ const ky = kpY0 + i * kpRowH;
1974
+ s.addShape('rect', { x: mreg.x, y: ky + kpRowH / 2 - 0.06, w: 0.12, h: 0.12,
1975
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
1976
+ s.addText([
1977
+ { text: kk + (kk && vv ? ' ' : ''), options: { fontSize: sz(12), bold: true, color: STYLE.ink } },
1978
+ { text: vv, options: { fontSize: sz(11), color: STYLE.body } },
1979
+ ], { x: mreg.x + 0.26, y: ky, w: mreg.w - 0.32, h: kpRowH - 0.04,
1980
+ fontFace: STYLE.font, valign: 'middle' });
1981
+ });
1864
1982
  } else if (type === 'comparison') {
1865
1983
  const C2 = PT.comparison;
1866
1984
  const lreg = REGIONS.regionOf('comparison', 'left') ||
@@ -1876,7 +1994,7 @@ CONTENT.sections.forEach((sec) => {
1876
1994
  fill: { color: STYLE.soft }, line: { type: 'none' } });
1877
1995
  [{ x: lreg.x, d: sec.left || {} }, { x: rx2, d: sec.right || {} }].forEach((sd) => {
1878
1996
  const isRight = sd.x === rx2;
1879
- const pts = sd.d.points || [];
1997
+ const pts = (sd.d.points || []).map(pointKV);
1880
1998
  /* 行高按面板可用高度自适应 */
1881
1999
  const avail = cph - C2.titleH - 0.34;
1882
2000
  const rowH = fitRowH(avail, pts.length, C2.rowH, 0.34);
@@ -1936,14 +2054,19 @@ CONTENT.sections.forEach((sec) => {
1936
2054
  const layout = String(img.layout || (items.length > 1 ? 'grid' : 'full')).toLowerCase();
1937
2055
  const fit = String(img.fit || IMG.fitDefault || 'cover').toLowerCase();
1938
2056
  const isPh = !!img.placeholder;
1939
- const ireg = REGIONS.regionOf('image', 'primary', { top: bodyY, caption: !!img.caption }) ||
2057
+ /* R6:图片区须按注释带感知下界收敛——so-what/来源行并存时图片底边不得越过注释带顶
2058
+ (ANNOTATION_BAND_OVERLAP);有图注时图注沉底注释带,图片区上界取 min。 */
2059
+ const iBottom = img.caption ? Math.min(bodyBottom, CONTENT_BOTTOM_NOTE) : bodyBottom;
2060
+ const ireg = REGIONS.regionOf('image', 'primary',
2061
+ { top: bodyY, caption: !!img.caption, bottom: iBottom }) ||
1940
2062
  { x: MX, y: Math.max(IM.y, bodyY), w: CW, h: IM.h };
1941
2063
  const iy = ireg.y;
1942
2064
  const capH = img.caption ? 0.32 : 0;
1943
2065
  const ih = Math.max(1.2, ireg.h);
1944
2066
  const capYImg = imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1945
- if (img.caption) s.addText(img.caption, { x: MX, y: capYImg + 0.04, w: CW, h: capH,
1946
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
2067
+ /* 图注沉底注释带(annotation self 豁免位);要点存在时不随之上移,避免被判侵入注释带 */
2068
+ if (img.caption) s.addText(img.caption, { x: MX, y: Math.max(capYImg + 0.04, CONTENT_BOTTOM_NOTE + 0.03),
2069
+ w: CW, h: capH, fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1947
2070
  } else if (type === 'donut') {
1948
2071
  const dcn = sec.chart || {};
1949
2072
  if (dcn.labels && dcn.values) {
@@ -1960,21 +2083,48 @@ CONTENT.sections.forEach((sec) => {
1960
2083
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, valign: 'middle' });
1961
2084
  const n = Math.max(1, dcn.labels.length);
1962
2085
  const maxLegH = Math.max(1.2, bodyBottom - (d.centerY - 1.7));
1963
- const rowH = Math.min(d.legendRowH, maxLegH / n, 3.4 / n);
1964
- const ly0 = Math.min(d.centerY - (n * rowH) / 2 + 0.1, bodyBottom - n * rowH - 0.04);
2086
+ /* 要点:与图例共用右列。有要点时右列改顶对齐堆叠(图例行高压缩 + 要点续排),
2087
+ 顶锚取 bodyY 以吃满纵向空间;无要点时保持原有环图居中图例(不改变现有版式)。 */
2088
+ const dpts = (sec.points || []).filter(Boolean);
2089
+ const dpRowH = 0.5;
2090
+ let legRowH, legY0;
2091
+ if (dpts.length) {
2092
+ const colAvail = Math.max(1.5, bodyBottom - bodyY - 0.04);
2093
+ const ptsH = dpts.length * dpRowH;
2094
+ legRowH = Math.max(0.32, Math.min(d.legendRowH, (colAvail - 0.1 - ptsH) / n));
2095
+ legY0 = bodyY;
2096
+ } else {
2097
+ legRowH = Math.min(d.legendRowH, maxLegH / n, 3.4 / n);
2098
+ legY0 = Math.min(d.centerY - (n * legRowH) / 2 + 0.1, bodyBottom - n * legRowH - 0.04);
2099
+ }
1965
2100
  const sum2 = total || 1;
1966
2101
  dcn.labels.forEach((lb, i) => {
1967
2102
  const v = dcn.values[i] || 0;
1968
- const y = ly0 + i * rowH;
2103
+ const y = legY0 + i * legRowH;
1969
2104
  s.addShape('ellipse', { x: d.legendX, y: y + 0.08, w: 0.16, h: 0.16,
1970
2105
  fill: { color: dcols[i % dcols.length] }, line: { type: 'none' } });
1971
- s.addText(lb, { x: d.legendX + 0.3, y, w: d.legendLabelW, h: rowH, valign: 'middle',
2106
+ s.addText(lb, { x: d.legendX + 0.3, y, w: d.legendLabelW, h: legRowH, valign: 'middle',
1972
2107
  fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.ink });
1973
- s.addText(String(v) + (dcn.unit || ''), { x: d.legendX + 3.8, y, w: d.legendValW, h: rowH, align: 'right',
2108
+ s.addText(String(v) + (dcn.unit || ''), { x: d.legendX + 3.8, y, w: d.legendValW, h: legRowH, align: 'right',
1974
2109
  valign: 'middle', fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.body });
1975
- s.addText((Math.round(v / sum2 * 1000) / 10) + '%', { x: d.legendPctX, y, w: d.legendPctW, h: rowH,
2110
+ s.addText((Math.round(v / sum2 * 1000) / 10) + '%', { x: d.legendPctX, y, w: d.legendPctW, h: legRowH,
1976
2111
  align: 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1977
2112
  });
2113
+ const dpY0 = legY0 + n * legRowH + 0.1;
2114
+ const dpCap = Math.max(0, Math.floor((bodyBottom - dpY0) / dpRowH));
2115
+ dpts.slice(0, dpCap).forEach((p, i) => {
2116
+ const kk = Array.isArray(p) ? String(p[0] || '') : String((p && p.t) || '');
2117
+ const vv = Array.isArray(p) ? String(p[1] || '')
2118
+ : (p && typeof p === 'object' ? String(p.d || '') : String(p == null ? '' : p));
2119
+ const py = dpY0 + i * dpRowH;
2120
+ s.addShape('rect', { x: d.legendX, y: py + dpRowH / 2 - 0.06, w: 0.12, h: 0.12,
2121
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
2122
+ s.addText([
2123
+ { text: kk + (kk && vv ? ' ' : ''), options: { fontSize: sz(12), bold: true, color: STYLE.ink } },
2124
+ { text: vv, options: { fontSize: sz(11), color: STYLE.body } },
2125
+ ], { x: d.legendX + 0.26, y: py, w: d.legendLabelW + d.legendValW - 0.3, h: dpRowH - 0.04,
2126
+ fontFace: STYLE.font, valign: 'middle' });
2127
+ });
1978
2128
  }
1979
2129
  } else if (type === 'heatmap') {
1980
2130
  /* 热力矩阵:行 × 列 + 4 级色阶(surface-1 → accent-soft → accent-soft-2 → accent) */
@@ -2029,7 +2179,7 @@ CONTENT.sections.forEach((sec) => {
2029
2179
  const B = PT.bullet;
2030
2180
  const breg = REGIONS.regionOf('bullet', 'primary', { bottom: bodyBottom }) ||
2031
2181
  { x: MX, y: B.y, w: CW, h: bodyBottom - B.y };
2032
- const items = sec.items || [];
2182
+ const items = (sec.items || []).map(bulletKV);
2033
2183
  const n = Math.max(1, items.length);
2034
2184
  const availH = breg.h;
2035
2185
  const rowH = fitRowH(availH, n, B.rowH, 0.26);
@@ -2140,7 +2290,7 @@ CONTENT.sections.forEach((sec) => {
2140
2290
  const T = PT.timeline;
2141
2291
  const treg = REGIONS.regionOf('timeline', 'primary') ||
2142
2292
  { x: MX, y: T.labelY, w: CW, h: T.descY + T.descH - T.labelY };
2143
- const ps = sec.phases || [];
2293
+ const ps = (sec.phases || []).map(phaseKV);
2144
2294
  const n = Math.max(1, ps.length);
2145
2295
  const stepW = (treg.w - 1.0) / n;
2146
2296
  s.addShape('line', { x: treg.x + 0.2, y: T.axisY, w: treg.w - 0.4, h: 0.02, line: { color: STYLE.line, width: 1.5 } });
@@ -2171,8 +2321,7 @@ CONTENT.sections.forEach((sec) => {
2171
2321
  const sx = reg.x + cw + 0.28;
2172
2322
  const rowH = Math.min(0.72, (reg.h - 0.1) / Math.max(1, pts.length));
2173
2323
  pts.slice(0, 6).forEach((p, i) => {
2174
- const k = Array.isArray(p) ? String(p[0] || '') : String((p && p.t) || '');
2175
- const v = Array.isArray(p) ? String(p[1] || '') : String((p && p.d) || '');
2324
+ const kv = pointKV(p), k = kv[0], v = kv[1];
2176
2325
  const y = reg.y + i * rowH;
2177
2326
  s.addShape('rect', { x: sx, y: y + 0.12, w: 0.08, h: 0.08,
2178
2327
  fill: { color: STYLE.accent }, line: { type: 'none' } });
@@ -2188,7 +2337,7 @@ CONTENT.sections.forEach((sec) => {
2188
2337
  chartBlock(s, c, cx, reg.y, cw, Math.max(1.5, reg.h), dcols);
2189
2338
  }
2190
2339
  } else if (type === 'twocol' || type === 'threecol') {
2191
- const ps = sec.paragraphs || [];
2340
+ const ps = (sec.paragraphs || []).map(paraKV);
2192
2341
  const nCol = (type === 'threecol') ? 3 : 2;
2193
2342
  const gap = (type === 'threecol') ? PT.research.col3Gap : PT.twocol.colGap;
2194
2343
  /* R3:多栏均分必须用版心全宽。regionOf('twocol','primary') 返回的是「单栏宽」,
@@ -2213,7 +2362,10 @@ CONTENT.sections.forEach((sec) => {
2213
2362
  valign: 'top', lineSpacing: sz(fz * 1.5), paraSpaceBefore: sz(7) });
2214
2363
  });
2215
2364
  } else if (type === 'halftable') {
2216
- const yH = sec.lead ? PT.common.bodyYWithLead : PT.research.denseTableY;
2365
+ let yH = sec.lead ? PT.common.bodyYWithLead : PT.research.denseTableY;
2366
+ /* 徽标占位:exhibitNo 徽标占 CONTENT_TOP-0.06 起 0.3 高;bodyY 已含 +0.42 让位,
2367
+ 表格/图顶取 max 不得压入徽标(否则 Exhibit 编号与表头叠印) */
2368
+ if (exBadge) yH = Math.max(yH, bodyY);
2217
2369
  const lwH = CW * PT.research.halfTableW;
2218
2370
  if (sec.table && sec.table.head) {
2219
2371
  addTable(s, sec.table, MX, yH, lwH, bodyBottom - yH,
@@ -2294,11 +2446,37 @@ CONTENT.sections.forEach((sec) => {
2294
2446
  const cdC = PT.cards;
2295
2447
  const creg = REGIONS.regionOf('cards', 'primary', { bottom: bodyBottom }) ||
2296
2448
  { x: MX, y: cdC.startY, w: CW, h: bodyBottom - cdC.startY };
2297
- const cds = sec.cards || [];
2449
+ const cds = (sec.cards || []).map(cd =>
2450
+ /* 与 r_cards 同口径:{title,points,icon} 字典 | [标题, 描述] 数组 | 纯字符串标题 */
2451
+ isRec(cd) ? cd
2452
+ : (Array.isArray(cd)
2453
+ ? { title: cd[0] || '', points: cd.length > 1 ? [['', cd[1]]] : [] }
2454
+ : { title: String(cd == null ? '' : cd), points: [] }));
2298
2455
  const c = Math.min(sec.columns || 3, cds.length || 1);
2299
2456
  const r = Math.ceil((cds.length || 1) / c);
2300
2457
  const gw = (creg.w - (c - 1) * cdC.gap) / c;
2301
2458
  const gh = fitRowH(creg.h - (r - 1) * cdC.gap, r, cdC.maxH, 0.6);
2459
+ /* points 文本归一化(scaffold v9 契约 {title, points:[[k,v]]} 兼容三种形态) */
2460
+ const cardPtText = (pt) => {
2461
+ if (Array.isArray(pt)) {
2462
+ const k = pt[0] == null ? '' : String(pt[0]);
2463
+ const v = pt[1] == null ? '' : String(pt[1]);
2464
+ return k && v ? ('· ' + k + ' ' + v) : ('· ' + (k || v));
2465
+ }
2466
+ if (pt && typeof pt === 'object') {
2467
+ const t = String(pt.k || pt.t || '');
2468
+ const d = String(pt.v || pt.d || '');
2469
+ return '· ' + t + (d ? ' ' + d : '');
2470
+ }
2471
+ return '· ' + String(pt == null ? '' : pt);
2472
+ };
2473
+ /* 有限缩字号:按最拥挤卡片的要点文本量统一选档(12 起阶梯下探),行距随字号等比;
2474
+ 取代无脑 autofit shrink,保证估算口径与渲染一致(TEXT_OVERFLOW_VERTICAL) */
2475
+ const ptsBoxH = Math.max(0.4, gh - cdC.titleH - 0.34);
2476
+ const ptsFzB = cds.length ? Math.min(...cds.map(cd =>
2477
+ fitFont((cd.points || []).map(cardPtText), gw - 0.36, ptsBoxH,
2478
+ { max: 12, lineFactor: 1.5, gapFactor: 0 }))) : 12;
2479
+ const ptsFz = sz(ptsFzB);
2302
2480
  cds.forEach((cd, i) => {
2303
2481
  const col = i % c, row = Math.floor(i / c);
2304
2482
  const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
@@ -2308,24 +2486,14 @@ CONTENT.sections.forEach((sec) => {
2308
2486
  const ico = 0.18;
2309
2487
  const icoName = cd.icon || ICON_LIB.pickIconName(cd.title || '', i);
2310
2488
  addIcon(s, icoName, x + 0.18, y + 0.2, ico, STYLE.accent);
2311
- s.addText(cd.title, { x: x + 0.18 + ico + 0.1, y: y + 0.14, w: gw - 0.36 - ico - 0.1, h: cdC.titleH, fontFace: STYLE.font,
2489
+ s.addText(cd.title || '', { x: x + 0.18 + ico + 0.1, y: y + 0.14, w: gw - 0.36 - ico - 0.1, h: cdC.titleH, fontFace: STYLE.font,
2312
2490
  fontSize: sz(15), bold: true, color: STYLE.ink, fit: 'shrink' });
2313
2491
  /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2314
- const ptLines = (cd.points || []).map(pt => {
2315
- if (Array.isArray(pt)) {
2316
- const k = pt[0] == null ? '' : String(pt[0]);
2317
- const v = pt[1] == null ? '' : String(pt[1]);
2318
- return k && v ? ('· ' + k + ' ' + v) : ('· ' + (k || v));
2319
- }
2320
- if (pt && typeof pt === 'object') {
2321
- return '· ' + String(pt.t || '') + (pt.d ? ' ' + String(pt.d) : '');
2322
- }
2323
- return '· ' + String(pt == null ? '' : pt);
2324
- });
2492
+ const ptLines = (cd.points || []).map(cardPtText);
2325
2493
  s.addText(ptLines.map(t => ({ text: t + '\n',
2326
- options: { fontSize: sz(12), color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2327
- { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h: Math.max(0.4, gh - cdC.titleH - 0.34),
2328
- fontFace: STYLE.font, valign: 'top', lineSpacing: sz(12) * 1.5, fit: 'shrink' });
2494
+ options: { fontSize: ptsFz, color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2495
+ { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h: ptsBoxH,
2496
+ fontFace: STYLE.font, valign: 'top', lineSpacing: ptsFz * 1.5 });
2329
2497
  });
2330
2498
  } else if (type === 'split') {
2331
2499
  /* 双区自由组合页:左区与右区各可为 要点 / 图表 / 表格 / 图片。
@@ -2340,7 +2508,7 @@ CONTENT.sections.forEach((sec) => {
2340
2508
  const lt = (sec.left && sec.left.type) || 'points';
2341
2509
  /* 要点区渲染(左右两区共用;v8 起 right.type='points' 也可用——修正 v7 只在 schema 声明却未实现的问题) */
2342
2510
  const drawPoints = (el, x, w) => {
2343
- const pts = (el && el.points) || [];
2511
+ const pts = ((el && el.points) || []).map(pointKV);
2344
2512
  const ly0 = bodyY, availP = bodyBottom - ly0;
2345
2513
  const rowH = fitRowH(availP, pts.length, SP.rowH, 0.3);
2346
2514
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), w - 0.4, availP, { max: 14, gapFactor: 0.6 });
@@ -2415,9 +2583,12 @@ CONTENT.sections.forEach((sec) => {
2415
2583
  const nw = Math.min(D.nodeMaxW, (availW - (nn - 1) * D.nodeGap) / nn);
2416
2584
  nodes.forEach((nd, ni) => {
2417
2585
  const x = MX + D.layerBarW + 0.2 + ni * (nw + D.nodeGap);
2418
- const acc = isRec(nd) && nd.accent;
2419
- const ntt = isRec(nd) ? nd.t : nd;
2420
- const nds = isRec(nd) ? (nd.d || '') : '';
2586
+ /* 节点三形态:{t,d,accent} 字典 | [标题, 注解] 数组 | 纯字符串
2587
+ (scaffold_report 发射数组形态;不归一化则 addText 收数组直接抛 TypeError) */
2588
+ const _nd = Array.isArray(nd) ? { t: nd[0], d: nd[1] } : nd;
2589
+ const acc = isRec(_nd) && _nd.accent;
2590
+ const ntt = String(isRec(_nd) ? (_nd.t || '') : (_nd == null ? '' : _nd));
2591
+ const nds = String(isRec(_nd) ? (_nd.d || '') : '');
2421
2592
  s.addShape('roundRect', { x, y, w: nw, h: lh, rectRadius: 0.06,
2422
2593
  fill: { color: acc ? STYLE.soft : STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2423
2594
  /* 节点内标题/注解:注解底对齐会「浮回」标题上(R1)。改为标题顶对齐 + 注解钳在标题下边;
@@ -2500,7 +2671,7 @@ CONTENT.sections.forEach((sec) => {
2500
2671
  } else {
2501
2672
  /* points(默认):要点列表 + 可选右侧指标列(文本区与指标列严格分区,不重叠) */
2502
2673
  const P = PT.points;
2503
- const pts = sec.points || [];
2674
+ const pts = (sec.points || []).map(pointKV);
2504
2675
  const hasMetrics = !!(sec.metrics && sec.metrics.length);
2505
2676
  /* 指标列最多 3 个,列宽按剩余空间收敛,保证文本区不被侵占(原重叠缺陷) */
2506
2677
  const nM = hasMetrics ? Math.min(sec.metrics.length, 3) : 0;
@@ -2525,7 +2696,7 @@ CONTENT.sections.forEach((sec) => {
2525
2696
  if (hasMetrics) {
2526
2697
  const gx = PW - MX - nM * mw;
2527
2698
  const vSize = nM >= 3 ? sz(28) : sz(34);
2528
- sec.metrics.slice(0, nM).forEach((m, i) => {
2699
+ sec.metrics.slice(0, nM).map(metricKV).forEach((m, i) => {
2529
2700
  const x = gx + i * mw;
2530
2701
  s.addText(m[0], { x, y: P.metricY, w: mw - 0.2, h: P.metricValH, fontFace: STYLE.fontDisplay,
2531
2702
  fontSize: vSize, bold: true, color: STYLE.accent });
@@ -2557,8 +2728,14 @@ CONTENT.sections.forEach((sec) => {
2557
2728
  if (_infoRows && _infoRows.length && infoDataTableMode(sec) === 'notes') {
2558
2729
  _notes.push('数据表(' + type + '):\n' + _infoRows.map(r => r.join(' | ')).join('\n'));
2559
2730
  }
2560
- if (sec.steps) _notes.push('步骤:' + sec.steps.map(st => (isRec(st) ? st.t : st[0]) || '').join(' → '));
2561
- if (sec.items) _notes.push('达成对比:' + sec.items.map(it => it[0] + ' ' + it[1] + (it[2] != null ? '/' + it[2] : '')).join(';'));
2731
+ if (sec.steps) _notes.push('步骤:' + sec.steps.map(st => {
2732
+ const t = isRec(st) ? st.t : (Array.isArray(st) ? st[0] : st);
2733
+ return t || '';
2734
+ }).join(' → '));
2735
+ if (sec.items) _notes.push('达成对比:' + sec.items.map(it => {
2736
+ const b = bulletKV(it);
2737
+ return String(b[0] || '') + (b[1] == null ? '' : ' ' + b[1]) + (b[2] == null ? '' : '/' + b[2]);
2738
+ }).join(';'));
2562
2739
  if (sec.levels) _notes.push('层级:' + sec.levels.map(l => (isRec(l) ? l.t : l[0]) || '').join(' → '));
2563
2740
  if (sec.soWhat) _notes.push('结论:' + sec.soWhat);
2564
2741
  if (sec.layoutPreset) _notes.push('布局骨架:' + sec.layoutPreset);
@@ -2623,4 +2800,8 @@ pptx.writeFile({ fileName: out }).then(() => {
2623
2800
  if (OVERLAP_PREEMIT.length) {
2624
2801
  console.warn('[build_pptx] PREEMIT_OVERLAP 共 ' + OVERLAP_PREEMIT.length + ' 处(发射前几何自检,详见上方 warn)——请先修区域分配再交付。');
2625
2802
  }
2803
+ }).catch((e) => {
2804
+ /* 写盘失败(无权限/磁盘满/路径非法)必须干净报错,不得 unhandled rejection 裸堆栈 */
2805
+ console.error('[build_pptx] FAIL: 写入 PPTX 失败 ' + out + ':' + (e && e.message));
2806
+ process.exit(1);
2626
2807
  });
@@ -121,8 +121,16 @@ def main() -> int:
121
121
  if not QUERIES.exists():
122
122
  print(f'MISSING {QUERIES}')
123
123
  return 1
124
- data = json.loads(QUERIES.read_text(encoding='utf-8'))
125
- desc = load_description()
124
+ try:
125
+ data = json.loads(QUERIES.read_text(encoding='utf-8'))
126
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
127
+ print(f'错误:无法解析 {QUERIES}: {e}')
128
+ return 2
129
+ try:
130
+ desc = load_description()
131
+ except (OSError, SystemExit) as e:
132
+ print(f'错误:{e}')
133
+ return 2
126
134
  rows, fails, total = evaluate(desc, data)
127
135
  n_pos = sum(1 for r in rows if r['expect'] == 'hit')
128
136
  n_neg = sum(1 for r in rows if r['expect'] == 'miss')
@@ -111,20 +111,31 @@ def main():
111
111
  if not path.exists():
112
112
  print(f"文件不存在: {path}")
113
113
  return 2
114
- txt = path.read_text(encoding='utf-8')
114
+ try:
115
+ txt = path.read_text(encoding='utf-8')
116
+ except UnicodeDecodeError as e:
117
+ print(f"文件不是合法 UTF-8: {path}({e})")
118
+ return 2
119
+ except OSError as e:
120
+ print(f"文件读取失败: {path}({e})")
121
+ return 2
115
122
 
116
- m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
123
+ # 括号感知抽取:模型字符串含 `};` 时正则 `\{[\s\S]*?\}` 会提前截断;
124
+ # 与 validate_report._extract_model 同口径(JSONDecoder.raw_decode)。
125
+ m = re.search(r'window\.REPORT_MODEL\s*=\s*', txt)
117
126
  if not m:
118
127
  print("未找到 window.REPORT_MODEL —— 报告未内嵌内容模型。")
119
128
  print("请按 references/pptx-export.md 在报告 <script> 中补齐模型后再抽取。")
120
129
  return 1
121
- raw = m.group(1)
122
130
  try:
123
- model = json.loads(raw)
131
+ model, _end = json.JSONDecoder().raw_decode(txt, m.end())
124
132
  except json.JSONDecodeError as e:
125
133
  print(f"REPORT_MODEL 不是合法 JSON: {e}")
126
134
  print("注意:模型必须是严格 JSON(双引号、无尾逗号、无注释)。")
127
135
  return 1
136
+ if not isinstance(model, dict):
137
+ print("REPORT_MODEL 根节点必须是 JSON 对象。")
138
+ return 1
128
139
 
129
140
  # 模型字符串字段净化:禁止把 HTML 标签当纯文本写入(cite 只允许 [n])。
130
141
  # 泄漏标签会在 PPTX/预览里原样露出 <a class="cite"…>,属交付硬缺陷。
@@ -207,7 +218,7 @@ def main():
207
218
  plain = re.sub(r'<[^>]+>', ' ', txt)
208
219
  secs = model.get('sections') or []
209
220
  miss_titles = [str(s.get('title'))[:14] for s in secs
210
- if s.get('title') and str(s.get('title')) not in plain]
221
+ if isinstance(s, dict) and s.get('title') and str(s.get('title')) not in plain]
211
222
  if miss_titles:
212
223
  issues.append(f"{len(miss_titles)} 个章节标题未在正文出现: {miss_titles[:3]}")
213
224
  n_ag = len(model.get('agenda') or [])