@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.
- package/CHANGELOG.md +159 -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 +4 -17
- package/topmind-cover/README.md +5 -18
- package/topmind-cover/SKILL.md +28 -35
- 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 +275 -249
- 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
|
@@ -58,7 +58,14 @@
|
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
60
|
/* eslint-disable */
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
682
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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(
|
|
1955
|
+
const kCap = Math.max(1, Math.floor(kAvail / K.metricRowH));
|
|
1854
1956
|
const kRows = Math.min(kmets.length, kCap);
|
|
1855
|
-
const kRowH = fitRowH(
|
|
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
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
2327
|
-
{ x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h:
|
|
2328
|
-
fontFace: STYLE.font, valign: 'top', lineSpacing:
|
|
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
|
-
|
|
2419
|
-
|
|
2420
|
-
const
|
|
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 =>
|
|
2561
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 [])
|