dsh-ppt-studio 1.1.0

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 (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +480 -0
  3. package/agent-presets/ppt/agent.cordis.yml +308 -0
  4. package/agent-presets/ppt/preset.yml +3 -0
  5. package/cordis.patch.yml +29 -0
  6. package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
  7. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
  8. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
  9. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
  10. 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 +113 -0
  11. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
  12. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
  13. package/docs/fidelity-report.md +225 -0
  14. package/examples/fx/fx-mini/deck.yaml +39 -0
  15. package/examples/fx/fx-mini/pages/01.yaml +6 -0
  16. package/examples/fx/fx-mini/pages/02.yaml +2 -0
  17. package/examples/fx/fx-mini/pages/03.yaml +6 -0
  18. package/examples/fx/fx-pro/deck.yaml +48 -0
  19. package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
  20. package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
  21. package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
  22. package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
  23. package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
  24. package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
  25. package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
  26. package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
  27. package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
  28. package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
  29. package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
  30. package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
  31. package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
  32. package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
  33. package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
  34. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
  35. package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
  36. package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
  37. package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
  38. package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
  39. package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
  40. package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
  41. package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
  42. package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
  43. package/examples/fx/fx-pro/preview/deck.html +10 -0
  44. package/examples/fx/fx-pro/preview/layout.json +2325 -0
  45. package/examples/fx/fx-pro/preview/measured.json +814 -0
  46. package/examples/fx/fx-pro.pptx +0 -0
  47. package/examples/fx/ren-single/01.png +0 -0
  48. package/examples/fx/ren-spliced/01.png +0 -0
  49. package/examples/fx/ren-spliced/03.png +0 -0
  50. package/examples/fx/seed.pptx +0 -0
  51. package/examples/fx/single.pptx +0 -0
  52. package/examples/fx/spliced.pptx +0 -0
  53. package/examples/smoke/deck.yaml +26 -0
  54. package/examples/smoke/imported/deck.yaml +30 -0
  55. package/examples/smoke/imported/import-styles.json +126 -0
  56. package/examples/smoke/imported/pages/slide_01.yaml +31 -0
  57. package/examples/smoke/imported/pages/slide_02.yaml +38 -0
  58. package/examples/smoke/imported/pages/slide_03.yaml +55 -0
  59. package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
  60. package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
  61. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
  62. package/examples/smoke/imported/preview-rt/deck.html +1 -0
  63. package/examples/smoke/imported/preview-rt/layout.json +434 -0
  64. package/examples/smoke/imported/reference/previews/01.png +0 -0
  65. package/examples/smoke/imported/reference/previews/02.png +0 -0
  66. package/examples/smoke/imported/reference/previews/03.png +0 -0
  67. package/examples/smoke/imported/source.pptx +0 -0
  68. package/examples/smoke/media-test.png +0 -0
  69. package/examples/smoke/out-smoke.pptx +0 -0
  70. package/examples/smoke/pages/01_cover.yaml +27 -0
  71. package/examples/smoke/pages/02_content.yaml +42 -0
  72. package/examples/smoke/pages/03_chart.yaml +35 -0
  73. package/examples/smoke/preview/01_pages_01_cover.html +1 -0
  74. package/examples/smoke/preview/02_pages_02_content.html +1 -0
  75. package/examples/smoke/preview/03_pages_03_chart.html +1 -0
  76. package/examples/smoke/preview/deck.html +1 -0
  77. package/examples/smoke/preview/layout.json +378 -0
  78. package/lib/autodeclare.js +40 -0
  79. package/lib/commands.js +161 -0
  80. package/lib/crosscheck.js +300 -0
  81. package/lib/imgmeta.js +55 -0
  82. package/lib/index.js +239 -0
  83. package/lib/measurement.js +209 -0
  84. package/lib/msrender.js +123 -0
  85. package/lib/pptd/export-pptx.js +686 -0
  86. package/lib/pptd/import-pptx.js +774 -0
  87. package/lib/pptd/layout.js +209 -0
  88. package/lib/pptd/render-html.js +254 -0
  89. package/lib/pptd/schema.js +518 -0
  90. package/lib/pptd/svgCharts.js +147 -0
  91. package/lib/pptxPy.js +205 -0
  92. package/lib/preset-delivery.js +195 -0
  93. package/lib/preview-server.js +164 -0
  94. package/lib/router.js +165 -0
  95. package/lib/scaffold.js +315 -0
  96. package/lib/skill.js +174 -0
  97. package/lib/splice.js +219 -0
  98. package/lib/state.js +88 -0
  99. package/lib/surgical.js +369 -0
  100. package/lib/templates.js +400 -0
  101. package/lib/tools.js +955 -0
  102. package/lib/verify.js +828 -0
  103. package/lib/xmljs.js +112 -0
  104. package/lib/zips.js +117 -0
  105. package/package.json +81 -0
  106. package/scripts/audit-manual-facts.mjs +294 -0
  107. package/scripts/audit-template-fidelity.mjs +162 -0
  108. package/scripts/bench-m2.mjs +48 -0
  109. package/scripts/build-templates.mjs +63 -0
  110. package/scripts/build.mjs +14 -0
  111. package/scripts/build.sh +7 -0
  112. package/scripts/check-lib-freshness.mjs +70 -0
  113. package/scripts/check-preset.mjs +189 -0
  114. package/scripts/check-tracked-lf.mjs +84 -0
  115. package/scripts/dbg-import.mjs +13 -0
  116. package/scripts/dbg-media.mjs +10 -0
  117. package/scripts/dbg-tpl-inventory.mjs +23 -0
  118. package/scripts/dump-workflow-baseline.mjs +38 -0
  119. package/scripts/e2e-1.0.mjs +129 -0
  120. package/scripts/eval-skills-blind.mjs +91 -0
  121. package/scripts/eval-skills-score.mjs +129 -0
  122. package/scripts/export-orig-templates.ps1 +17 -0
  123. package/scripts/fixtures/workflow-baseline.json +300 -0
  124. package/scripts/fixtures.mjs +501 -0
  125. package/scripts/import-user-templates.mjs +60 -0
  126. package/scripts/install-desktop.mjs +312 -0
  127. package/scripts/install.mjs +209 -0
  128. package/scripts/migrate-templates-dualtrack.mjs +71 -0
  129. package/scripts/preflight-1.0.mjs +198 -0
  130. package/scripts/probe-asset-freshness.mjs +57 -0
  131. package/scripts/probe-effect-semantics.mjs +69 -0
  132. package/scripts/probe-reparse-safety.mjs +32 -0
  133. package/scripts/rebuild-templates-c11.mjs +69 -0
  134. package/scripts/regression-real.mjs +84 -0
  135. package/scripts/release-sync.mjs +425 -0
  136. package/scripts/shot-templates.mjs +49 -0
  137. package/scripts/smoke.mjs +2512 -0
  138. package/scripts/verify-bundle-install.mjs +281 -0
  139. package/scripts/verify-fresh-install.mjs +260 -0
  140. package/skills/ppt-studio-copy/SKILL.md +139 -0
  141. package/skills/ppt-studio-craft/SKILL.md +171 -0
  142. package/skills/ppt-studio-data/SKILL.md +173 -0
  143. package/skills/ppt-studio-manual/SKILL.md +104 -0
  144. package/templates/academic-white/deck.yaml +24 -0
  145. package/templates/academic-white/pages/_chart.yaml +43 -0
  146. package/templates/academic-white/pages/_compare.yaml +37 -0
  147. package/templates/academic-white/pages/_content.yaml +35 -0
  148. package/templates/academic-white/pages/_cover.yaml +28 -0
  149. package/templates/academic-white/pages/_ending.yaml +28 -0
  150. package/templates/academic-white/pages/_flow.yaml +61 -0
  151. package/templates/academic-white/preview.png +0 -0
  152. package/templates/academic-white/template.yaml +14 -0
  153. package/templates/business-blue/deck.yaml +28 -0
  154. package/templates/business-blue/pages/_chart.yaml +36 -0
  155. package/templates/business-blue/pages/_compare.yaml +45 -0
  156. package/templates/business-blue/pages/_content.yaml +80 -0
  157. package/templates/business-blue/pages/_cover.yaml +39 -0
  158. package/templates/business-blue/pages/_ending.yaml +32 -0
  159. package/templates/business-blue/pages/_flow.yaml +87 -0
  160. package/templates/business-blue/preview.png +0 -0
  161. package/templates/business-blue/template.yaml +14 -0
  162. package/templates/pitch-bold/deck.yaml +23 -0
  163. package/templates/pitch-bold/pages/_chart.yaml +32 -0
  164. package/templates/pitch-bold/pages/_compare.yaml +36 -0
  165. package/templates/pitch-bold/pages/_content.yaml +58 -0
  166. package/templates/pitch-bold/pages/_cover.yaml +31 -0
  167. package/templates/pitch-bold/pages/_ending.yaml +27 -0
  168. package/templates/pitch-bold/pages/_flow.yaml +46 -0
  169. package/templates/pitch-bold/preview.png +0 -0
  170. package/templates/pitch-bold/template.yaml +14 -0
  171. package/templates/tech-dark/deck.yaml +24 -0
  172. package/templates/tech-dark/pages/_chart.yaml +27 -0
  173. package/templates/tech-dark/pages/_compare.yaml +38 -0
  174. package/templates/tech-dark/pages/_content.yaml +51 -0
  175. package/templates/tech-dark/pages/_cover.yaml +30 -0
  176. package/templates/tech-dark/pages/_ending.yaml +25 -0
  177. package/templates/tech-dark/pages/_flow.yaml +80 -0
  178. package/templates/tech-dark/preview.png +0 -0
  179. package/templates/tech-dark/template.yaml +14 -0
package/lib/skill.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 内置 skill 包(提问式手册 + 制作能力手册)——DSH 0.1.5 起的"插件内嵌 skill"通道:
3
+ * ctx.skills.register(...)(@deepseek-ai/dsh-skill 的运行时注册)
4
+ *
5
+ * 为什么走内嵌而不是只写文件:
6
+ * - 注册落在**调用 ctx 所在层**;本插件由 agent preset 的常驻装配挂载 → 落该 preset 层,
7
+ * 只有 PPT 工作室会话看得见(不污染全局技能目录);
8
+ * - 内容随包(lib/../skills/<name>/SKILL.md),与插件同生共死——升级即新、卸载即净,无副本可过期;
9
+ * - 与用户自装同名技能**不冲突**:注册表跨层重名由"就近层优先"裁决,PPT 会话内命中包内嵌这一份,
10
+ * 用户那份在其它会话照旧可见。
11
+ *
12
+ * 设计约束(2026-09-14,用户明确):
13
+ * - 内嵌技能是**纯增益**:不改变任何铁律与门禁语义;技能缺失 / skills 服务缺位时插件完整可用;
14
+ * - 每个技能必须自包含且短(描述 ≤500 字且只写触发,正文几千字符量级)——正文是按需加载的成本。
15
+ *
16
+ * 兼容策略:install.mjs 仍把同一份 SKILL.md 镜像到 <dshHome>/skills/<name>/(非 PPT 会话也能问到手册)。
17
+ */
18
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import YAML from 'yaml'
22
+
23
+ /** 随包技能根目录(包根/skills)。 */
24
+ export const SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
25
+ /** 提问式手册(历史名,保持导出兼容)。 */
26
+ export const MANUAL_NAME = 'ppt-studio-manual'
27
+ export const MANUAL_DIR = join(SKILLS_DIR, MANUAL_NAME)
28
+ export const MANUAL_FILE = join(MANUAL_DIR, 'SKILL.md')
29
+
30
+ /**
31
+ * 解析 SKILL.md:YAML frontmatter(name/description/whenToUse/disable-model-invocation/user-invocable/metadata)
32
+ * + body 作为 content。语义与 @deepseek-ai/dsh-skill-filesystem 的 parseSkillFile 对齐
33
+ * (content = 去 frontmatter 后 trim 的正文),保证"文件通道"与"内嵌通道"交付同一份内容。
34
+ * @returns {{name: string, description: string, whenToUse?: string, invocation?: {modelInvocable: boolean, userInvocable: boolean}, metadata?: object, content: string} | null}
35
+ */
36
+ export function parseManual(raw) {
37
+ if (typeof raw !== 'string') return null
38
+ const text = raw.replace(/^\uFEFF/, '')
39
+ if (!text.startsWith('---')) return null
40
+ const firstEnd = text.indexOf('\n')
41
+ if (firstEnd < 0) return null
42
+ const close = text.indexOf('\n---', firstEnd)
43
+ if (close < 0) return null
44
+ const bodyStart = text.indexOf('\n', close + 1)
45
+ let data
46
+ try {
47
+ data = YAML.parse(text.slice(firstEnd + 1, close + 1))
48
+ } catch {
49
+ return null
50
+ }
51
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null
52
+ const name = typeof data.name === 'string' ? data.name : ''
53
+ const description = typeof data.description === 'string' ? data.description : ''
54
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) || description.length === 0) return null
55
+ const invocation = invocationOf(data)
56
+ return {
57
+ name,
58
+ description,
59
+ ...(typeof data.whenToUse === 'string' && data.whenToUse.length > 0 ? { whenToUse: data.whenToUse } : {}),
60
+ ...(invocation !== undefined ? { invocation } : {}),
61
+ ...(data.metadata && typeof data.metadata === 'object' && !Array.isArray(data.metadata) ? { metadata: data.metadata } : {}),
62
+ content: (bodyStart < 0 ? '' : text.slice(bodyStart)).trim(),
63
+ }
64
+ }
65
+
66
+ /** frontmatter 调用策略:仅显式给出时才产出(缺省由注册表补 {modelInvocable:true, userInvocable:true})。 */
67
+ function invocationOf(data) {
68
+ const model = data['disable-model-invocation']
69
+ const user = data['user-invocable']
70
+ if (model === undefined && user === undefined) return undefined
71
+ if ((model !== undefined && typeof model !== 'boolean') || (user !== undefined && typeof user !== 'boolean')) return undefined
72
+ return { modelInvocable: model !== true, userInvocable: user !== false }
73
+ }
74
+
75
+ /**
76
+ * 扫描随包技能目录,返回全部可注册技能(跳过无 SKILL.md / frontmatter 非法的目录——不阻断装配)。
77
+ * @returns {{dir: string, file: string, parsed: object}[]}
78
+ */
79
+ export function listBundledSkills(dir = SKILLS_DIR) {
80
+ const out = []
81
+ if (!existsSync(dir)) return out
82
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
83
+ if (!entry.isDirectory()) continue
84
+ const skillDir = join(dir, entry.name)
85
+ const file = join(skillDir, 'SKILL.md')
86
+ if (!existsSync(file)) continue
87
+ let parsed = null
88
+ try {
89
+ parsed = parseManual(readFileSync(file, 'utf8'))
90
+ } catch {
91
+ continue
92
+ }
93
+ if (parsed === null) continue
94
+ out.push({ dir: skillDir, file, parsed })
95
+ }
96
+ return out.sort((a, b) => a.parsed.name.localeCompare(b.parsed.name))
97
+ }
98
+
99
+ /**
100
+ * 本包内嵌技能的注册结果(纯标量自检快照,不持有活对象)。
101
+ * 由 `ppt_state` 输出——安装后若想确认"技能到底有没有挂上",问一次 ppt_state 即可。
102
+ */
103
+ const STATUS = {
104
+ registered: false,
105
+ reason: '未尝试',
106
+ name: MANUAL_NAME, // 兼容字段:指向提问式手册
107
+ contentLen: 0, // 兼容字段:手册正文长度
108
+ skills: [], // [{name, contentLen, registered}]
109
+ }
110
+
111
+ /** 注册结果快照(副本;调用方只读)。 */
112
+ export function manualSkillStatus() {
113
+ return { ...STATUS, skills: STATUS.skills.map((s) => ({ ...s })) }
114
+ }
115
+
116
+ /** 把单个技能注册进当前 ctx(挂本插件 fiber:停用/卸载即自动注销)。 */
117
+ function registerOne(ctx, skills, item) {
118
+ const { parsed } = item
119
+ return ctx.effect(() => skills.register({
120
+ name: parsed.name,
121
+ description: parsed.description,
122
+ ...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
123
+ ...(parsed.invocation !== undefined ? { invocation: parsed.invocation } : {}),
124
+ metadata: { ...(parsed.metadata ?? {}), plugin: 'dsh-ppt-studio', delivery: 'embedded' },
125
+ // 资源基址:技能里的相对引用(references/ 等)可据此定位;同时标明来源是包目录
126
+ resourceBase: { kind: 'directory', path: item.dir },
127
+ source: 'runtime',
128
+ content: parsed.content,
129
+ }), `ppt-studio: embedded skill (${parsed.name})`)
130
+ }
131
+
132
+ /**
133
+ * 注册全部随包内嵌技能。
134
+ * @returns {{name: string, contentLen: number}[]} 实际注册成功的技能(skills 服务缺失时为空数组)
135
+ */
136
+ export function registerManualSkill(ctx) {
137
+ const skills = ctx.get('skills')
138
+ if (skills === undefined) {
139
+ STATUS.registered = false
140
+ STATUS.reason = 'skills 服务缺失(本部署未装配 @deepseek-ai/dsh-skill)'
141
+ STATUS.skills = []
142
+ return []
143
+ }
144
+ const found = listBundledSkills()
145
+ if (found.length === 0) {
146
+ STATUS.registered = false
147
+ STATUS.reason = `未找到随包技能(${SKILLS_DIR})`
148
+ ctx.logger?.warn?.(`ppt-studio: ${STATUS.reason}——内嵌 skill 未注册`)
149
+ STATUS.skills = []
150
+ return []
151
+ }
152
+ const registered = []
153
+ for (const item of found) {
154
+ try {
155
+ registerOne(ctx, skills, item)
156
+ registered.push({ name: item.parsed.name, contentLen: item.parsed.content.length, registered: true })
157
+ } catch (error) {
158
+ ctx.logger?.warn?.(`ppt-studio: 内嵌 skill ${item.parsed.name} 注册失败:${error?.message ?? error}`)
159
+ registered.push({ name: item.parsed.name, contentLen: item.parsed.content.length, registered: false })
160
+ }
161
+ }
162
+ const manual = registered.find((s) => s.name === MANUAL_NAME)
163
+ STATUS.registered = registered.every((s) => s.registered)
164
+ STATUS.reason = STATUS.registered ? 'ok' : '部分技能注册失败'
165
+ STATUS.name = MANUAL_NAME
166
+ STATUS.contentLen = manual?.contentLen ?? 0
167
+ STATUS.skills = registered
168
+ return registered.map((s) => ({ name: s.name, contentLen: s.contentLen }))
169
+ }
170
+
171
+ /** 随包技能名清单(ppt_state 自检用)。 */
172
+ export function bundledSkillNames() {
173
+ return listBundledSkills().map((s) => s.parsed.name)
174
+ }
package/lib/splice.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A3/A9 修复(反馈二 ★★):保真交付——"在原件上只换一页、其余页逐字节不动"。
3
+ *
4
+ * `spliceIntoSource`:把 deck 的某一页(pptd 导出单页)替换进 source.pptx 的指定页,
5
+ * 保留源母版/布局(横幅/页脚原样)、保留该页 notes 关系、媒体合并;其余条目逐字节不变(SHA256 自证)。
6
+ * `sliceSource`:从(已 splice 或任意)source.pptx 修剪出"单页 + 完整母版/布局/主题"的独立文件
7
+ * (等价演示的"单页版":sldIdLst 只留 1 条、其余幻灯/备注/关系/Override 删除)。
8
+ *
9
+ * 实现事实:元组手术用自研 zipRead/zipWrite(deflate 重写,Office 可读)+ 正则处理
10
+ * presentation.xml / rels / [Content_Types].xml —— 与测试会话手工 PowerShell 手术的程序化等价物。
11
+ */
12
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'
13
+ import { existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { tmpdir } from 'node:os'
16
+ import crypto from 'node:crypto'
17
+ import { zipRead, zipWrite, decodeXml } from './zips.js'
18
+ import { resolveDeck } from './pptd/schema.js'
19
+ import { exportPptx } from './pptd/export-pptx.js'
20
+
21
+ const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
22
+
23
+ function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex') }
24
+
25
+ /** zip 条目逐条目 SHA256 摘要(返回 Map<name, hash>)。 */
26
+ export function zipDigests(buf) {
27
+ const out = new Map()
28
+ for (const [name, data] of zipRead(buf)) out.set(name, sha256(data))
29
+ return out
30
+ }
31
+
32
+ /** 从 deck 导出第 page 张(1 基)幻灯的 XML + 媒体包。 */
33
+ export async function deckSlideBundle(deckDir, page) {
34
+ const ctx = await resolveDeck(deckDir)
35
+ if (page < 1 || page > ctx.pages.length) throw new Error(`deck 页号越界:${page}(共 ${ctx.pages.length} 页)`)
36
+ const tmp = join(tmpdir(), `dsh-ppt-splice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
37
+ await mkdir(tmp, { recursive: true })
38
+ try {
39
+ const r = await exportPptx(ctx, { out: join(tmp, 'deck.pptx'), engine: 'pptd' })
40
+ const z = zipRead(await readFile(r.file))
41
+ const slideXml = z.get(`ppt/slides/slide${page}.xml`)
42
+ const slideRels = z.get(`ppt/slides/_rels/slide${page}.xml.rels`)
43
+ if (!slideXml) throw new Error(`导出产物缺少第 ${page} 张 slide XML(导出异常)`)
44
+ const relText = decodeXml(slideRels ?? '')
45
+ const media = []
46
+ for (const m of relText.matchAll(/Id="(rId\d+)"[^>]*Type="[^"]*\/image"[^>]*Target="\.\.\/media\/([^"]+)"/g)) {
47
+ const name = decodeURIComponent(m[2])
48
+ let data = z.get('ppt/media/' + name)
49
+ if (!data) {
50
+ const dfile = join(deckDir, 'media', name)
51
+ if (existsSync(dfile)) data = await readFile(dfile)
52
+ }
53
+ media.push({ rId: m[1], name, data: data ?? TINY_PNG })
54
+ }
55
+ return { slideXml: decodeXml(slideXml), media, count: ctx.pages.length }
56
+ } finally {
57
+ await rm(tmp, { recursive: true, force: true }).catch(() => {})
58
+ }
59
+ }
60
+
61
+ /** 定位 source.pptx 第 page 页(1 基)的 slide 键(含 'ppt/' 前缀)。 */
62
+ export function locateSourceSlide(z, page) {
63
+ const pres = decodeXml(z.get('ppt/presentation.xml'))
64
+ const relsText = decodeXml(z.get('ppt/_rels/presentation.xml.rels'))
65
+ const rIds = [...pres.matchAll(/<p:sldId\b[^>]*r:id="([^"]+)"/g)].map((m) => m[1])
66
+ if (page < 1 || page > rIds.length) throw new Error(`source 页号越界:${page}(共 ${rIds.length} 页)`)
67
+ const rid = rIds[page - 1]
68
+ const rel = new Map()
69
+ for (const m of relsText.matchAll(/<Relationship\s+Id="([^"]+)"\s+Type="([^"]+)"\s+Target="([^"]+)"/g)) rel.set(m[1], { type: m[2], target: m[3] })
70
+ const r = rel.get(rid)
71
+ const target = (r?.target ?? '').replace(/^(\.\.\/)+/, '').replace(/^ppt\//, '')
72
+ if (!r || !/^slides\/slide\d+\.xml$/.test(target)) throw new Error(`source 第 ${page} 页关系解析失败(${rid} → ${r?.target ?? '?'})`)
73
+ const slideName = target.split('/').pop()
74
+ const slideRelsKey = `ppt/slides/_rels/${slideName}.rels`
75
+ const slideRels = z.has(slideRelsKey) ? decodeXml(z.get(slideRelsKey)) : null
76
+ return { slide: target, slideKey: 'ppt/' + target, slideRelsKey, slideRels, rid, rIds }
77
+ }
78
+
79
+ function maxRelId(relsXml) {
80
+ let max = 0
81
+ for (const m of relsXml.matchAll(/rId(\d+)/g)) max = Math.max(max, Number(m[1]))
82
+ return max
83
+ }
84
+
85
+ /**
86
+ * 把 deck 第 deckPage 页替换进 source.pptx 的第 sourcePage 页。
87
+ * @returns { out, sourcePages, deckPages, replaced, unchangedCount, changed, mediaAdded, mediaReused, notesKept }
88
+ */
89
+ export async function spliceIntoSource({ deckDir, source, page, sourcePage = page, out }) {
90
+ const zbuf = await readFile(source)
91
+ const z = zipRead(zbuf)
92
+ const src = locateSourceSlide(z, sourcePage)
93
+ const bundle = await deckSlideBundle(deckDir, page)
94
+
95
+ // 新 XML:媒体 rId 改号(与源幻灯 rels 现有 id 无冲突)
96
+ if (!src.slideRels) throw new Error(`源幻灯 ${src.slideKey} 无关系文件(异常结构)——无法安全替换,请人工处理`)
97
+ const base = maxRelId(src.slideRels)
98
+ let rewritten = bundle.slideXml
99
+ const mediaRels = []
100
+ const mediaNames = []
101
+ bundle.media.forEach((m, i) => {
102
+ const newId = `rId${base + 1 + i}`
103
+ rewritten = rewritten.replaceAll(`r:embed="${m.rId}"`, `r:embed="${newId}"`).replaceAll(`r:link="${m.rId}"`, `r:link="${newId}"`)
104
+ mediaNames.push(m.name)
105
+ mediaRels.push(` <Relationship Id="${newId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.name}"/>`)
106
+ })
107
+ // 源幻灯 rels 原样保留(布局/备注等);媒体关系追加尾部
108
+ const newRels = src.slideRels.replace(/<\/Relationships>\s*$/, mediaRels.join('\n') + '\n</Relationships>')
109
+
110
+ // 媒体:同名已存在 → 复用源条目(零改动);否则新增(数据来自导出包/deck media/占位图)
111
+ const partsToAdd = []
112
+ let mediaReused = 0
113
+ const seenCts = new Set((decodeXml(z.get('[Content_Types].xml') ?? '') ?? '').match(/<Default\s+[^>]*Extension="([^"]+)"/g)?.map((s) => s.match(/Extension="([^"]+)"/)[1].toLowerCase()) ?? [])
114
+ let newCt = z.has('[Content_Types].xml') ? decodeXml(z.get('[Content_Types].xml')) : '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>'
115
+ let ctChanged = false
116
+ for (const name of mediaNames) {
117
+ const key = 'ppt/media/' + name
118
+ if (z.has(key)) { mediaReused++; continue }
119
+ const hit = bundle.media.find((m) => m.name === name)
120
+ partsToAdd.push([key, hit?.data ?? TINY_PNG])
121
+ const ext = (name.split('.').pop() ?? '').toLowerCase()
122
+ if (ext && !seenCts.has(ext)) {
123
+ const ct = ext === 'png' ? 'image/png' : (ext === 'jpg' || ext === 'jpeg') ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : ext === 'emf' ? 'image/x-emf' : 'application/octet-stream'
124
+ newCt = newCt.replace(/<\/Types>\s*$/, `<Default Extension="${ext}" ContentType="${ct}"/>\n</Types>`)
125
+ seenCts.add(ext)
126
+ ctChanged = true
127
+ }
128
+ }
129
+
130
+ // entries 重建:保持源顺序,替换目标 slide/rels,追加新增媒体,必要时更新 Content_Types
131
+ const relsKey = src.slideRelsKey
132
+ const entries = {}
133
+ for (const [name, data] of z) {
134
+ if (name === src.slideKey) { entries[name] = Buffer.from(rewritten, 'utf8'); continue }
135
+ if (name === relsKey) { entries[name] = Buffer.from(newRels, 'utf8'); continue }
136
+ if (ctChanged && name === '[Content_Types].xml') { entries[name] = Buffer.from(newCt, 'utf8'); continue }
137
+ entries[name] = data
138
+ }
139
+ for (const [key, data] of partsToAdd) entries[key] = data
140
+
141
+ const outPath = out ?? source.replace(/\.pptx$/i, '-spliced.pptx')
142
+ await writeFile(outPath, zipWrite(entries))
143
+
144
+ // 自证:源条目(除 slide/rels/ContentTypes)与输出逐字节一致
145
+ const srcDig = zipDigests(zbuf)
146
+ const outDig = zipDigests(await readFile(outPath))
147
+ const excluded = new Set([src.slideKey, relsKey, '[Content_Types].xml'])
148
+ const changedKeys = [...srcDig.keys()].filter((k) => !excluded.has(k) && srcDig.get(k) !== (outDig.get(k) ?? ''))
149
+ const unchangedCount = [...srcDig.keys()].filter((k) => !excluded.has(k) && srcDig.get(k) === outDig.get(k)).length
150
+ const notesKept = /notesSlide/.test(src.slideRels ?? '')
151
+ return {
152
+ out: outPath, sourcePages: src.rIds.length, deckPages: bundle.count,
153
+ replaced: { slide: src.slideKey, sourcePage, deckPage: page },
154
+ unchangedCount,
155
+ changed: [...changedKeys, ...(ctChanged ? ['[Content_Types].xml' + '(媒体类型新增)'] : []), ...partsToAdd.map(([k]) => k + '(新增)')],
156
+ mediaAdded: partsToAdd.length,
157
+ mediaReused,
158
+ notesKept,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 单页化:source.pptx(可先 splice)→ "单页 + 完整母版/布局/主题"独立文件。
164
+ * 等价测试会话手工手术:sldIdLst 只留 1 条、其余幻灯/备注/关系/Override 删除、布局母版全保留。
165
+ */
166
+ export async function sliceSource({ source, page, out }) {
167
+ const zbuf = await readFile(source)
168
+ const z = zipRead(zbuf)
169
+ const src = locateSourceSlide(z, page)
170
+ const slideKey = src.slideKey
171
+ const slideName = src.slide.split('/').pop()
172
+ const relsKey = `ppt/slides/_rels/${slideName}.rels`
173
+ let notesKey = null
174
+ if (z.has(relsKey)) {
175
+ const rels = decodeXml(z.get(relsKey))
176
+ const m = rels.match(/Target="\.\.\/notesSlides\/(notesSlide\d+\.xml)"/)
177
+ if (m) notesKey = 'ppt/notesSlides/' + m[1]
178
+ }
179
+ // presentation.xml:sldIdLst 只留目标页(完整捕获标签含 />,否则替换产物缺闭合 → 文档损坏)
180
+ let pres = decodeXml(z.get('ppt/presentation.xml'))
181
+ const sldIds = [...pres.matchAll(/<p:sldId\b[^>]*?r:id="([^"]+)"\s*\/?>/g)]
182
+ pres = pres.replace(/<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/, () => {
183
+ const pick = sldIds.find((m) => m[1] === src.rid)
184
+ return `<p:sldIdLst>${pick?.[0] ?? ''}</p:sldIdLst>`
185
+ })
186
+ // presentation.xml.rels:删除非目标页的 slide 关系(layout/master/theme 等保留)
187
+ let presRels = decodeXml(z.get('ppt/_rels/presentation.xml.rels'))
188
+ presRels = presRels.replace(/<Relationship\s+Id="(rId\d+)"\s+Type="[^"]*\/slide"\s+Target="[^"]+"\s*\/>/g, (m0, rid) => (rid === src.rid ? m0 : ''))
189
+ // [Content_Types].xml:删除其他幻灯/备注 Override(保留目标页与其余类型)
190
+ const slideNo = slideName.match(/slide(\d+)\.xml$/)?.[1] ?? ''
191
+ const notesNo = notesKey?.match(/notesSlide(\d+)\.xml$/)?.[1] ?? ''
192
+ let ct = decodeXml(z.get('[Content_Types].xml'))
193
+ ct = ct.replace(/<Override\s+PartName="\/ppt\/slides\/slide\d+\.xml"\s+ContentType="[^"]*"\s*\/>/g, (m0) => m0.includes(`slide${slideNo}.xml`) ? m0 : '')
194
+ ct = ct.replace(/<Override\s+PartName="\/ppt\/notesSlides\/notesSlide\d+\.xml"\s+ContentType="[^"]*"\s*\/>/g, (m0) => (notesNo && m0.includes(`notesSlide${notesNo}.xml`)) ? m0 : '')
195
+
196
+ const entries = {}
197
+ for (const [name, data] of z) {
198
+ if (name === 'ppt/presentation.xml') { entries[name] = Buffer.from(pres, 'utf8'); continue }
199
+ if (name === 'ppt/_rels/presentation.xml.rels') { entries[name] = Buffer.from(presRels, 'utf8'); continue }
200
+ if (name === '[Content_Types].xml') { entries[name] = Buffer.from(ct, 'utf8'); continue }
201
+ if (name.startsWith('ppt/slides/')) {
202
+ if (name === slideKey || name === relsKey) entries[name] = data
203
+ continue
204
+ }
205
+ if (name.startsWith('ppt/notesSlides/')) {
206
+ if (notesKey && name === notesKey) entries[name] = data
207
+ continue
208
+ }
209
+ entries[name] = data
210
+ }
211
+ const outPath = out ?? source.replace(/\.pptx$/i, '-single.pptx')
212
+ await writeFile(outPath, zipWrite(entries))
213
+ return {
214
+ out: outPath, page,
215
+ layouts: [...z.keys()].filter((k) => k.startsWith('ppt/slideLayouts/')).length,
216
+ masters: [...z.keys()].filter((k) => k.startsWith('ppt/slideMasters/')).length,
217
+ entries: Object.keys(entries).length,
218
+ }
219
+ }
package/lib/state.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ppt-studio 状态:会话级(路由/档位)+ 项目级(state.json)。
3
+ * 会话状态持久化到 ~/.dsh/ppt-studio/session-<id>.json,供 resume 恢复。
4
+ */
5
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises'
6
+ import { existsSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+
10
+ const ROOT = join(homedir(), '.dsh', 'ppt-studio')
11
+ export const SESSION_DIR = ROOT
12
+
13
+ export const DEFAULT_SESSION = () => ({
14
+ routing: 'auto', // auto | on | off(语义判断开关)
15
+ mode: 'auto', // auto | free | mid | strict(协作模式)
16
+ fidelity: 'auto', // auto | strict | free
17
+ review: 'points', // none | points | every
18
+ quality: 'standard', // quick | standard | audit
19
+ engine: 'auto', // auto(=pptd 主引擎) | pptd | python-pptx(pptxgenjs 未实现,命令面明确拒绝)
20
+ quick: false, // 快速生成模式(低 token 快交付;跳过视觉审阅/素材/定调)
21
+ template: null, // 模板 id(内置或用户自建;/ppt template <id> 或 ppt_templates 选择)
22
+ pauseAfter: [],
23
+ workflowActive: false,
24
+ taskType: null,
25
+ deckDir: null,
26
+ })
27
+
28
+ const sessionPath = (sessionId) => join(ROOT, `session-${sanitize(sessionId)}.json`)
29
+
30
+ export async function loadSession(sessionId) {
31
+ return (await loadSessionDiag(sessionId)).state
32
+ }
33
+
34
+ /** 会话状态 + **诊断**(2026-09-18 新增)。区分"没有文件"(正常,新会话)与"文件存在却坏了"(异常)。
35
+ * 两者原来都静默返回默认值,于是状态文件一旦损坏,`quality: 'audit'` 会**无声**变成 `standard`。 */
36
+ export async function loadSessionDiag(sessionId) {
37
+ const p = sessionPath(sessionId)
38
+ if (!existsSync(p)) return { state: DEFAULT_SESSION(), error: null }
39
+ try {
40
+ return { state: { ...DEFAULT_SESSION(), ...JSON.parse(await readFile(p, 'utf8')) }, error: null }
41
+ } catch (e) {
42
+ return { state: DEFAULT_SESSION(), error: `会话状态文件读取/解析失败(${p}):${String(e?.message ?? e)}` }
43
+ }
44
+ }
45
+
46
+ export async function saveSession(sessionId, state) {
47
+ await mkdir(ROOT, { recursive: true })
48
+ await writeFile(sessionPath(sessionId), JSON.stringify(state, null, 2))
49
+ }
50
+
51
+ const DEFAULT_PROJECT = () => ({
52
+ taskType: null, // from-scratch | augment | edit | summarize | unknown
53
+ spec: {},
54
+ stage: null, // s0|s1|s2|s3|s4|s5|s6|done
55
+ pages: {}, // page ref → status
56
+ pauseAfter: [],
57
+ reviews: [],
58
+ engine: null,
59
+ quality: 'standard',
60
+ })
61
+
62
+ export async function loadProject(deckDir) {
63
+ return (await loadProjectDiag(deckDir)).state
64
+ }
65
+
66
+ /** 项目状态 + **诊断**(2026-09-18 新增)。**为什么要区分**:文件不存在 = 正常(新工程,用默认);
67
+ * 文件存在却读不了/解析失败 = 异常。原来两者都静默返回默认值 ⇒ `state.json` 一损坏,
68
+ * `quality: 'audit'` 会**无声**降成 `standard`——最严档(禁 autoDeclare + 强制视觉审阅 + 导出回读断言)
69
+ * 被降级而用户毫不知情。这正是"严格性只升不降"与"诚实边界优于静默降级"两条原则要防的事。 */
70
+ export async function loadProjectDiag(deckDir) {
71
+ const p = join(deckDir, 'state.json')
72
+ if (!existsSync(p)) return { state: DEFAULT_PROJECT(), error: null }
73
+ try {
74
+ return { state: { ...DEFAULT_PROJECT(), ...JSON.parse(await readFile(p, 'utf8')) }, error: null }
75
+ } catch (e) {
76
+ return { state: DEFAULT_PROJECT(), error: `项目状态文件读取/解析失败(${p}):${String(e?.message ?? e)}` }
77
+ }
78
+ }
79
+
80
+ export async function saveProject(deckDir, state) {
81
+ await mkdir(deckDir, { recursive: true })
82
+ await writeFile(join(deckDir, 'state.json'), JSON.stringify(state, null, 2))
83
+ }
84
+
85
+ function sanitize(s) { return String(s).replace(/[^\w-]+/g, '_').slice(0, 64) }
86
+
87
+ export async function fileExists(p) { return existsSync(p) }
88
+ export { access }