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.
- package/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -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 +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- 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 }
|