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.
- package/README.md +37 -26
- package/cordis.patch.yml +5 -3
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -1
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +138 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +11 -2
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +28 -2
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +6 -3
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/lib/commands.js +12 -11
- package/lib/index.js +32 -2
- package/lib/pptd/export-pptx.js +45 -4
- package/lib/preview-server.js +67 -16
- package/lib/scaffold.js +1 -1
- package/lib/templates.js +5 -2
- package/lib/tools.js +24 -7
- package/lib/verify.js +12 -1
- package/package.json +2 -2
- package/scripts/install-desktop.mjs +7 -2
- package/scripts/install.mjs +4 -0
- package/scripts/smoke.mjs +215 -2
- package/skills/ppt-studio-manual/SKILL.md +2 -2
package/lib/pptd/export-pptx.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
package/lib/preview-server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
132
|
-
if (!
|
|
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
|
-
})
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
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
|
|
216
|
-
const code =
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
* ──
|
|
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/…`)——和应用的插件管理器同一套工具链,
|
package/scripts/install.mjs
CHANGED
|
@@ -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` 注册表**声明**——本插件的
|