dsh-ppt-studio 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +38 -18
  2. package/agent-presets/ppt/agent.cordis.yml +22 -15
  3. package/cordis.patch.yml +19 -14
  4. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
  5. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
  6. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -0
  7. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
  8. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
  9. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
  10. package/examples/fx/fx-pro/preview/deck.html +1 -1
  11. package/examples/fx/fx-pro/preview/measured.json +1 -1
  12. package/examples/fx/fx-pro.pptx +0 -0
  13. package/examples/fx/seed.pptx +0 -0
  14. package/examples/fx/single.pptx +0 -0
  15. package/examples/fx/spliced.pptx +0 -0
  16. package/examples/smoke/imported/deck.yaml +6 -6
  17. package/examples/smoke/imported/import-styles.json +1 -33
  18. package/examples/smoke/imported/loss-audit.json +99 -0
  19. package/examples/smoke/imported/pages/slide_03.yaml +5 -36
  20. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
  21. package/examples/smoke/imported/preview-rt/deck.html +1 -1
  22. package/examples/smoke/imported/preview-rt/layout.json +26 -78
  23. package/examples/smoke/imported/reference/previews/03.png +0 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/03_pages_03_chart.html +1 -1
  27. package/examples/smoke/preview/deck.html +1 -1
  28. package/lib/capabilities.js +189 -0
  29. package/lib/home.js +32 -0
  30. package/lib/index.js +278 -112
  31. package/lib/office-qa.js +111 -0
  32. package/lib/pptd/export-pptx.js +159 -10
  33. package/lib/pptd/import-pptx.js +12 -0
  34. package/lib/pptd/loss-audit.js +363 -0
  35. package/lib/pptd/nativeChart.js +454 -0
  36. package/lib/pptd/schema.js +48 -1
  37. package/lib/pptd/svgCharts.js +180 -37
  38. package/lib/pptxPy.js +96 -11
  39. package/lib/preset-delivery.js +107 -9
  40. package/lib/preview-server.js +7 -5
  41. package/lib/router.js +3 -2
  42. package/lib/state.js +8 -6
  43. package/lib/templates.js +4 -4
  44. package/lib/tools.js +81 -21
  45. package/package.json +2 -1
  46. package/scripts/audit-manual-facts.mjs +4 -2
  47. package/scripts/e2e-1.0.mjs +2 -2
  48. package/scripts/fixtures/workflow-baseline.json +10 -10
  49. package/scripts/install.mjs +13 -11
  50. package/scripts/smoke.mjs +390 -66
  51. package/scripts/verify-loss-audit.mjs +245 -0
  52. package/scripts/verify-native-charts.mjs +529 -0
  53. package/scripts/verify-preset-scope.mjs +593 -0
  54. package/skills/ppt-studio-data/SKILL.md +5 -7
  55. package/skills/ppt-studio-manual/SKILL.md +2 -2
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * 图表 SVG 生成(HTML 预览用):bar / line / pie。
3
- * ppbx 导出用矢量拼绘(见 export-pptx.js),两处共用数据解析。
3
+ * 导出侧:原生可编辑图表(默认,见 nativeChart.js)或矢量拼绘(`render: 'vector'`,见 export-pptx.js 的 chartSp)。
4
+ *
5
+ * 第三轮(2026-09-27)**预览与成品对齐**:原生图表自带坐标轴/网格线/刻度标签/图例/数据标签,
6
+ * SVG 也必须画这些——否则"预览 ≠ 成品",模型与用户会被误导(ppt_shot/ppt_measure 的输入就是这份 SVG)。
7
+ * 三层同源:`chartOptions()`(schema.js 给默认)→ `resolveChart()`(本文件,落地 auto 默认)→
8
+ * 预览 SVG 与导出 chartN.xml 都读同一个 resolveChart 结果。
4
9
  */
10
+ import { chartOptions } from './schema.js'
5
11
 
6
12
  /** 解析 chart 数据为 { categories: [...], values: [number], series: [{name, values}] } */
7
13
  export function chartData(chart) {
@@ -33,6 +39,33 @@ export function chartData(chart) {
33
39
  return out
34
40
  }
35
41
 
42
+ /**
43
+ * 解析成"预览/导出共用"的图表模型(**唯一真相**):
44
+ * 类别、系列(名 + 数值)、分类列名、配色、已落地的渲染选项(render/legend/labels/axes)。
45
+ */
46
+ export function resolveChart(chart) {
47
+ const data = chartData(chart)
48
+ const cols = chart?.data?.cols ?? []
49
+ const series = data.series.map((s, i) => ({ name: String(s.name ?? `系列${i + 1}`), values: s.values }))
50
+ const opt = chartOptions(chart)
51
+ const multi = series.length > 1
52
+ const vector = opt.render === 'vector'
53
+ // auto 语义(schema.js chartOptions 注释里有同一份说明):多系列/饼图才有必要画图例;单系列图例是噪音。
54
+ const legend = vector ? false : (opt.legend === 'auto' ? (chart?.type === 'pie' || multi ? 'bottom' : false) : opt.legend)
55
+ // auto 语义:饼图的"百分比 + 分类名"是必要信息;柱/线逐点标数字会糊。
56
+ const labels = vector ? false : (opt.labels === 'auto' ? (chart?.type === 'pie') : opt.labels)
57
+ // 矢量拼绘(chartSp)不画坐标轴/网格线 ⇒ 预览也不画(否则"预览 ≠ 成品")。schema 对 vector+axes 显式值直接报错。
58
+ const axes = vector ? false : opt.axes
59
+ return {
60
+ type: chart?.type,
61
+ categories: data.categories,
62
+ series,
63
+ catHeader: String(cols[0] ?? '类别'),
64
+ colors: chartColors(chart),
65
+ opts: { render: opt.render, legend, labels, axes },
66
+ }
67
+ }
68
+
36
69
  /**
37
70
  * 图表数据检查(P1 修复,v0.6.1):把"静默失败"变显式。
38
71
  * 返回问题描述字符串或 null(正常)。
@@ -58,54 +91,86 @@ export function chartColors(chart) {
58
91
  return (chart.colors?.length ? chart.colors : PALETTE).map((c) => c)
59
92
  }
60
93
 
61
- /** 生成 SVG 字符串;viewBox 由调用方传入(px 坐标)。
62
- * padding: 坐标轴留白 [t, r, b, l] 默认 [8, 12, 24, 12]。
94
+ const GRID = '#e2e8f0'
95
+ const AXIS = '#cbd5e1'
96
+ const TICK_TEXT = '#64748b'
97
+
98
+ /**
99
+ * 生成 SVG 字符串;viewBox 由调用方传入(px 坐标)。
100
+ * 与成品对齐的内容:坐标轴(轴线+刻度)、网格线、刻度标签、分类标签、图例、数据标签。
63
101
  */
64
102
  export function chartSvg(chart, w, h) {
65
- const data = chartData(chart)
66
- const colors = chartColors(chart)
67
- const pad = { t: 12, r: 12, b: data.categories.length ? 28 : 12, l: 36 }
103
+ const m = resolveChart(chart)
104
+ const colors = m.colors
105
+ const colorOf = (i) => colors[i % colors.length]
106
+ const n = m.categories.length
107
+ const vals = m.series.flatMap((s) => s.values)
108
+ const maxV = Math.max(1e-9, ...vals)
109
+ const minV = Math.min(0, ...vals)
110
+ const span = Math.max(1e-9, maxV - minV)
111
+ const showAxes = m.type !== 'pie' && m.opts.axes !== false
112
+
113
+ // ── 图例(与原生图表的 c:legend 同位置)──
114
+ const legendItems = m.type === 'pie'
115
+ ? m.categories.map((c, i) => ({ label: c, color: colorOf(i) }))
116
+ : m.series.map((s, i) => ({ label: s.name, color: colorOf(i) }))
117
+ const legend = m.opts.legend ? layoutLegend(legendItems, w, h, m.opts.legend) : null
118
+
119
+ const pad = { t: 12, r: 12, b: showAxes && n ? 28 : 12, l: showAxes ? 36 : 12 }
120
+ if (legend?.pos === 'top') pad.t += legend.h
121
+ if (legend?.pos === 'bottom') pad.b += legend.h
122
+ if (legend?.pos === 'left') pad.l += legend.w
123
+ if (legend?.pos === 'right') pad.r += legend.w
124
+ if (m.opts.labels && m.type !== 'pie') pad.t += 12 // 数据标签要地方
68
125
  const iw = Math.max(1, w - pad.l - pad.r)
69
126
  const ih = Math.max(1, h - pad.t - pad.b)
70
- const maxV = Math.max(1e-9, ...data.series.flatMap((s) => s.values))
71
- const minV = Math.min(0, ...data.series.flatMap((s) => s.values))
72
- const span = Math.max(1e-9, maxV - minV)
127
+ const slot = iw / Math.max(1, n)
73
128
  const y0 = pad.t + (maxV / span) * ih
74
129
  const y = (v) => pad.t + ((maxV - v) / span) * ih
75
130
  const parts = []
76
- // gridline + y 轴刻度
77
- for (let i = 0; i <= 4; i++) {
78
- const gy = pad.t + (ih * i) / 4
79
- parts.push(`<line x1="${pad.l}" y1="${gy}" x2="${w - pad.r}" y2="${gy}" stroke="#e2e8f0" stroke-width="1"/>`)
80
- const gv = maxV - (span * i) / 4
81
- parts.push(`<text x="${pad.l - 4}" y="${gy + 3}" font-size="9" fill="#64748b" text-anchor="end">${fmtNum(gv)}</text>`)
131
+
132
+ // 网格线 + 值轴刻度标签 + 刻度线
133
+ if (showAxes) {
134
+ for (let i = 0; i <= 4; i++) {
135
+ const gy = pad.t + (ih * i) / 4
136
+ parts.push(`<line x1="${pad.l}" y1="${gy.toFixed(1)}" x2="${(w - pad.r).toFixed(1)}" y2="${gy.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`)
137
+ parts.push(`<line x1="${pad.l - 3}" y1="${gy.toFixed(1)}" x2="${pad.l}" y2="${gy.toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
138
+ const gv = maxV - (span * i) / 4
139
+ parts.push(`<text x="${pad.l - 5}" y="${(gy + 3).toFixed(1)}" font-size="9" fill="${TICK_TEXT}" text-anchor="end">${fmtNum(gv)}</text>`)
140
+ }
141
+ // 坐标轴轴线(原生图表有,预览以前没有 ⇒ 补齐)
142
+ parts.push(`<line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${(pad.t + ih).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
143
+ parts.push(`<line x1="${pad.l}" y1="${(pad.t + ih).toFixed(1)}" x2="${(w - pad.r).toFixed(1)}" y2="${(pad.t + ih).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
82
144
  }
83
- const n = data.categories.length
84
- const grouped = data.series.length > 1 && chart.type === 'bar'
85
- const slot = iw / Math.max(1, n)
86
- if (chart.type === 'bar') {
87
- const sGroup = grouped ? 0.72 : 1
88
- data.series.forEach((s, si) => {
145
+
146
+ if (m.type === 'bar') {
147
+ const grouped = m.series.length > 1
148
+ m.series.forEach((s, si) => {
89
149
  s.values.forEach((v, i) => {
90
150
  const cx = pad.l + slot * i + slot / 2
91
- const bw = groupWidth(slot, data.series.length) * sGroup
92
- const off = grouped ? (si - (data.series.length - 1) / 2) * (bw + 1.5) : 0
151
+ const bw = groupWidth(slot, m.series.length)
152
+ const off = grouped ? (si - (m.series.length - 1) / 2) * (bw + 1.5) : 0
93
153
  const bh = Math.max(1, Math.abs(y(v) - y0))
94
154
  const by = v >= 0 ? y(v) : y0
95
- parts.push(`<rect x="${cx + off - bw / 2}" y="${by}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colors[si % colors.length]}"><title>${esc(data.categories[i])}: ${v}</title></rect>`)
155
+ parts.push(`<rect x="${(cx + off - bw / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colorOf(si)}"><title>${esc(s.name)} · ${esc(m.categories[i])}: ${v}</title></rect>`)
156
+ if (m.opts.labels) parts.push(labelText(cx + off, by - 4, fmtNum(v)))
96
157
  })
97
158
  })
98
- } else if (chart.type === 'line') {
99
- data.series.forEach((s, si) => {
159
+ } else if (m.type === 'line') {
160
+ m.series.forEach((s, si) => {
100
161
  const pts = s.values.map((v, i) => `${(pad.l + slot * i + slot / 2).toFixed(1)},${y(v).toFixed(1)}`)
101
- parts.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="${colors[si % colors.length]}" stroke-width="2"/>`)
102
- pts.forEach((p, i) => parts.push(`<circle cx="${p.split(',')[0]}" cy="${p.split(',')[1]}" r="2.5" fill="${colors[si % colors.length]}"><title>${esc(data.categories[i])}: ${s.values[i]}</title></circle>`))
162
+ parts.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="${colorOf(si)}" stroke-width="2"/>`)
163
+ pts.forEach((p, i) => {
164
+ const [px, py] = p.split(',')
165
+ parts.push(`<circle cx="${px}" cy="${py}" r="2.5" fill="${colorOf(si)}"><title>${esc(s.name)} · ${esc(m.categories[i])}: ${s.values[i]}</title></circle>`)
166
+ if (m.opts.labels) parts.push(labelText(Number(px), Number(py) - 6, fmtNum(s.values[i])))
167
+ })
103
168
  })
104
- } else if (chart.type === 'pie') {
105
- const v = data.series[0].values
169
+ } else if (m.type === 'pie') {
170
+ const v = m.series[0]?.values ?? []
106
171
  const total = v.reduce((a, b) => a + Math.max(0, b), 0) || 1
107
- const r = Math.min(iw, ih) / 2 - 2
108
- const cx = w / 2
172
+ const r = Math.min(iw, ih) / 2 - (m.opts.labels ? 6 : 2)
173
+ const cx = pad.l + iw / 2
109
174
  const cy = pad.t + ih / 2
110
175
  let angle = -Math.PI / 2
111
176
  v.forEach((val, i) => {
@@ -117,22 +182,100 @@ export function chartSvg(chart, w, h) {
117
182
  const x2 = cx + r * Math.cos(a2)
118
183
  const y2 = cy + r * Math.sin(a2)
119
184
  if (frac > 0.0005) {
120
- parts.push(`<path d="M${cx},${cy} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} Z" fill="${colors[i % colors.length]}"><title>${esc(data.categories[i])}: ${val}</title></path>`)
185
+ parts.push(`<path d="M${cx},${cy} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} Z" fill="${colorOf(i)}"><title>${esc(m.categories[i])}: ${val}</title></path>`)
186
+ }
187
+ if (m.opts.labels && frac > 0.0005) {
188
+ const mid = angle + frac * Math.PI
189
+ const lx = cx + (r + 10) * Math.cos(mid)
190
+ const ly = cy + (r + 10) * Math.sin(mid)
191
+ parts.push(labelText(lx, ly + 3, `${m.categories[i]} ${Math.round(frac * 100)}%`, mid > Math.PI / 2 || mid < -Math.PI / 2 ? 'end' : 'start'))
121
192
  }
122
193
  angle = a2
123
194
  })
124
195
  }
125
- data.categories.forEach((c, i) => {
126
- if (chart.type === 'pie') return
196
+
197
+ m.categories.forEach((c, i) => {
198
+ if (m.type === 'pie') return
199
+ if (!showAxes) return
127
200
  const cx = pad.l + slot * i + slot / 2
128
201
  const label = c.length > 8 ? c.slice(0, 8) + '…' : c
129
- parts.push(`<text x="${cx.toFixed(1)}" y="${h - 8}" font-size="9" fill="#475569" text-anchor="middle">${esc(label)}</text>`)
202
+ parts.push(`<text x="${cx.toFixed(1)}" y="${(pad.t + ih + 12).toFixed(1)}" font-size="9" fill="#475569" text-anchor="middle">${esc(label)}</text>`)
203
+ parts.push(`<line x1="${cx.toFixed(1)}" y1="${(pad.t + ih).toFixed(1)}" x2="${cx.toFixed(1)}" y2="${(pad.t + ih + 3).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
130
204
  })
205
+
206
+ if (legend) parts.push(...renderLegend(legend, w, h, pad, legendItems))
207
+
131
208
  return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg" style="display:block">${parts.join('')}</svg>`
132
209
  }
133
210
 
211
+ function labelText(x, y, text, anchor = 'middle') {
212
+ return `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="9" fill="#334155" text-anchor="${anchor}">${esc(text)}</text>`
213
+ }
214
+
215
+ /** 估算文本宽度(CJK 按 1em,其余 0.55em)——只用于图例排布,不需要精确。 */
216
+ function textW(s, size) {
217
+ let w = 0
218
+ for (const ch of String(s)) w += /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/.test(ch) ? size : size * 0.55
219
+ return w
220
+ }
221
+
222
+ /** 图例占位计算:bottom/top 横排(超宽自动换行),left/right 竖排。 */
223
+ function layoutLegend(items, w, h, pos) {
224
+ const SIZE = 9
225
+ const swatch = 8
226
+ const gap = 6
227
+ const rowH = 14
228
+ if (pos === 'left' || pos === 'right') {
229
+ const maxItem = Math.max(20, ...items.map((it) => textW(it.label, SIZE))) + swatch + gap + 6
230
+ return { pos, w: Math.min(Math.round(w * 0.32), Math.ceil(maxItem)), h: rowH, rows: 1 }
231
+ }
232
+ const avail = Math.max(20, w - 16)
233
+ const items2 = items.map((it) => textW(it.label, SIZE) + swatch + gap + 8)
234
+ let rows = 1
235
+ let acc = 0
236
+ for (const iw2 of items2) {
237
+ if (acc > 0 && acc + iw2 > avail) { rows++; acc = iw2 } else acc += iw2
238
+ }
239
+ return { pos, w: 0, h: rows * rowH + 2, rows }
240
+ }
241
+
242
+ function renderLegend(legend, w, h, pad, items) {
243
+ const out = []
244
+ const SIZE = 9
245
+ const swatch = 8
246
+ const gap = 6
247
+ const rowH = 14
248
+ const widths = items.map((it) => textW(it.label, SIZE) + swatch + gap + 8)
249
+ if (legend.pos === 'left' || legend.pos === 'right') {
250
+ const totalH = items.length * rowH
251
+ let y = Math.max(pad.t, (h - totalH) / 2)
252
+ const x = legend.pos === 'left' ? 6 : w - legend.w + 6
253
+ for (let i = 0; i < items.length; i++) {
254
+ out.push(`<rect x="${x}" y="${(y + 3).toFixed(1)}" width="${swatch}" height="${swatch}" fill="${items[i].color}" rx="1"/>`)
255
+ out.push(`<text x="${x + swatch + gap}" y="${(y + 10).toFixed(1)}" font-size="${SIZE}" fill="${TICK_TEXT}">${esc(items[i].label)}</text>`)
256
+ y += rowH
257
+ }
258
+ return out
259
+ }
260
+ const top = legend.pos === 'top'
261
+ const avail = Math.max(20, w - 16)
262
+ let row = 0
263
+ let acc = 0
264
+ const baseY = top ? 4 : h - legend.h + 2
265
+ for (let i = 0; i < items.length; i++) {
266
+ if (acc > 0 && acc + widths[i] > avail) { row++; acc = 0 }
267
+ const x = 8 + acc
268
+ const y = baseY + row * rowH
269
+ out.push(`<rect x="${x}" y="${(y + 3).toFixed(1)}" width="${swatch}" height="${swatch}" fill="${items[i].color}" rx="1"/>`)
270
+ out.push(`<text x="${x + swatch + gap}" y="${(y + 10).toFixed(1)}" font-size="${SIZE}" fill="${TICK_TEXT}">${esc(items[i].label)}</text>`)
271
+ acc += widths[i]
272
+ }
273
+ return out
274
+ }
275
+
134
276
  function groupWidth(slot, count) {
135
- const bw = count > 1 ? slot * 0.7 : slot * 0.62
277
+ // 与原生图表 gapWidth=100(柱宽 = 1/2 槽)/ gapWidth=100+聚类(≈0.7/n 槽)对齐
278
+ const bw = count > 1 ? slot * 0.7 : slot * 0.5
136
279
  return Math.max(2, bw / Math.max(1, count))
137
280
  }
138
281
 
package/lib/pptxPy.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * python-pptx 引擎:把中间层(deck.yaml)映射为 python-pptx 脚本(兜底引擎)。
3
- * 覆盖 text / shape / image / table;chart 降级为表格+说明。
3
+ * 覆盖 text / shape / image / table / **chart(原生可编辑图表 + 内嵌数据工作簿)**。
4
4
  * 需要 python 环境 + python-pptx(运行前探测,缺失则报错提示)。
5
5
  */
6
6
  import { readFile } from 'node:fs/promises'
@@ -8,6 +8,8 @@ import { join, isAbsolute } from 'node:path'
8
8
  import { spawnSync } from 'node:child_process'
9
9
  import YAML from 'yaml'
10
10
  import { normalizePage, measureText } from './pptd/layout.js'
11
+ import { chartData, chartColors } from './pptd/svgCharts.js'
12
+ import { findBundledPython } from './capabilities.js'
11
13
 
12
14
  export function genPythonScript(ctx) {
13
15
  const py = []
@@ -16,6 +18,10 @@ export function genPythonScript(ctx) {
16
18
  py.push('from pptx.dml.color import RGBColor')
17
19
  py.push('from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR')
18
20
  py.push('from pptx.enum.text import PP_ALIGN, MSO_ANCHOR')
21
+ // 原生图表(2026-09-26 第二轮 ③):python-pptx 会自己写 `ppt/charts/chartN.xml` + **内嵌数据工作簿**
22
+ // `ppt/embeddings/*.xlsx`,于是成品里的图表可"编辑数据/换类型"、数据随文件走。
23
+ py.push('from pptx.chart.data import CategoryChartData')
24
+ py.push('from pptx.enum.chart import XL_CHART_TYPE')
19
25
  py.push('import json, sys')
20
26
  py.push('')
21
27
  // OUT 由 runPythonExport 替换成字面量路径(见文件末尾的 replace)。
@@ -126,7 +132,54 @@ export function genPythonScript(ctx) {
126
132
  break
127
133
  }
128
134
  case 'chart': {
129
- py.push(`# chart 降级:引擎 B 以表格表达(引擎 A 支持矢量拼绘)`)
135
+ // 原生可编辑图表(2026-09-26 第二轮 ③)——**不再降级为表格**。
136
+ // 旧行为是"图表以表格表达",用户拿到成品看不见图、也改不了数据;现在交给 python-pptx
137
+ // 生成原生图表:`ppt/charts/chartN.xml` + 内嵌工作簿一起落盘 ⇒ PowerPoint 里可"编辑数据"、
138
+ // 可切换图表类型、坐标轴/图例/数据标签都由图表自己算。
139
+ const cdata = chartData(el.chart)
140
+ const ccolors = chartColors(el.chart)
141
+ const kindOf = { bar: 'COLUMN_CLUSTERED', line: 'LINE_MARKERS', pie: 'PIE' }
142
+ const kind = kindOf[el.chart?.type] ?? 'COLUMN_CLUSTERED'
143
+ const series = cdata.series.length ? cdata.series : [{ name: '系列1', values: [] }]
144
+ py.push(`chart_data = CategoryChartData()`)
145
+ py.push(`chart_data.categories = ${pyLit(cdata.categories)}`)
146
+ for (const s of series) {
147
+ const vals = s.values.map((v) => (Number.isFinite(v) ? String(v) : '0'))
148
+ // 单元素元组必须带尾逗号,否则 `(12)` 是整数而不是序列
149
+ py.push(`chart_data.add_series(${pyLit(s.name ?? '系列')}, (${vals.join(', ')}${vals.length === 1 ? ',' : ''}))`)
150
+ }
151
+ py.push(`gf = slide.shapes.add_chart(XL_CHART_TYPE.${kind}, Emu(${x}*12700), Emu(${y}*12700), Emu(${w}*12700), Emu(${h}*12700), chart_data)`)
152
+ py.push(`chart = gf.chart`)
153
+ py.push(`chart.has_legend = ${series.length > 1 ? 'True' : 'False'}`)
154
+ if (series.length > 1) py.push(`chart.legend.include_in_layout = False`)
155
+ py.push(`plot = chart.plots[0]`)
156
+ if (el.chart?.type === 'pie') {
157
+ py.push(`plot.has_data_labels = True`)
158
+ py.push(`plot.data_labels.show_percentage = True`)
159
+ py.push(`plot.data_labels.show_value = False`)
160
+ } else {
161
+ // `vary_by_categories=False` 是必须的:python-pptx 默认模板会按分类上色,
162
+ // 于是单系列图例列成分类名、每根柱子不同色(2026-09-26 实测),与我们的观感预期不符。
163
+ py.push(`plot.vary_by_categories = False`)
164
+ py.push(`plot.has_data_labels = True`)
165
+ py.push(`plot.data_labels.number_format = '0'`)
166
+ py.push(`plot.data_labels.number_format_is_linked = False`)
167
+ }
168
+ // 上色是**观感项**:任何 API 差异都不该让整册导出失败 ⇒ 整块 try/except 包住。
169
+ py.push(`try:`)
170
+ if (el.chart?.type === 'pie') {
171
+ py.push(` _pie = ${pyLit(ccolors)}`)
172
+ py.push(` for i, pt in enumerate(plot.points):`)
173
+ py.push(` pt.format.fill.solid(); pt.format.fill.fore_color.rgb = rgb(_pie[i % len(_pie)])`)
174
+ } else {
175
+ py.push(` _cols = ${pyLit(ccolors)}`)
176
+ py.push(` for i, s in enumerate(chart.series):`)
177
+ py.push(` _c = rgb(_cols[i % len(_cols)])`)
178
+ py.push(` s.format.fill.solid(); s.format.fill.fore_color.rgb = _c`)
179
+ if (el.chart?.type === 'line') py.push(` s.format.line.color.rgb = _c`)
180
+ }
181
+ py.push(`except Exception:`)
182
+ py.push(` pass`)
130
183
  break
131
184
  }
132
185
  }
@@ -167,21 +220,51 @@ function isApproxFill(fill) {
167
220
  && (fill.type === 'gradient' || fill.alpha != null || Array.isArray(fill.stops)))
168
221
  }
169
222
 
223
+ /**
224
+ * 找可用的 python-pptx 解释器。**顺序很重要**(2026-09-26,第二轮兜底增强):
225
+ * ① **捆绑 Python**(桌面端:`<dshHome>/dsh-runtimes/dsh-primary-runtime/dependencies/python/python.exe`
226
+ * 或 carrier `resources/runtime/primary-runtime/...`)——它自带 python-pptx 1.0.2 / lxml / Pillow /
227
+ * XlsxWriter / openpyxl / pandas,正是官方三个 office 技能假定的那一套;这条**不需要用户装任何东西**。
228
+ * ② PATH 上的 `python` / `py` / `python3`(老路子)。
229
+ * 语义(向后兼容):能 `import pptx` 的立刻返回 `has:true` 并带 `source`;只找到"能跑但缺库"的,
230
+ * 记下**第一个**作为 `has:false` 结果(继续往后找,别被一个残缺解释器挡住),供错误信息说明原因。
231
+ */
170
232
  export function findPython() {
171
- for (const cmd of ['python', 'py']) {
233
+ let partial = null
234
+ const probe = (cmd, source) => {
172
235
  try {
173
- const r = spawnSync(cmd, ['-c', 'import pptx; print("ok")'], { encoding: 'utf8', timeout: 15000 })
174
- if (r.status === 0 && r.stdout.includes('ok')) return { cmd, has: true }
175
- if (r.status === 0 || (r.error === undefined && r.stderr === '')) return { cmd, has: false }
176
- } catch { /* try next */ }
236
+ const r = spawnSync(cmd, ['-c', 'import pptx; print("ok")'], { encoding: 'utf8', timeout: 20000 })
237
+ if (r.status === 0 && String(r.stdout).includes('ok')) return { cmd, has: true, source }
238
+ // 能起来但没装 pptx(stderr 非空且不是 spawn 失败)⇒ 记为"残缺但可用",继续找下一个
239
+ if (r.status === 0 || (r.error === undefined && r.stderr === '')) return { cmd, has: false, source, partial: true }
240
+ return null
241
+ } catch { return null }
242
+ }
243
+ const bundled = findBundledPython()
244
+ if (bundled !== null) {
245
+ const r = probe(bundled.path, `bundled:${bundled.source}`)
246
+ if (r?.has) return { cmd: r.cmd, has: true, source: r.source }
247
+ if (r?.partial && partial === null) partial = r
177
248
  }
178
- return { cmd: null, has: false }
249
+ for (const cmd of ['python', 'py', 'python3']) {
250
+ const r = probe(cmd, 'path')
251
+ if (r?.has) return { cmd: r.cmd, has: true, source: r.source }
252
+ if (r?.partial && partial === null) partial = r
253
+ }
254
+ return partial ?? { cmd: null, has: false, source: null }
179
255
  }
180
256
 
181
257
  export function runPythonScript(ctx, script) {
182
258
  const py = findPython()
183
- if (!py.has) throw new Error('python-pptx 引擎不可用:未检测到带 python-pptx 的 python 环境(pip install python-pptx)——可改用默认 pptd 引擎')
184
- return startPython(py.cmd, script)
259
+ if (!py.has) {
260
+ const bundled = findBundledPython()
261
+ throw new Error('python-pptx 引擎不可用:未检测到带 python-pptx 的解释器'
262
+ + (bundled === null
263
+ ? '(未探测到捆绑 Python,PATH 上也没有可用的 python;可 pip install python-pptx)'
264
+ : `(探测到捆绑 Python 但缺库:${bundled.path})`)
265
+ + '——可改用默认 pptd 引擎')
266
+ }
267
+ return { stdout: startPython(py.cmd, script), cmd: py.cmd, source: py.source }
185
268
  }
186
269
 
187
270
  function startPython(cmd, script) {
@@ -201,5 +284,7 @@ export async function runPythonExport(ctx, out) {
201
284
  // `python -c` 下没有 argv[1] ⇒ **文件已写出却报执行失败**)
202
285
  const script = genPythonScript(ctx).replace(/^OUT = .*$/m, `OUT = ${JSON.stringify(outPath)}`)
203
286
  const result = await runPythonScript(ctx, script)
204
- return { file: outPath, engine: 'python-pptx', note: result.trim() }
287
+ // 解释器来源一并带出(2026-09-26):兜底是"最后保险",报告里要能看出**用的是哪一个解释器**,
288
+ // 否则"捆绑 Python 到底有没有被用上"只能靠猜。
289
+ return { file: outPath, engine: 'python-pptx', note: result.stdout.trim(), interpreter: result.cmd, interpreterSource: result.source }
205
290
  }
@@ -37,8 +37,22 @@
37
37
  * · 注册表缺失(极简部署 / 低于 0.1.7 的宿主)只告警,**绝不抛**——预设没了也不能让插件挂不上;
38
38
  * · 组合里出现解析不了的相对名时**拒绝声明**(宁可没有预设,也不声明一条注定 broken 的声明:
39
39
  * broken 的预设会被 `presetOptions()` 过滤掉,等于白声明还多一条误导日志)。
40
+ *
41
+ * ── 2026-09-26 新增:**自定位行**("只在「PPT 工作室」里可见"的载体)──────────────────
42
+ * 用户要求"装上插件后**其他预设完全不受影响**"。做法是把本包**挂进自己的预设组合**:
43
+ * 声明预设时在 `plugins` 末尾追加一行
44
+ * { id: 'ppt-studio-scope', name: 'file:///<本包绝对路径>/lib/index.js', config: { scope: 'preset' } }
45
+ * —— 预设的常驻组合会真的 import 并 apply 这行(`dsh-agent-preset-registry` 声明即挂载),
46
+ * 而注册进"预设作用域 ctx"的工具/技能/提示段**只对该预设的会话可见**(分层注册表按 scope 链合并)。
47
+ * 三条必须守住的细节:
48
+ * ① **行名必须是绝对 `file://`**:预设行按**注册表所在 ctx 的 baseUrl** 解析(历史事故根因),
49
+ * 裸包名/相对名在 profile 里解析不到;而 Loader 对**非 `.` 开头**的名字**原样 `import(name)`**
50
+ * ⇒ `file://` 不看 baseUrl。这是 2026-09-26 真宿主实测通过的路径。
51
+ * ② **路径现算,不写死**:用 `import.meta.url` 推出本包绝对路径 ⇒ 升级换代、换 profile 都不会陈旧。
52
+ * ③ **预设档实例必须 `autoPreset: false`**:否则它会再声明一次预设 ⇒ 注册表对重复 id 直接 throw。
53
+ * 本行只在"想要隔离"时追加;不健康时由 index.js 自动回落到全局能力面(隔离失效但功能不丢)。
40
54
  */
41
- import { existsSync, readFileSync } from 'node:fs'
55
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
42
56
  import { dirname, join, resolve } from 'node:path'
43
57
  import { fileURLToPath, pathToFileURL } from 'node:url'
44
58
  import { parse } from 'yaml'
@@ -60,11 +74,59 @@ export function builtinPresetDir() {
60
74
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'agent-presets', PRESET_ID)
61
75
  }
62
76
 
77
+ /** 包根(`lib/` 的上一级)。 */
78
+ export function packageRoot() {
79
+ return join(dirname(fileURLToPath(import.meta.url)), '..')
80
+ }
81
+
82
+ /** **自定位行的 id**(预设档实例的身份标识,便于从日志/状态里认出来)。 */
83
+ export const SCOPE_ROW_ID = 'ppt-studio-scope'
84
+
85
+ /**
86
+ * 本包的加载入口(用于自定位行)。优先 `lib/index.js`(`package.json` 的 main),
87
+ * 其次读 `package.json.main`;都拿不到返回 null(调用方据此**不追加**该行——宁可不隔离,也不加一行注定 broken 的)。
88
+ * 路径经 `realpathSync` 归一:pnpm 顶层软链与 `.pnpm/...` 真实路径在 Node 里解析成同一模块实例。
89
+ */
90
+ export function pluginEntryFile() {
91
+ const pkgRoot = packageRoot()
92
+ const candidate = join(pkgRoot, 'lib', 'index.js')
93
+ const fallbackMain = (() => {
94
+ try {
95
+ const main = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).main
96
+ return typeof main === 'string' && main ? resolve(pkgRoot, main) : null
97
+ } catch { return null }
98
+ })()
99
+ const entry = existsSync(candidate) ? candidate : (fallbackMain && existsSync(fallbackMain) ? fallbackMain : null)
100
+ if (entry === null) return null
101
+ try { return realpathSync(entry) } catch { return entry }
102
+ }
103
+
104
+ /**
105
+ * 自定位预设行:让本包被**自己的预设**挂载一次,从而把能力面注册进**预设作用域**。
106
+ * `config.scope: 'preset'` = 预设档实例;`autoPreset: false` = 它不再声明预设(重复 id 会 throw)。
107
+ * @param {{presetIds?: string[]}} [extra] 额外透传的配置
108
+ * @returns {object|null} 入口文件缺失时返回 null
109
+ */
110
+ export function selfPresetRow(extra = {}) {
111
+ const entry = pluginEntryFile()
112
+ if (entry === null) return null
113
+ return {
114
+ id: SCOPE_ROW_ID,
115
+ name: pathToFileURL(entry).href,
116
+ config: { scope: 'preset', autoPreset: false, presetIds: extra?.presetIds ?? [PRESET_ID] },
117
+ }
118
+ }
119
+
63
120
  /** 最近一次声明的结果(供 ppt_state 与测试观测;进程内单例)。 */
64
- const STATUS = { id: PRESET_ID, declared: false, via: '', reason: '尚未装配', rows: 0 }
121
+ const STATUS = { id: PRESET_ID, declared: false, via: '', reason: '尚未装配', rows: 0, selfRow: false, health: 'unknown', isolation: 'unknown' }
65
122
  export function presetDeliveryStatus() {
66
123
  return { ...STATUS }
67
124
  }
125
+ /** 覆盖/合并交付状态的附加字段(index.js 用它写入"隔离是否生效 / 是否已回落")。 */
126
+ export function notePresetStatus(patch = {}) {
127
+ for (const [k, v] of Object.entries(patch)) STATUS[k] = v
128
+ return presetDeliveryStatus()
129
+ }
68
130
 
69
131
  function loggerOf(ctx) {
70
132
  try { return typeof ctx.logger === 'function' ? ctx.logger('ppt-studio') : ctx.logger } catch { return undefined }
@@ -97,9 +159,11 @@ function absolutizeRows(rows, baseDir) {
97
159
  /**
98
160
  * 组装交给 `agentPresets.register()` 的声明。
99
161
  * @param {string} [dir] 预设目录(缺省 = 包内自带那份)
100
- * @returns {{id: string, name: string, description: string, order: number, plugins: object[]}}
162
+ * @param {{selfRow?: boolean, presetIds?: string[]}} [opts] `selfRow: true` 时在末尾追加**自定位行**
163
+ * (= 让本包在自己的预设里以"预设档"再挂一次,能力面因此只对该预设可见)
164
+ * @returns {{id: string, name: string, description: string, order: number, plugins: object[], selfRow: boolean}}
101
165
  */
102
- export function presetDeclaration(dir = builtinPresetDir()) {
166
+ export function presetDeclaration(dir = builtinPresetDir(), opts = {}) {
103
167
  const composition = join(dir, 'agent.cordis.yml')
104
168
  if (!existsSync(composition)) throw new Error(`包内预设组合缺失:${composition}`)
105
169
  let meta = {}
@@ -108,12 +172,22 @@ export function presetDeclaration(dir = builtinPresetDir()) {
108
172
  try { meta = parse(readFileSync(metaFile, 'utf8')) ?? {} } catch { meta = {} }
109
173
  }
110
174
  const plugins = absolutizeRows(readEntryList(composition), dir)
175
+ let selfRow = false
176
+ if (opts?.selfRow === true) {
177
+ const row = selfPresetRow({ presetIds: opts?.presetIds })
178
+ if (row !== null) {
179
+ // 幂等:组合里若已存在同名行(用户手工加过),不重复追加
180
+ if (!plugins.some((r) => r?.id === SCOPE_ROW_ID)) { plugins.push(row); selfRow = true }
181
+ else selfRow = true
182
+ }
183
+ }
111
184
  return {
112
185
  id: PRESET_ID,
113
186
  name: typeof meta.name === 'string' ? meta.name : 'PPT 工作室',
114
187
  description: typeof meta.description === 'string' ? meta.description : '',
115
188
  order: typeof meta.order === 'number' ? meta.order : 2,
116
189
  plugins,
190
+ selfRow,
117
191
  }
118
192
  }
119
193
 
@@ -123,55 +197,79 @@ function readRegistry(ctx) {
123
197
 
124
198
  /**
125
199
  * 幂等地把「PPT 工作室」预设声明给 agent-preset 注册表。
126
- * @returns {{id: string, declared: boolean, via: string, reason: string, rows: number}} 当前(可能尚未完成的)状态
200
+ * @param {object} ctx 插件作用域 ctx(profile 档)
201
+ * @param {object} [config] 插件配置(`autoPreset:false` 关闭;`scope:'preset'` 时追加自定位行)
202
+ * @param {{selfRow?: boolean, onSettled?: (health: {ok: boolean, reason: string, rows?: number, selfRow?: boolean, presetId: string}) => void}} [hooks]
203
+ * `onSettled` 在声明落地(成功/失败/注册表不可见)后**恰好调用一次**,供调用方决定是否需要回落。
204
+ * @returns {{id: string, declared: boolean, via: string, reason: string, rows: number, selfRow: boolean, health: string}} 当前(可能尚未完成的)状态
127
205
  */
128
- export function declareAgentPreset(ctx, config = {}) {
206
+ export function declareAgentPreset(ctx, config = {}, hooks = {}) {
129
207
  if (config?.autoPreset === false) {
130
208
  STATUS.declared = false
131
209
  STATUS.via = ''
210
+ STATUS.health = 'off'
132
211
  STATUS.reason = 'autoPreset=false(配置关闭)'
133
212
  return presetDeliveryStatus()
134
213
  }
135
214
 
215
+ const wantIsolation = hooks?.selfRow === true || config?.scope === 'preset'
136
216
  let declaration
137
217
  try {
138
- declaration = presetDeclaration()
218
+ declaration = presetDeclaration(builtinPresetDir(), { selfRow: wantIsolation, presetIds: config?.presetIds })
139
219
  } catch (e) {
140
220
  STATUS.declared = false
141
221
  STATUS.via = ''
222
+ STATUS.health = 'unavailable'
142
223
  STATUS.reason = `预设组合不可用(未声明):${String(e?.message ?? e)}`
143
224
  loggerOf(ctx)?.warn?.(`[ppt-studio] ${STATUS.reason}`)
144
225
  return presetDeliveryStatus()
145
226
  }
146
227
  STATUS.rows = declaration.plugins.length
228
+ STATUS.selfRow = declaration.selfRow === true
229
+ STATUS.health = 'pending'
147
230
  STATUS.reason = '等待 agentPresets 注册表…'
148
231
 
149
232
  const warn = (message) => { try { loggerOf(ctx)?.warn?.(`[ppt-studio] ${message}`) } catch { /* 无 logger 时静默 */ } }
150
233
  let release
151
234
  let closed = false
235
+ let settled = false
152
236
  let queue = Promise.resolve()
237
+ const settle = (health) => {
238
+ if (settled) return
239
+ settled = true
240
+ try { hooks?.onSettled?.(health) } catch (e) { warn(`onSettled 回调异常:${String(e?.message ?? e)}`) }
241
+ }
153
242
 
154
243
  const declare = (registry) => {
155
244
  queue = queue.then(async () => {
156
245
  if (closed || release !== undefined) return
157
246
  if (registry === undefined) {
158
247
  STATUS.reason = 'agentPresets 服务不可见(本部署未装配预设注册表?)——预设未声明'
248
+ STATUS.health = 'no-registry'
159
249
  warn(STATUS.reason)
250
+ settle({ ok: false, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
160
251
  return
161
252
  }
162
253
  try {
163
254
  release = await registry.register(declaration)
164
255
  STATUS.declared = true
165
256
  STATUS.via = 'agentPresets.register'
166
- STATUS.reason = `已声明(${declaration.plugins.length} 行)——新建会话的预设选择器里可选「${declaration.name}」`
257
+ STATUS.health = 'declared'
258
+ STATUS.reason = `已声明(${declaration.plugins.length} 行${declaration.selfRow ? ',含自定位行' : ''})——新建会话的预设选择器里可选「${declaration.name}」`
167
259
  try { loggerOf(ctx)?.info?.(`[ppt-studio] ${STATUS.reason}`) } catch { /* 忽略 */ }
260
+ settle({ ok: true, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
168
261
  } catch (e) {
169
262
  STATUS.declared = false
170
263
  STATUS.via = ''
264
+ STATUS.health = 'failed'
171
265
  STATUS.reason = `声明失败(不影响插件本身):${String(e?.message ?? e)}`
172
266
  warn(STATUS.reason)
267
+ settle({ ok: false, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
173
268
  }
174
- }).catch((e) => { warn(`预设声明队列异常:${String(e?.message ?? e)}`) })
269
+ }).catch((e) => {
270
+ warn(`预设声明队列异常:${String(e?.message ?? e)}`)
271
+ settle({ ok: false, reason: `预设声明队列异常:${String(e?.message ?? e)}`, presetId: PRESET_ID })
272
+ })
175
273
  }
176
274
 
177
275
  ctx.effect(() => {