dsh-ppt-studio 1.1.0 → 1.1.2

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.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { writeFile, readFile } from 'node:fs/promises'
14
14
  import { join, isAbsolute } from 'node:path'
15
+ import { createHash } from 'node:crypto'
15
16
  import { zipWrite } from '../zips.js'
16
17
  import { normalizePage } from './layout.js'
17
18
  import { chartData, chartColors, chartIssue } from './svgCharts.js'
@@ -36,6 +37,37 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
36
37
  const lineProof = { exp: 0, out: 0, wrong: [] }
37
38
  UID = 1000
38
39
 
40
+ // 媒体部件命名(2026-09-26 修复"同名静默错图")——**整册共享**(不能按页各算一套,否则同一 srcPath
41
+ // 在不同页会拿到不同部件名,parity 会假红)。要防的事故:旧实现 addMedia 按**完整路径**去重(rId 不同),
42
+ // 但包内部件名与 rels Target 都取 `basename` ⇒ `media/a/logo.png` 与 `media/b/logo.png` 共用一个
43
+ // `ppt/media/logo.png`,后写覆盖先写 —— **成品里一张图被静默换成另一张**,而 parity(只数 `<p:pic>`)与
44
+ // warnings 全绿(2026-09-26 实测复现)。
45
+ // 规则:ASCII 安全且未被占用 ⇒ **原名不动**(既有产物零变化);非 ASCII/空格或重名 ⇒ `stem-<hash8>.<ext>`
46
+ // (hash 取相对路径,跨机器/跨运行确定;`ppt/media/` 是包内部件名,用户看不到)。
47
+ const usedParts = new Map() // srcPath → 部件名
48
+ const takenParts = new Set()
49
+ const renamedMedia = new Map() // srcPath → 改名后的部件名(供报告"不静默")
50
+ const mediaPartName = (srcPath) => {
51
+ if (usedParts.has(srcPath)) return usedParts.get(srcPath)
52
+ const key = String(srcPath)
53
+ const base = key.split(/[\\/]/).pop() || 'image'
54
+ const dot = base.lastIndexOf('.')
55
+ const ext = dot > 0 ? base.slice(dot) : ''
56
+ const stem = dot > 0 ? base.slice(0, dot) : base
57
+ let part = base
58
+ if (!/^[A-Za-z0-9._-]+$/.test(base) || takenParts.has(base)) {
59
+ const slug = stem.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'image'
60
+ const h = createHash('sha1').update(key).digest('hex').slice(0, 8)
61
+ part = `${slug}-${h}${ext}`
62
+ let n = 1
63
+ while (takenParts.has(part)) part = `${slug}-${h}-${n++}${ext}`
64
+ renamedMedia.set(key, part)
65
+ }
66
+ takenParts.add(part)
67
+ usedParts.set(srcPath, part)
68
+ return part
69
+ }
70
+
39
71
  for (const page of ctx.pages) {
40
72
  const els = normalizePage(page, ctx)
41
73
  const shapes = []
@@ -46,7 +78,7 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
46
78
  if (mediaSeen.has(srcPath)) return mediaSeen.get(srcPath)
47
79
  const rId = `rId${media.length + 2}`
48
80
  mediaSeen.set(srcPath, rId)
49
- media.push({ srcPath, rId })
81
+ media.push({ srcPath, rId, part: mediaPartName(srcPath) })
50
82
  return rId
51
83
  }
52
84
 
@@ -136,13 +168,13 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
136
168
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
137
169
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
138
170
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>',
139
- ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.srcPath.split(/[\\/]/).pop()}"/>`),
171
+ ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.part}"/>`),
140
172
  notesRel,
141
173
  '</Relationships>',
142
174
  ].join('')
143
175
  files[`ppt/slides/_rels/slide${n}.xml.rels`] = srels
144
176
  for (const m of s.media) {
145
- const bn = m.srcPath.split(/[\\/]/).pop()
177
+ const bn = m.part
146
178
  try {
147
179
  files[`ppt/media/${bn}`] = await readFile(join(ctx.dir, m.srcPath))
148
180
  } catch {
@@ -171,13 +203,20 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
171
203
  const notesParts = Object.keys(files).filter((k) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(k))
172
204
  const notesOut = notesParts.length
173
205
  const notesRelOk = slides.filter((s) => s.notes).every((s) => String(files[`ppt/slides/_rels/slide${slides.indexOf(s) + 1}.xml.rels`]).includes(`notesSlide${s.notesNo}.xml`))
206
+ // 媒体 parity(2026-09-26 新增):**不同 srcPath 数 == 包内媒体部件数**。
207
+ // 为什么必须单独加一条:图数量 parity(imagesExp/imagesOut)只数 `<p:pic>`,两张不同的图塌成同一个部件时
208
+ // 它依然是 2/2、ok=true(实测)。而"两张不同的图被写成同一个部件"= 成品里静默用错图,正是本插件最不能出的错。
209
+ const mediaExpSet = new Set()
210
+ for (const s of slides) for (const m of s.media) mediaExpSet.add(m.srcPath)
211
+ const mediaOut = Object.keys(files).filter((k) => /^ppt\/media\//.test(k)).length
174
212
  report.parity = {
175
213
  tablesExp, tablesOut, imagesExp, imagesOut, illegalFrames: illegal,
176
214
  linesExp: lineProof.exp, linesOut: lineProof.out, linesWrong: lineProof.wrong.length,
177
215
  notesExp, notesOut,
216
+ mediaExp: mediaExpSet.size, mediaOut,
178
217
  ok: tablesExp === tablesOut && imagesExp === imagesOut && illegal === 0 &&
179
218
  lineProof.exp === lineProof.out && lineProof.wrong.length === 0 &&
180
- notesExp === notesOut && notesRelOk,
219
+ notesExp === notesOut && notesRelOk && mediaExpSet.size === mediaOut,
181
220
  }
182
221
 
183
222
  // out:绝对路径原样使用;相对路径相对 deck 目录(反馈 E1 ★)
@@ -186,6 +225,8 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
186
225
  report.file = outPath
187
226
  report.slides = slides.length
188
227
  report.engine = engine
228
+ // 改名必须**可见**(不静默):非 ASCII/空格或重名时部件名变了,报告里说明改成了什么。
229
+ if (renamedMedia.size) report.mediaRenamed = [...renamedMedia].map(([src, part]) => `${src} → ppt/media/${part}`)
189
230
  return report
190
231
  }
191
232
 
@@ -6,7 +6,7 @@
6
6
  * - URL 为同源相对路径(/ppt-preview/<token>/pages/deck.html):GUI 页面本身就在 host 上,
7
7
  * 点击/iframe 均同源,不依赖端口探测。
8
8
  */
9
- import { mkdir, rm, copyFile, readFile, stat } from 'node:fs/promises'
9
+ import { mkdir, rm, copyFile, cp, readFile, stat } from 'node:fs/promises'
10
10
  import { existsSync } from 'node:fs'
11
11
  import { join, dirname, extname, normalize } from 'node:path'
12
12
  import { homedir } from 'node:os'
@@ -44,13 +44,14 @@ export async function buildPreview(dir) {
44
44
  await copyFile(join(dir, 'preview', f), join(root, 'pages', f))
45
45
  }
46
46
  await copyFile(join(dir, 'preview', 'deck.html'), join(root, 'pages', 'deck.html'))
47
- // media(含页面背景图;html 以 ../media/ 引用)
47
+ // media(含页面背景图与**子目录**;html 以 ../media/ 引用)
48
+ // 2026-09-26 修复:旧实现只 `readdir` 顶层再逐个 `copyFile` —— 目录条目会被当文件拷,Windows 上直接
49
+ // `EPERM: operation not permitted, copyfile '<deck>\media\sub' -> '<preview>\media\sub'`
50
+ // ⇒ `buildPreview` 整体抛出、`ppt_preview` 直接失败(**不是"缺图"**),预览根还留下半成品。
51
+ // DSL 允许 `media/子目录/x.png`(schema 对 image.src 只校验是字符串、未禁止子目录),文档也从未声明"平铺",
52
+ // 所以这里改成整目录镜像。`cp(recursive)` 保留相对路径 ⇒ 路由层(previewFileFor + join)本就能服务子路径。
48
53
  if (existsSync(join(dir, 'media'))) {
49
- const mediaDir = join(root, 'media')
50
- await mkdir(mediaDir, { recursive: true })
51
- for (const name of await import('node:fs/promises').then((f) => f.readdir(join(dir, 'media')))) {
52
- await copyFile(join(dir, 'media', name), join(mediaDir, name))
53
- }
54
+ await cp(join(dir, 'media'), join(root, 'media'), { recursive: true })
54
55
  }
55
56
  tokens.set(token, root)
56
57
  // 持久化映射(跨进程/重启后链接仍可用):.meta.json 记录源目录
@@ -103,6 +104,27 @@ async function releaseRouteWhenZero() {
103
104
  }
104
105
  }
105
106
 
107
+ /**
108
+ * 调用 `webServer.register` 并**归一化**它的返回值与异常。
109
+ *
110
+ * 真契约(2026-09-26 实测 DSH 0.1.7-rc.2,源码 `dsh-host-webserver/lib/index.js:177-184`):
111
+ * register 是**同步**方法 —— 成功返回 disposer **函数**,重复注册**同步抛错**(不是 Promise!)。
112
+ * 历史实现写的是 `ws.register({...}).then(...)`:
113
+ * 对同步返回的函数调 `.then` 立即 TypeError,而它是在 `apply()` 里**同步抛出**的
114
+ * ⇒ **整个插件条目激活失败**(桌面端实测:"1 entry did not activate ppt-studio"),
115
+ * 用户连 `ppt_state` 都调不到。假对象当时写成 async,给这个 bug 背了书(smoke §29 已改真契约)。
116
+ *
117
+ * 这里吃三种返回形态:同步函数 / Promise<函数> / `{dispose()}` 对象;同步抛错归一成 `{ thrown }`
118
+ * (不向外抛:预览只是附加能力,不该让插件整体装不上)。
119
+ */
120
+ function attemptRegister(ws, makeRoute) {
121
+ try {
122
+ return { value: ws.register(makeRoute()) }
123
+ } catch (error) {
124
+ return { thrown: error }
125
+ }
126
+ }
127
+
106
128
  /** 注册预览路由(返回 disposer;无 webServer 环境返回 null)。 */
107
129
  export function registerPreviewRoute(ctx) {
108
130
  const ws = ctx.get('webServer')
@@ -118,7 +140,7 @@ export function registerPreviewRoute(ctx) {
118
140
  return ctx.effect(() => () => { ROUTE_REG.count--; void releaseRouteWhenZero() }, 'ppt-studio: preview route (ref)')
119
141
  }
120
142
  let cancelled = false
121
- const unregPromise = ws.register({
143
+ const reg = attemptRegister(ws, () => ({
122
144
  kind: 'prefix',
123
145
  path: '/ppt-preview', // prefix 语义:path 不带尾斜杠(实测:带斜杠不命中)
124
146
  async handler(req, res) {
@@ -128,10 +150,8 @@ export function registerPreviewRoute(ctx) {
128
150
  const m = u.pathname.match(/^\/ppt-preview\/([a-zA-Z0-9-]+)\/(.*)$/)
129
151
  if (!m) return notFound(res)
130
152
  const root = await resolveToken(m[1])
131
- const rel = normalize(m[2]).replace(/^([/\\])+/, '')
132
- if (!root || rel.includes('..')) return notFound(res)
133
- const file = join(root, rel)
134
- if (!existsSync(file)) return notFound(res)
153
+ const file = root ? previewFileFor(root, m[2]) : null
154
+ if (!file || !existsSync(file)) return notFound(res)
135
155
  const st = await stat(file)
136
156
  if (st.isDirectory()) return notFound(res)
137
157
  const buf = await readFile(file)
@@ -142,10 +162,17 @@ export function registerPreviewRoute(ctx) {
142
162
  res.end('preview error')
143
163
  }
144
164
  },
145
- }).then(async (u) => {
146
- if (cancelled) { try { await u() } catch { /* 幂等 */ } ; return null }
147
- ROUTE_REG.unreg = u
148
- return u
165
+ }))
166
+ if (reg.thrown) {
167
+ // 重复注册(并发装配的另一路已抢先注册)⇒ 按上方表判据语义:只计数,不再注册。
168
+ ROUTE_REG.count++
169
+ return ctx.effect(() => () => { ROUTE_REG.count--; void releaseRouteWhenZero() }, 'ppt-studio: preview route (dup)')
170
+ }
171
+ const unregPromise = Promise.resolve(reg.value).then(async (u) => {
172
+ const dispose = typeof u === 'function' ? u : (u && typeof u.dispose === 'function' ? () => u.dispose() : null)
173
+ if (cancelled) { if (dispose) { try { await dispose() } catch { /* 幂等 */ } } ; return null }
174
+ ROUTE_REG.unreg = dispose
175
+ return dispose
149
176
  }).catch(() => null)
150
177
  ROUTE_REG.count++
151
178
  // 同上:cb 立即执行、返回值是 disposer —— 清理逻辑必须包在**返回的函数**里。
@@ -158,6 +185,30 @@ export function registerPreviewRoute(ctx) {
158
185
  }, 'ppt-studio: preview route')
159
186
  }
160
187
 
188
+ /**
189
+ * 把 `/ppt-preview/<token>/` 之后的**原始请求路径**解析成预览根内的文件;越界或畸形编码返回 null。
190
+ * 抽成导出函数是为了可单测(smoke §54 直接断言,不必起 HTTP)。
191
+ *
192
+ * 2026-09-26 修复两件事:
193
+ * ① **必须 `decodeURIComponent` 一次**:浏览器会把 `media/中文 图.png` 编码成 `%E4%B8%AD…%20…` 再请求,
194
+ * 而 `new URL().pathname` **保留编码形式** ⇒ 旧实现拼出的磁盘路径里含 `%E4%B8%AD`,`existsSync` 必失败。
195
+ * 实测:图片已正确拷进预览根,预览仍 404(导出正常)——中文/空格文件名是中文用户的常态。
196
+ * ② 穿越判定改为**按路径段**看 `..`:旧实现 `rel.includes('..')` 会顺带误杀 `a..b.png` 这类合法文件名。
197
+ */
198
+ export function previewFileFor(root, rawRel) {
199
+ let decoded = String(rawRel ?? '')
200
+ try { decoded = decodeURIComponent(decoded) } catch { return null } // 畸形百分号编码 → 当作不存在
201
+ if (decoded.includes('\0')) return null
202
+ // 任何 `..` **路径段**一律拒绝(保守策略:浏览器发出的请求路径本已规范化,代价为零)。
203
+ // 注意必须**先判再 normalize**——`path.normalize` 会把 `media/../x` 折叠成 `x`,折叠后就看不出越级意图了。
204
+ // 旧实现用 `rel.includes('..')`,会顺带误杀 `a..b.png` 这类合法文件名;这里只拦真正的上级目录段。
205
+ if (decoded.split(/[/\\]+/).some((seg) => seg === '..')) return null
206
+ const norm = normalize(decoded).replace(/^([/\\])+/, '')
207
+ if (!norm) return null
208
+ if (norm.split(/[/\\]+/).some((seg) => seg === '..')) return null // 归一化后仍越界(双保险)
209
+ return join(root, norm)
210
+ }
211
+
161
212
  function notFound(res) {
162
213
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
163
214
  res.end('not found')
package/lib/scaffold.js CHANGED
@@ -83,7 +83,7 @@ elements:
83
83
  - elementId: img
84
84
  elementType: image
85
85
  bounds: [60, 100, 400, 300]
86
- src: media/pic.png # 相对 deck 根
86
+ src: media/pic.png # 相对 deck 根;子目录与中文/空格名都可以(media/子目录/图.png)
87
87
  fit: cover # cover|contain|fill
88
88
  - elementId: tbl
89
89
  elementType: table
package/lib/templates.js CHANGED
@@ -211,9 +211,12 @@ export async function registerTemplate(dir, opts = {}, { targetDir = userTemplat
211
211
  const ctx3 = await resolveDeck(tplDir)
212
212
  const r3 = await renderDeck(ctx3, { out: '_cleanup-tmp' })
213
213
  const v3 = verifyDeck(r3.layout)
214
+ // 结构化消费(2026-09-26):旧实现 `line.match(/\[✗\] (\w+)/)` 用的是 `\w+`,而所有门禁码都带连字符
215
+ // (out-of-page / text-overflow / theme-conformance…)⇒ 直方图里全是截断码(`out×3`、`text×2`),
216
+ // 而这份报告是模型读的。改用 verifyDeck 新增的结构化 errors。
214
217
  const detail = {}
215
- for (const line of v3.text.split('\n').filter((l) => l.includes('[✗]'))) {
216
- const code = (line.match(/\[✗\] (\w+)/) ?? [])[1] ?? 'other'
218
+ for (const f of v3.errors) {
219
+ const code = f.code ?? 'other'
217
220
  detail[code] = (detail[code] ?? 0) + 1
218
221
  }
219
222
  cleanup = { declared: cleanup.declared, outSafe, remainingErrors: Object.values(detail).reduce((a, b) => a + b, 0), detail }
package/lib/tools.js CHANGED
@@ -541,7 +541,7 @@ export function registerTools(ctx) {
541
541
  const ctx1 = await loadCtx(dir)
542
542
  const r = await renderDeck(ctx1, {})
543
543
  const v = verifyDeck(r.layout)
544
- const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
544
+ const errors = v.errors.length
545
545
  const addedNote = added.length
546
546
  ? `\n已写入声明:${added.map((a) => `第${a.page}页 +${a.added}对`).join(',')}`
547
547
  : '\n未发现可自动声明的警告级重叠(全部已声明/内容互压/豁免)。'
@@ -559,13 +559,15 @@ export function registerTools(ctx) {
559
559
  layout = r.layout
560
560
  }
561
561
  let localNote = ''
562
+ let pageScope = null
562
563
  if (pagesSel) {
563
564
  const before = (layout.pages ?? []).length
564
565
  layout = { ...layout, pages: (layout.pages ?? []).filter((p) => pagesSel.has(p.index + 1)) }
565
- localNote = `\n\n> 局部校验:仅审阅第 [${[...pagesSel].sort((a, b) => a - b).join(', ')}] 页(共 ${before} 页;其余页本次未检查)`
566
+ pageScope = { sel: [...pagesSel].sort((a, b) => a - b), covered: pagesSel.size, total: before }
567
+ localNote = `\n\n> 局部校验:仅审阅第 [${pageScope.sel.join(', ')}] 页(共 ${before} 页;其余页本次未检查)`
566
568
  }
567
569
  const v = verifyDeck(layout)
568
- const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
570
+ const errors = v.errors.length
569
571
  // M2:实测交叉(D3:实测=终审、估算=预检)
570
572
  let measuredText = ''
571
573
  let measuredErrors = 0
@@ -585,7 +587,16 @@ export function registerTools(ctx) {
585
587
  }
586
588
  const gate = errors + measuredErrors
587
589
  const head = v.text
588
- return `# 数字审阅${localNote}\n${head}${measuredText}\n\n---\n门禁:${gate === 0 ? '✓ 通过' : `✗ ${gate} 个错误(必须清零${measuredErrors ? `,含 M2 实测 ${measuredErrors} 个` : ''}`}\n${pptxSnapshotText}${degradeNote}`
590
+ // 门禁行必须带**审阅范围**(2026-09-26 修复"局部审阅假绿"):pages= 生效时,旧实现把
591
+ // `layout.pages` 先过滤再计数,于是"只审 1 页且该页干净"会打印与整册通过**完全同形**的
592
+ // `门禁:✓ 通过`——而本工具自述写着"不改写 layout.json(防部分校验静默漏检其余页)"
593
+ // (tools.js 的 pages 参数说明 / docs/02 §174)⇒ 用户与模型会得出"整册通过"的错误结论。
594
+ const gateText = gate === 0
595
+ ? (pageScope
596
+ ? `✓ 选中的 ${pageScope.covered} 页通过 —— 其余 ${Math.max(0, pageScope.total - pageScope.covered)} 页**本次未检查**(要整册结论请去掉 pages 重跑)`
597
+ : '✓ 通过')
598
+ : `✗ ${gate} 个错误(必须清零${measuredErrors ? `,含 M2 实测 ${measuredErrors} 个` : ''})`
599
+ return `# 数字审阅${localNote}\n${head}${measuredText}\n\n---\n门禁${pageScope ? '(局部)' : ''}:${gateText}\n${pptxSnapshotText}${degradeNote}`
589
600
  } catch (error) {
590
601
  return `✗ 验证失败:\n${errText(error)}`
591
602
  }
@@ -654,10 +665,16 @@ export function registerTools(ctx) {
654
665
  const phNote = r.mediaPlaceholders?.length
655
666
  ? `\n\n⚠ 缺失媒体 ${r.mediaPlaceholders.length} 个(${r.mediaPlaceholders.join('、')})已用 1×1 白色占位导出——请替换真实图片或删除页面引用(可重新 ppt_import 提取或人工补图)`
656
667
  : ''
668
+ // 媒体部件改名必须可见(2026-09-26):同名冲突/非 ASCII 名会让包内部件名变化——内容不变,
669
+ // 但用户至少要能看到"发生过什么",而不是只看到 parity 数字。
670
+ const renamedNote = r.mediaRenamed?.length
671
+ ? `\n\nℹ 媒体部件改名 ${r.mediaRenamed.length} 个(原因:不同目录同名,或文件名含非 ASCII/空格;` +
672
+ `PPT 里的图片内容与位置不变,改的只是包内部件名):\n${r.mediaRenamed.map((x) => ` - ${x}`).join('\n')}`
673
+ : ''
657
674
  const parityNote = r.parity
658
675
  ? (r.parity.ok
659
- ? `\n✅ 元素 parity 回读:表 ${r.parity.tablesExp}/${r.parity.tablesOut} · 图 ${r.parity.imagesExp}/${r.parity.imagesOut} · 线方向 ${r.parity.linesExp ?? 0}/${r.parity.linesOut ?? 0}(逐条从 OOXML 反推端点自证) · 结构合法(无 PowerPoint 弃帧类嵌套 xfrm)`
660
- : `\n✗ 元素 parity 不一致:表 期望${r.parity.tablesExp}/实际${r.parity.tablesOut} · 图 期望${r.parity.imagesExp}/实际${r.parity.imagesOut} · 线 期望${r.parity.linesExp ?? 0}/实际${r.parity.linesOut ?? 0}/方向错${r.parity.linesWrong ?? 0} · 非法帧 ${r.parity.illegalFrames}——请勿交付,附本输出反馈插件团队`)
676
+ ? `\n✅ 元素 parity 回读:表 ${r.parity.tablesExp}/${r.parity.tablesOut} · 图 ${r.parity.imagesExp}/${r.parity.imagesOut} · 媒体部件 ${r.parity.mediaExp ?? '?'}/${r.parity.mediaOut ?? '?'} · 线方向 ${r.parity.linesExp ?? 0}/${r.parity.linesOut ?? 0}(逐条从 OOXML 反推端点自证) · 结构合法(无 PowerPoint 弃帧类嵌套 xfrm)`
677
+ : `\n✗ 元素 parity 不一致:表 期望${r.parity.tablesExp}/实际${r.parity.tablesOut} · 图 期望${r.parity.imagesExp}/实际${r.parity.imagesOut} · 媒体 期望${r.parity.mediaExp ?? '?'}/实际${r.parity.mediaOut ?? '?'} · 线 期望${r.parity.linesExp ?? 0}/实际${r.parity.linesOut ?? 0}/方向错${r.parity.linesWrong ?? 0} · 非法帧 ${r.parity.illegalFrames}——请勿交付,附本输出反馈插件团队`)
661
678
  : ''
662
679
  // P8(测试反馈):真渲染抽查策略——含复杂元素的页必须覆盖(P1 事故的直接防线)
663
680
  const riskyPages = (ctx0.pages ?? []).map((p, i) => {
@@ -667,7 +684,7 @@ export function registerTools(ctx) {
667
684
  const renderNote = riskyPages.length
668
685
  ? `\nP8 真渲染抽查:建议优先覆盖含 table/chart/image/custGeom 的页 → 第 ${riskyPages.join('、')} 页(每类至少一页;audit 档导出自动全页真渲染)`
669
686
  : ''
670
- return `✓ 已导出(pptd 引擎,${r.slides} 页):${r.file}${fit}${phNote}${parityNote}${renderNote}${floorNote}${chartNote}${await withAudit(r.file)}${engineNote}`
687
+ return `✓ 已导出(pptd 引擎,${r.slides} 页):${r.file}${fit}${phNote}${renamedNote}${parityNote}${renderNote}${floorNote}${chartNote}${await withAudit(r.file)}${engineNote}`
671
688
  } catch (error) {
672
689
  // 自动回退链(C1 决定):auto 且 pptd 硬失败 → 有 python-pptx 则兜底并醒目标注降级(绝不静默)
673
690
  if (eff.allowFallback) {
package/lib/verify.js CHANGED
@@ -452,6 +452,13 @@ export function verifyDeck(layout) {
452
452
  const size = layout.size ?? { width: 960, height: 540 }
453
453
  const theme = layout.theme ?? null
454
454
  let total = 0
455
+ // 结构化 findings(2026-09-26 新增,**纯增量**):消费方(tools.js 的门禁计数、templates.js 的收纳报告)
456
+ // 此前靠 `text.split('\n').filter(l => l.includes('[✗]'))` 与正则 `\[✗\] (\w+)` 反解文本 —— 后者会把
457
+ // `out-of-page` 截成 `out`(`\w` 不含连字符),报告里的错误码直方图全是错的。这里把本就算出来的
458
+ // errors/warns/suggestions 直接带出去,文本格式保持原样(`text` 对用户与旧断言零变化)。
459
+ const allErrors = []
460
+ const allWarns = []
461
+ const allSuggestions = []
455
462
  for (const page of layout.pages ?? []) {
456
463
  const findings = analyzePage(page, size)
457
464
  const suggestions = theme ? aestheticSuggestions(page, size, theme) : []
@@ -461,6 +468,10 @@ export function verifyDeck(layout) {
461
468
  const confirmed = findings.filter((f) => f.severity === 'confirmed')
462
469
  const errors = findings.filter((f) => f.severity === 'error')
463
470
  const warns = findings.filter((f) => f.severity === 'warning')
471
+ const at = { page: page.index + 1, pageName: page.name }
472
+ allErrors.push(...errors.map((f) => ({ ...f, ...at })))
473
+ allWarns.push(...warns.map((f) => ({ ...f, ...at })))
474
+ allSuggestions.push(...suggestions.map((s) => ({ ...s, ...at })))
464
475
  total += findings.length + suggestions.length
465
476
  const confOverlap = confirmed.filter((f) => f.code === 'expected-overlap').length
466
477
  const confOut = confirmed.filter((f) => f.code === 'expected-out-of-safe').length
@@ -475,7 +486,7 @@ export function verifyDeck(layout) {
475
486
  out.push(` - [·] ${s.code}|${s.message}`)
476
487
  }
477
488
  }
478
- return { text: out.join('\n'), total, size }
489
+ return { text: out.join('\n'), total, size, errors: allErrors, warns: allWarns, suggestions: allSuggestions }
479
490
  }
480
491
 
481
492
  /**
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-ppt-studio",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "在 DeepSeek Harness 里做 PPT:说需求 → 逐页制作 → 导出可编辑的 .pptx。含布局检查、Office 真渲染复核,以及可扩展的本地模板库",
5
- "//publish": "2026-09-18 定:发 npm,**无 scope** 包名 `dsh-ppt-studio`。**状态(2026-09-26):尚未发布** —— `npm login` 已通(`npm whoami` = `zbsph`)、包名未被占用(`npm view` 404)、构件已备好(发版那个 tgz,sha 与 Release 资产一致),但首次 `npm publish` 返回 **E403**:账号开了 2FA,普通登录凭据不能写入,需 `--otp=<6 位码>` 或勾了 Bypass 2FA 的 granular token。发布纪律:**用发版时那个 tgz**(`npm publish \"<.sync-state.json 里 localTgz 指向的文件>\"`),保证 registry 字节与 GitHub Release 资产**逐字节相同**;**别在 main 前进后跑裸 `npm publish`**(会按当前工作区重新打包)。成功后才做:核对 `npm view dsh-ppt-studio version dist.integrity`,并在隔离 DSH_HOME 里 `dsh plugin add dsh-ppt-studio` 真装一遍。见 docs/06 与 docs/03 的 1.1.0 条目。",
5
+ "//publish": "2026-09-18 定:发 npm,**无 scope** 包名 `dsh-ppt-studio`。**状态(2026-09-26):已发布** —— `dsh-ppt-studio@1.1.0` 在 npm 上(`dist-tags.latest`、public,发布者 `zbsph`),registry 的 tarball 与 GitHub Release 资产**逐字节相同**(sha256 `9303cc5c…`)。首个版本是 `npm publish <发版 tgz>` 直发的;**从下一版起改走 Trusted Publishing(OIDC)全自动通道**:`.github/workflows/publish.yml` 由 release 事件触发 → 下载 Release 资产 → OIDC `npm publish` → 发后自证 integrity —— 零长期 token、零手工(npm 已公告:Bypass-2FA 直发 2027-01 移除)。发布纪律不变:**用发版时那个 tgz**(`.sync-state.json` 的 `localTgz`),**别在 main 前进后跑裸 `npm publish`**(会按当前工作区重新打包 ⇒ 破坏'两条通道同一份字节')。手动补发入口:Actions → Publish to npm → Run workflow。见 README §10 与 docs/05「发布-同步纪律」。",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/zbsph/dsh-ppt-studio.git" },
7
7
  "homepage": "https://github.com/zbsph/dsh-ppt-studio#readme",
8
8
  "bugs": { "url": "https://github.com/zbsph/dsh-ppt-studio/issues" },
@@ -2,7 +2,12 @@
2
2
  /**
3
3
  * dsh-ppt-studio 桌面端安装器(DSH Electron 应用)
4
4
  *
5
- * ── 为什么需要单独一个安装器(2026-09-26 实测,两条都是硬事实)──────────────────────
5
+ * ── 何时用它:**只在归档通道**(2026-09-26 起)──────────────────────────────────────
6
+ * **默认安装途径是 npm**:桌面端在「插件管理」里填包名 `dsh-ppt-studio` 即可(内部就是 `pnpm add`,
7
+ * 会写进 profile 的 `dependencies` + `dsh.profile.bundles`)。本脚本是**离线/归档备选**——
8
+ * 只有当你要用 GitHub Release 的 `.tgz` 资产、或干脆不经过 registry 时才需要它。
9
+ *
10
+ * ── 为什么"用资产 URL 装"在桌面端还得有单独一个安装器(2026-09-26 实测,两条都是硬事实)────
6
11
  * ① **独立 CLI 拒绝桌面 profile**:`@deepseek-ai/dsh` 的 `lib/bin.js` 写死了
7
12
  * if (profile.toLowerCase() === "desktop") program.error('profile "desktop" is managed exclusively by the Electron application')
8
13
  * 启动与 `plugin` 两条路都过这道守卫,且**没有环境变量旁路**
@@ -12,7 +17,7 @@
12
17
  * [ERR_PNPM_MISSING_TARBALL_INTEGRITY] … its lockfile entry has no "integrity" field
13
18
  * 隔离复现:同一份 pnpm/工作区,**去掉 nodeLinker** 就成功;**换 pnpm 11.21** 也成功。
14
19
  * ⇒ 这是 pnpm 11.7 在 hoisted + tarball-URL 组合上的缺陷,不是本插件的代码问题;
15
- * 但它让"用发布资产 URL 装"这条**我们唯一公开的安装通道**在桌面端直接不可用。
20
+ * 但它让"用发布资产 URL 装"这条归档通道在桌面端直接不可用(npm 通道不受影响)。
16
21
  *
17
22
  * ── 本脚本怎么做 ────────────────────────────────────────────────────────────────
18
23
  * 1) 只用**应用自带的 pnpm/Node**(`resources/runtime/…`)——和应用的插件管理器同一套工具链,
@@ -6,6 +6,10 @@
6
6
  * 3) 校验"预设**声明**"所需的包内资产在位,并清理历史遗留产物
7
7
  * 装完:重启 dsh web → 新建会话时选「PPT 工作室」→ 直接提需求。
8
8
  *
9
+ * ── 何时用它:**只在归档通道**(2026-09-26 起)──────────────────────────────────────
10
+ * **默认安装途径是 npm**:web 端 `dsh plugin --profile web add dsh-ppt-studio`;桌面端在插件管理里填
11
+ * 同名包。本脚本是**离线/归档备选**——解压发布包后跑它,用于不进 registry / 内网 / 需要留档的场景。
12
+ *
9
13
  * ── 2026-09-24 适配 DSH 0.1.7-rc.1:**安装器不再写预设目录** ──────────────────────
10
14
  * 0.1.6 及以前,预设靠"往 `<dshHome>/.agent-presets/ppt/` 写文件"交付(宿主启动时扫描该目录)。
11
15
  * 0.1.7 把那条发现路径**整个删掉了**,预设改为向 `agentPresets` 注册表**声明**——本插件的