dsh-ppt 0.7.0 → 0.8.1

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 (37) hide show
  1. package/CHANGELOG.md +114 -91
  2. package/LICENSE +21 -21
  3. package/README.en.md +42 -219
  4. package/README.md +42 -234
  5. package/cordis.patch.yml +19 -19
  6. package/docs/CHANGELOG.en.md +29 -0
  7. package/docs/FEATURE-PLAN-2026-10-01.md +50 -50
  8. package/docs/LIBREOFFICE-INTEGRATION-2026-10-01.md +69 -69
  9. package/docs/USAGE.en.md +194 -0
  10. package/docs/USAGE.md +209 -0
  11. package/docs/VALIDATION.md +17 -0
  12. package/docs/validation/0.8.0.md +10 -0
  13. package/docs/validation-language-2026-10-03.md +16 -0
  14. package/lib/client.js +304 -0
  15. package/lib/execution.d.ts +1 -0
  16. package/lib/execution.js +1 -1
  17. package/lib/index.d.ts +1 -0
  18. package/lib/index.js +15 -1
  19. package/lib/tools.js +2 -1
  20. package/lib/types.d.ts +1 -0
  21. package/lib/workbench-language.d.ts +4 -0
  22. package/lib/workbench-language.js +65 -0
  23. package/lib/workbench.d.ts +32 -0
  24. package/lib/workbench.js +270 -0
  25. package/package.json +91 -84
  26. package/skills/dsh-ppt/SKILL.md +74 -74
  27. package/skills/dsh-ppt/package.json +10 -10
  28. package/skills/dsh-ppt/references/advanced.md +39 -39
  29. package/skills/dsh-ppt/references/copywriting.md +59 -59
  30. package/skills/dsh-ppt/references/syntax.md +44 -44
  31. package/skills/dsh-ppt/references/themes.md +61 -61
  32. package/skills/dsh-ppt/references/troubleshooting.md +19 -19
  33. package/skills/dsh-ppt/scripts/build-deck.mjs +184 -184
  34. package/skills/dsh-ppt/scripts/deck-advanced.mjs +468 -456
  35. package/skills/dsh-ppt/scripts/deck-core.mjs +2347 -2347
  36. package/skills/dsh-ppt/scripts/deck-media.mjs +160 -160
  37. package/skills/dsh-ppt/scripts/deck-render.mjs +270 -236
@@ -1,462 +1,474 @@
1
- import { mkdirSync, readFileSync, statSync, openSync, closeSync, rmSync } from 'node:fs'
2
- import { resolve, dirname, extname } from 'node:path'
3
- import { createHash, randomUUID } from 'node:crypto'
4
- import { inflateSync } from 'node:zlib'
5
- import { buildPptx, renderHtml, normalizeBuildOptions, normalizeSlides, resolveTheme, resolveLanguage, resolveDeckFileName, commitDeckArtifacts, DECK_VERSION, pptxEastAsianFont } from './deck-core.mjs'
6
- import { MEDIA_LAYOUTS, mediaBoxes, LOGO_BOX, FOOTER_BOX, chartColors, normalizeChart } from './deck-media.mjs'
7
- export { buildDeck, buildThemePreview, listThemes, resolveTheme, resolveLanguage } from './deck-core.mjs'
8
-
9
- const MAX_IMAGE = 5 * 1024 * 1024, MAX_ASSETS = 32 * 1024 * 1024, MAX_DOCUMENT = 64 * 1024 * 1024
10
- const MAX_HISTORY = 20
11
- const layouts = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table', ...MEDIA_LAYOUTS])
12
- const hash = data => createHash('sha256').update(data).digest('hex')
13
- const clone = value => structuredClone(value)
14
- function abort(signal) { signal?.throwIfAborted() }
15
- function fail(message) { throw new Error('dsh-ppt:' + message) }
16
-
17
- const scenarios = [
18
- { id: 'weekly', name: { zh: '工作周报', en: 'Weekly report' }, theme: 'data', sections: [['本周工作', 'This week'], ['完成情况', 'Progress'], ['问题与风险', 'Risks'], ['下周计划', 'Next week']] },
19
- { id: 'defense', name: { zh: '论文答辩', en: 'Thesis defense' }, theme: 'soft', sections: [['研究问题', 'Research question'], ['相关研究', 'Related work'], ['研究方法', 'Method'], ['研究结果', 'Results'], ['结论与局限', 'Conclusions and limitations']] },
20
- { id: 'project', name: { zh: '项目汇报', en: 'Project update' }, theme: 'swiss', sections: [['目标与范围', 'Goals and scope'], ['进度与交付', 'Progress and delivery'], ['数据与证据', 'Evidence'], ['风险与决策', 'Risks and decisions'], ['下一步', 'Next steps']] },
21
- { id: 'pitch', name: { zh: '项目路演', en: 'Pitch deck' }, theme: 'velvet', sections: [['用户问题', 'Customer problem'], ['解决方案', 'Solution'], ['产品与证据', 'Product and evidence'], ['商业模式', 'Business model'], ['计划与需求', 'Plan and ask']] },
22
- ]
23
-
24
- export function listTemplates(lang = 'zh') {
25
- const language = resolveLanguage(lang).id === 'en' ? 'en' : 'zh'
26
- return scenarios.map(s => ({ id: s.id, name: s.name[language], theme: s.theme,
27
- outline: s.sections.map(p => p[language === 'en' ? 1 : 0]),
28
- slides: templateSlides(s, s.name[language], language) }))
29
- }
30
- function templateSlides(scenario, title, lang) {
31
- const en = lang === 'en'
32
- return [{ layout: 'cover', title, subtitle: en ? 'To fill: presenter and date' : '待填写:汇报人和日期' },
33
- ...scenario.sections.map(section => ({ layout: 'bullets', title: section[en ? 1 : 0], bullets: [en ? 'To fill: specific facts, evidence and next action' : '待填写:具体事实、依据与下一步行动'] })),
34
- { layout: 'closing', title: en ? 'Next action' : '下一步行动', subtitle: en ? 'To fill: owner and deadline' : '待填写:负责人和时间' }]
35
- }
36
-
37
- function imageInfo(bytes, page) {
38
- let width, height, mime
39
- if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
40
- if (bytes.length < 45 || bytes.toString('ascii', 12, 16) !== 'IHDR') fail(`${page} PNG 文件不完整`)
41
- width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20); mime = 'image/png'
42
- let offset = 8, ended = false; const compressed = []
43
- while (offset + 12 <= bytes.length) {
44
- const length = bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8)
45
- if (offset + length + 12 > bytes.length) fail(`${page} PNG 数据截断`)
46
- if (type === 'IDAT') compressed.push(bytes.subarray(offset + 8, offset + 8 + length))
47
- if (type === 'IEND') { ended = true; break }
48
- offset += length + 12
49
- }
50
- if (!ended || compressed.length === 0) fail(`${page} PNG 缺少图像数据`)
51
- try { inflateSync(Buffer.concat(compressed), { maxOutputLength: 128 * 1024 * 1024 }) } catch { fail(`${page} PNG 图像数据无法解码或过大`) }
52
- } else if (bytes[0] === 0xff && bytes[1] === 0xd8) {
53
- mime = 'image/jpeg'
54
- let offset = 2
55
- while (offset + 4 < bytes.length) {
56
- if (bytes[offset] !== 0xff) break
57
- while (bytes[offset] === 0xff) offset++
58
- const marker = bytes[offset++]
59
- if (marker === 0xd9 || marker === 0xda) break
60
- if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue
61
- const length = bytes.readUInt16BE(offset)
62
- if (length < 2 || offset + length > bytes.length) fail(`${page} JPEG 数据截断`)
63
- if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7) { height = bytes.readUInt16BE(offset + 3); width = bytes.readUInt16BE(offset + 5) }
64
- offset += length
65
- }
66
- if (!bytes.subarray(-2).equals(Buffer.from([0xff, 0xd9]))) fail(`${page} JPEG 文件不完整`)
67
- } else fail(`${page} 图片仅支持 PNG / JPEG,请先转换格式`)
68
- if (!width || !height || width > 16000 || height > 16000 || width * height > 32_000_000) fail(`${page} 图片尺寸无效或超过 3200 万像素`)
69
- return { width, height, mime }
70
- }
71
-
72
- function addAsset(image, assets, cwd, page) {
73
- if (image.assetId && !image.src) {
74
- if (!assets[image.assetId]) fail(`${page} 找不到嵌入图片 ${image.assetId}`)
75
- return image.assetId
76
- }
77
- const source = String(image.src ?? '').trim()
78
- let bytes
79
- if (/^data:/i.test(source)) {
80
- const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=\r\n]+)$/.exec(source)
81
- if (!match || source.length > MAX_IMAGE * 1.4) fail(`${page} 图片 data URI 无效或超过 5 MiB`)
82
- bytes = Buffer.from(match[2], 'base64')
83
- } else {
84
- if (!source || /^[a-z][a-z0-9+.-]*:/i.test(source) && !/^[a-z]:[\\/]/i.test(source)) fail(`${page} 图片需本地路径或 PNG/JPEG data URI,不支持外链`)
85
- const path = resolve(cwd, source)
86
- let size
87
- try { size = statSync(path).size } catch { fail(`${page} 图片不存在:${path}`) }
88
- if (size > MAX_IMAGE) fail(`${page} 图片超过 5 MiB:${path}`)
89
- try { bytes = readFileSync(path) } catch { fail(`${page} 图片无法读取:${path}`) }
90
- }
91
- if (bytes.length > MAX_IMAGE) fail(`${page} 图片超过 5 MiB`)
92
- const info = imageInfo(bytes, page), id = 'img-' + hash(bytes)
93
- assets[id] = { ...info, data: `data:${info.mime};base64,${bytes.toString('base64')}` }
94
- return id
95
- }
96
-
97
- function normalizeBrand(input, assets, cwd) {
98
- if (input === undefined || input === null) return undefined
99
- if (typeof input !== 'object' || Array.isArray(input)) fail('brand 必须是对象')
100
- const brand = {}
101
- for (const key of ['primaryColor', 'backgroundColor', 'textColor']) {
102
- if (input[key] !== undefined && input[key] !== '') {
103
- if (!/^#[\da-f]{6}$/i.test(input[key])) fail(`brand.${key} 必须为 #RRGGBB`)
104
- brand[key] = input[key].toUpperCase()
105
- }
106
- }
107
- if (input.fontFamily) {
108
- const font = String(input.fontFamily).trim()
109
- if (font.length > 80 || !/^[\p{L}\p{N} _-]+$/u.test(font)) fail('brand.fontFamily 需单个字体名称,最多 80 字符')
110
- brand.fontFamily = font
111
- }
112
- if (input.name) brand.name = String(input.name).slice(0, 100)
113
- if (input.footer) {
114
- if (String(input.footer).length > 100) fail('brand.footer 最多 100 字符')
115
- brand.footer = String(input.footer)
116
- }
117
- if (input.logo) brand.logoAssetId = addAsset({ src: input.logo }, assets, cwd, '品牌 Logo')
118
- else if (input.logoAssetId) {
119
- if (!assets[input.logoAssetId]) fail('品牌 Logo 嵌入资产不存在')
120
- brand.logoAssetId = input.logoAssetId
121
- }
122
- return brand
123
- }
124
- function brandedTheme(manifest) {
125
- const theme = clone(resolveTheme(manifest.theme)), brand = manifest.brand
126
- if (brand?.primaryColor) theme.palette.accent = brand.primaryColor
127
- if (brand?.backgroundColor) theme.palette.bg = brand.backgroundColor
128
- if (brand?.textColor) theme.palette.fg = brand.textColor
129
- if (brand?.fontFamily) theme.fonts = { heading: brand.fontFamily, body: brand.fontFamily }
130
- return theme
131
- }
132
- function embedSlides(slides, assets, cwd) {
133
- return slides.map((slide, index) => {
134
- if (!slide.image) return slide
135
- const assetId = addAsset(slide.image, assets, cwd, `第 ${index + 1} 页`)
136
- const { src, ...image } = slide.image
137
- return { ...slide, image: { ...image, assetId } }
138
- })
139
- }
140
- function validateRawSlides(slides) {
141
- if (slides === undefined || slides === null) return
142
- if (!Array.isArray(slides)) fail('slides 必须是数组')
143
- slides.forEach((s, i) => {
144
- if (!s || typeof s !== 'object' || Array.isArray(s)) fail(`第 ${i + 1} 页必须是对象`)
145
- if (s.layout && !layouts.has(s.layout)) fail(`第 ${i + 1} 页未知布局 ${s.layout}`)
146
- if (s.chart && s.image) fail(`第 ${i + 1} 页不能同时放入 image 和 chart`)
147
- if (s.image && s.layout && !['image', 'image-left', 'image-right'].includes(s.layout)) fail(`第 ${i + 1} 页图片需使用 image / image-left / image-right 布局`)
148
- if (s.chart && s.layout && s.layout !== 'chart') fail(`第 ${i + 1} 页图表需使用 chart 布局`)
149
- })
150
- }
151
- function snapshot(manifest) {
152
- return clone(Object.fromEntries(['title', 'theme', 'language', 'motion', 'slides', 'brand', 'template'].filter(k => manifest[k] !== undefined).map(k => [k, manifest[k]])))
153
- }
154
- function collectAssets(manifest) {
155
- const ids = new Set()
156
- for (const state of [manifest, ...(manifest.history ?? [])]) {
157
- if (state.brand?.logoAssetId) ids.add(state.brand.logoAssetId)
158
- for (const slide of state.slides) if (slide.image?.assetId) ids.add(slide.image.assetId)
159
- }
160
- manifest.assets = Object.fromEntries([...ids].map(id => [id, manifest.assets[id]]))
161
- const size = Object.values(manifest.assets).reduce((sum, asset) => sum + (asset?.data?.length ?? 0), 0)
162
- if (size > MAX_ASSETS * 1.4) fail('工程内图片总量超过 32 MiB,请压缩图片或减少图片')
163
- }
164
-
165
- function embeddedAssetError(asset, id) {
166
- try {
167
- if (!asset || typeof asset.data !== 'string' || asset.data.length > MAX_IMAGE * 1.4) fail('嵌入图片数据缺失或过大')
168
- const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(asset.data)
169
- if (!match) fail('嵌入图片格式无效')
170
- const bytes = Buffer.from(match[2], 'base64')
171
- if (bytes.length > MAX_IMAGE || bytes.toString('base64') !== match[2]) fail('嵌入图片编码无效或过大')
172
- const info = imageInfo(bytes, '嵌入图片')
173
- if (asset.width !== info.width || asset.height !== info.height || asset.mime !== info.mime || id !== 'img-' + hash(bytes)) fail('嵌入图片尺寸或内容摘要不匹配')
174
- return undefined
175
- } catch (error) { return error.message }
176
- }
177
-
178
- // This is a conservative fit estimate. Fonts and PowerPoint/WPS rendering still need visual inspection.
179
- function textWidth(text) { return [...String(text ?? '')].reduce((n, c) => n + (/[^\x00-\xff]/.test(c) ? 1 : 0.55), 0) }
180
- function contrast(a, b) {
181
- const lum = color => {
182
- const rgb = color.slice(1).match(/../g).map(v => parseInt(v, 16) / 255).map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4)
183
- return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722
184
- }
185
- const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05)
186
- }
187
- export function checkManifest(manifest) {
188
- const issues = [], theme = brandedTheme(manifest)
189
- const invalidAssets = new Map()
190
- const assetError = id => {
191
- if (!invalidAssets.has(id)) invalidAssets.set(id, embeddedAssetError(manifest.assets?.[id], id))
192
- return invalidAssets.get(id)
193
- }
194
- const issue = (index, code, message, suggestion, severity = 'warning') => issues.push({ slide: index + 1, slideId: manifest.slides[index]?.id, severity, code, message, suggestion })
195
- manifest.slides.forEach((slide, i) => {
196
- if (!slide.title?.trim()) issue(i, 'missing-title', '页面没有标题', '补充能说明本页内容的标题')
197
- const titleLimit = ['cover', 'closing', 'statement', 'quote', 'section'].includes(slide.layout) ? 40 : 24
198
- if (textWidth(slide.title) > titleLimit) issue(i, 'title-overflow', '标题可能换行过多或超过标题区域', '缩短标题,把解释移入正文')
199
- const mixed = ['image-left', 'image-right'].includes(slide.layout), bullets = slide.bullets ?? []
200
- if (bullets.length > (mixed ? 5 : 8)) issue(i, 'dense-bullets', `本页有 ${bullets.length} 条要点,放映时过密`, '减少要点或拆成多页')
201
- const width = mixed ? 19 : 37
202
- const lines = bullets.reduce((sum, b) => sum + Math.max(1, Math.ceil(textWidth(b) / width)), 0)
203
- + (slide.subtitle ? Math.ceil(textWidth(slide.subtitle) / width) : 0)
204
- if (lines > (mixed ? 9 : 11)) issue(i, 'body-overflow', '正文估计超过可用高度', '先精简文字,再分拆页面,避免继续缩小字体')
205
- if ([slide.title, slide.subtitle, ...bullets].some(s => /待填写|To fill:/i.test(String(s)))) issue(i, 'template-unfilled', '模板中还有待填写内容', '提供实际事实、依据和行动,填完后重新检查')
206
- if (slide.layout === 'table') {
207
- if ((slide.rows?.[0]?.length ?? 0) > 6) issue(i, 'small-font', '表格超过 6 列,使用 14pt,小屏放映可能难以阅读', '减少列数或把重点数据改成图表,导出后检查可读性')
208
- if (slide.rows?.some(row => row.some(c => textWidth(c) > 18))) issue(i, 'table-density', '表格单元格文字较长,可能换行拥挤', '精简单元格或减少列数')
209
- }
210
- if (slide.image) {
211
- const asset = manifest.assets?.[slide.image.assetId], box = mediaBoxes(slide.layout).image
212
- if (!asset) issue(i, 'missing-image', '找不到本页的嵌入图片', '重新指定图片路径后编辑本页', 'error')
213
- else {
214
- if (assetError(slide.image.assetId)) issue(i, 'invalid-image', assetError(slide.image.assetId), '重新提供本地图片后编辑该页', 'error')
215
- if (asset.width < box.w * 100 || asset.height < box.h * 100) issue(i, 'low-resolution', `图片为 ${asset.width}×${asset.height},放大后可能模糊`, '换成更高分辨率图片;Logo 等小图可忽略此提示')
216
- }
217
- if (!slide.image.alt?.trim()) issue(i, 'missing-alt', '图片没有说明文字', '填写 image.alt,方便读屏及理解图片')
218
- }
219
- if (slide.chart) {
220
- try { normalizeChart(slide.chart, i + 1) } catch (error) { issue(i, 'invalid-chart', error.message, '修正图表类别和数值', 'error') }
221
- if (slide.chart.categories?.length > 10 || slide.chart.categories?.some(c => textWidth(c) > 10)) issue(i, 'chart-density', '图表类别较多或标签较长,可能相互遮挡', '减少类别,或使用横向条形图')
222
- if (slide.chart.series?.some(s => textWidth(s.name) > 14)) issue(i, 'chart-legend', '图例名称较长', '精简系列名称并检查渲染')
223
- }
224
- if (contrast(theme.palette.bg, theme.palette.fg) < 4.5) issue(i, 'low-contrast', '正文与背景对比度低于 4.5:1', '调整品牌背景色或文字色')
225
- })
226
- if (manifest.brand?.logoAssetId && !manifest.assets?.[manifest.brand.logoAssetId]) issue(0, 'missing-logo', '品牌 Logo 资产不存在', '重新提供 Logo', 'error')
227
- else if (manifest.brand?.logoAssetId && assetError(manifest.brand.logoAssetId)) issue(0, 'invalid-logo', assetError(manifest.brand.logoAssetId), '重新提供 Logo', 'error')
228
- return { ok: !issues.some(i => i.severity === 'error'), slideCount: manifest.slides.length,
229
- errorCount: issues.filter(i => i.severity === 'error').length, warningCount: issues.filter(i => i.severity === 'warning').length,
230
- issues, note: '文字尺寸与溢出为静态估算;最终请检查 HTML 放映和 PowerPoint / WPS 实际显示。页脚和页码的小字号不计入正文检查。' }
231
- }
232
-
233
- async function exportPptx(manifest, theme, language, signal) {
234
- const base = buildPptx(manifest, theme, language)
235
- if (!manifest.brand?.logoAssetId && !manifest.brand?.footer && !manifest.slides.some(s => s.image || s.chart)) return base
236
- abort(signal)
237
- let lib
238
- try { lib = await import('@office-kit/pptx') } catch (error) { fail('图片、图表和品牌导出需要 @office-kit/pptx@0.21.0。插件请重新安装;独立技能请在技能目录运行 npm install --ignore-scripts。' + error.message) }
239
- abort(signal)
240
- const presentation = await lib.loadPresentation(base), slides = lib.getSlides(presentation)
241
- const emu = box => Object.fromEntries(Object.entries(box).map(([k, v]) => [k, Math.round(v * 914400)]))
242
- const putImage = (slide, id, box, fit, name, alt) => {
243
- const asset = manifest.assets[id], bytes = Buffer.from(asset.data.split(',')[1], 'base64')
244
- const shape = lib.addSlideImage(slide, bytes, { ...emu(box), name, fit: fit === 'contain' ? 'contain' : 'fill' })
245
- lib.setShapeDescription(shape, alt || name)
246
- if (fit === 'cover') {
247
- const natural = asset.width / asset.height, target = box.w / box.h
248
- const fraction = natural > target ? (1 - target / natural) / 2 : (1 - natural / target) / 2
249
- lib.setShapeImageCrop(shape, natural > target ? { left: fraction, right: fraction } : { top: fraction, bottom: fraction })
250
- }
251
- }
252
- manifest.slides.forEach((source, index) => {
253
- abort(signal)
254
- const slide = slides[index], boxes = mediaBoxes(source.layout)
255
- if (source.image) putImage(slide, source.image.assetId, boxes.image, source.image.fit, 'Slide image', source.image.alt)
256
- if (source.chart) {
257
- const colors = chartColors(theme), font = pptxEastAsianFont(theme.fonts.body)
258
- const style = { font, sizePt: 17, color: theme.palette.fg }
259
- lib.addSlideChart(slide, { ...emu(boxes.chart), name: 'Editable data chart', spec: {
260
- kind: source.chart.kind, categories: source.chart.categories,
261
- series: source.chart.series.map((s, i) => ({ ...s, color: colors[i], ...(source.chart.kind === 'pie' ? { pointColors: source.chart.categories.map((_, j) => colors[j % colors.length]) } : {}) })),
262
- chartAreaFill: theme.palette.bg, chartAreaStrokeColor: theme.palette.bg, plotAreaFill: theme.palette.bg,
263
- categoryAxisLabelStyle: style, valueAxisLabelStyle: style,
264
- valueAxisMinorTickMark: 'none', categoryAxisMinorTickMark: 'none', valueAxisMajorTickMark: 'none', categoryAxisMajorTickMark: 'none',
265
- valueAxisMajorGridlines: true, valueAxisMajorGridlineColor: theme.palette.panel,
266
- ...(source.chart.kind === 'bar' ? { categoryAxisOrientation: 'maxMin', valueAxisCrosses: 'max' } : {}),
267
- ...(source.chart.kind === 'pie' ? { dataLabels: { showPercent: true, showValue: false, showCategory: false, showSeriesName: false, numberFormat: '0.0%', position: 'bestFit', textStyle: style } } : {}),
268
- ...(source.chart.unit ? { valueAxisTitle: source.chart.unit, valueAxisTitleStyle: style } : {}),
269
- legend: { position: 'b', textStyle: style },
270
- } })
271
- }
272
- if (manifest.brand?.logoAssetId) putImage(slide, manifest.brand.logoAssetId, LOGO_BOX, 'contain', 'Brand logo', manifest.brand.name)
273
- if (manifest.brand?.footer) {
274
- const footer = lib.addSlideTextBox(slide, { ...emu(FOOTER_BOX), text: manifest.brand.footer, name: 'Brand footer' })
275
- lib.setShapeTextFormat(footer, { size: 10, font: pptxEastAsianFont(theme.fonts.body), color: theme.palette.muted })
276
- }
277
- })
278
- const invalid = lib.validatePresentation(presentation).filter(i => i.severity === 'error')
279
- if (invalid.length) fail('PPTX 结构校验失败:' + invalid.map(i => i.message).join(';'))
280
- const bytes = await lib.savePresentation(presentation)
281
- abort(signal)
282
- return Buffer.from(bytes)
283
- }
284
-
285
- function documentBytes(manifest) {
286
- const text = JSON.stringify(manifest, null, 2) + '\n'
287
- if (Buffer.byteLength(text) > MAX_DOCUMENT) fail('JSON 工程含历史超过 64 MiB,请缩减图片或清理历史后另存工程')
288
- return text
289
- }
290
- function result(manifest, paths) {
291
- return { ok: true, title: manifest.title, theme: manifest.theme, language: manifest.language,
292
- slideCount: manifest.slides.length, deckId: manifest.deckId, revision: manifest.revision,
293
- slides: manifest.slides.map((s, i) => ({ slide: i + 1, id: s.id, title: s.title, layout: s.layout })),
294
- undoAvailable: manifest.history.length, deliveryStatus: manifest.quality?.issues.some(i => i.code === 'template-unfilled') ? 'draft' : 'ready-for-review',
295
- quality: manifest.quality, verification: { static: 'checked', pptxRender: 'not-verified', visual: 'not-verified' }, outputDir: dirname(paths.json),
296
- files: paths, htmlPath: paths.html, pptxPath: paths.pptx, jsonPath: paths.json }
297
- }
298
- function pathsFor(jsonPath) {
299
- const base = jsonPath.slice(0, -5)
300
- return { html: base + '.html', pptx: base + '.pptx', json: jsonPath }
301
- }
302
- function withLock(path, commit) {
303
- const lock = path + '.dsh-ppt-lock'
304
- let fd
305
- try { fd = openSync(lock, 'wx') } catch { fail('该工程正在被另一次修改,请稍后重试:' + path) }
306
- try { return commit() } finally { closeSync(fd); rmSync(lock, { force: true }) }
307
- }
308
- async function generate(manifest, signal) {
309
- abort(signal)
310
- collectAssets(manifest)
311
- manifest.slideCount = manifest.slides.length
312
- manifest.quality = checkManifest(manifest)
313
- if (manifest.quality.errorCount) fail(manifest.quality.issues.filter(i => i.severity === 'error').map(i => `第 ${i.slide} 页:${i.message}`).join(';'))
314
- const theme = brandedTheme(manifest), language = resolveLanguage(manifest.language)
1
+ import { mkdirSync, readFileSync, statSync, openSync, closeSync, rmSync } from 'node:fs'
2
+ import { resolve, dirname, extname } from 'node:path'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { inflateSync } from 'node:zlib'
5
+ import { buildPptx, renderHtml, normalizeBuildOptions, normalizeSlides, resolveTheme, resolveLanguage, resolveDeckFileName, commitDeckArtifacts, DECK_VERSION, pptxEastAsianFont } from './deck-core.mjs'
6
+ import { MEDIA_LAYOUTS, mediaBoxes, LOGO_BOX, FOOTER_BOX, chartColors, normalizeChart } from './deck-media.mjs'
7
+ export { buildDeck, buildThemePreview, listThemes, resolveTheme, resolveLanguage } from './deck-core.mjs'
8
+
9
+ const MAX_IMAGE = 5 * 1024 * 1024, MAX_ASSETS = 32 * 1024 * 1024, MAX_DOCUMENT = 64 * 1024 * 1024
10
+ const MAX_HISTORY = 20
11
+ const layouts = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table', ...MEDIA_LAYOUTS])
12
+ const hash = data => createHash('sha256').update(data).digest('hex')
13
+ const clone = value => structuredClone(value)
14
+ function abort(signal) { signal?.throwIfAborted() }
15
+ function fail(message) { throw new Error('dsh-ppt:' + message) }
16
+
17
+ const scenarios = [
18
+ { id: 'weekly', name: { zh: '工作周报', en: 'Weekly report' }, theme: 'data', sections: [['本周工作', 'This week'], ['完成情况', 'Progress'], ['问题与风险', 'Risks'], ['下周计划', 'Next week']] },
19
+ { id: 'defense', name: { zh: '论文答辩', en: 'Thesis defense' }, theme: 'soft', sections: [['研究问题', 'Research question'], ['相关研究', 'Related work'], ['研究方法', 'Method'], ['研究结果', 'Results'], ['结论与局限', 'Conclusions and limitations']] },
20
+ { id: 'project', name: { zh: '项目汇报', en: 'Project update' }, theme: 'swiss', sections: [['目标与范围', 'Goals and scope'], ['进度与交付', 'Progress and delivery'], ['数据与证据', 'Evidence'], ['风险与决策', 'Risks and decisions'], ['下一步', 'Next steps']] },
21
+ { id: 'pitch', name: { zh: '项目路演', en: 'Pitch deck' }, theme: 'velvet', sections: [['用户问题', 'Customer problem'], ['解决方案', 'Solution'], ['产品与证据', 'Product and evidence'], ['商业模式', 'Business model'], ['计划与需求', 'Plan and ask']] },
22
+ ]
23
+
24
+ export function listTemplates(lang = 'zh') {
25
+ const language = resolveLanguage(lang).id === 'en' ? 'en' : 'zh'
26
+ return scenarios.map(s => ({ id: s.id, name: s.name[language], theme: s.theme,
27
+ outline: s.sections.map(p => p[language === 'en' ? 1 : 0]),
28
+ slides: templateSlides(s, s.name[language], language) }))
29
+ }
30
+ function templateSlides(scenario, title, lang) {
31
+ const en = lang === 'en'
32
+ return [{ layout: 'cover', title, subtitle: en ? 'To fill: presenter and date' : '待填写:汇报人和日期' },
33
+ ...scenario.sections.map(section => ({ layout: 'bullets', title: section[en ? 1 : 0], bullets: [en ? 'To fill: specific facts, evidence and next action' : '待填写:具体事实、依据与下一步行动'] })),
34
+ { layout: 'closing', title: en ? 'Next action' : '下一步行动', subtitle: en ? 'To fill: owner and deadline' : '待填写:负责人和时间' }]
35
+ }
36
+
37
+ function imageInfo(bytes, page) {
38
+ let width, height, mime
39
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
40
+ if (bytes.length < 45 || bytes.toString('ascii', 12, 16) !== 'IHDR') fail(`${page} PNG 文件不完整`)
41
+ width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20); mime = 'image/png'
42
+ let offset = 8, ended = false; const compressed = []
43
+ while (offset + 12 <= bytes.length) {
44
+ const length = bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8)
45
+ if (offset + length + 12 > bytes.length) fail(`${page} PNG 数据截断`)
46
+ if (type === 'IDAT') compressed.push(bytes.subarray(offset + 8, offset + 8 + length))
47
+ if (type === 'IEND') { ended = true; break }
48
+ offset += length + 12
49
+ }
50
+ if (!ended || compressed.length === 0) fail(`${page} PNG 缺少图像数据`)
51
+ try { inflateSync(Buffer.concat(compressed), { maxOutputLength: 128 * 1024 * 1024 }) } catch { fail(`${page} PNG 图像数据无法解码或过大`) }
52
+ } else if (bytes[0] === 0xff && bytes[1] === 0xd8) {
53
+ mime = 'image/jpeg'
54
+ let offset = 2
55
+ while (offset + 4 < bytes.length) {
56
+ if (bytes[offset] !== 0xff) break
57
+ while (bytes[offset] === 0xff) offset++
58
+ const marker = bytes[offset++]
59
+ if (marker === 0xd9 || marker === 0xda) break
60
+ if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue
61
+ const length = bytes.readUInt16BE(offset)
62
+ if (length < 2 || offset + length > bytes.length) fail(`${page} JPEG 数据截断`)
63
+ if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7) { height = bytes.readUInt16BE(offset + 3); width = bytes.readUInt16BE(offset + 5) }
64
+ offset += length
65
+ }
66
+ if (!bytes.subarray(-2).equals(Buffer.from([0xff, 0xd9]))) fail(`${page} JPEG 文件不完整`)
67
+ } else fail(`${page} 图片仅支持 PNG / JPEG,请先转换格式`)
68
+ if (!width || !height || width > 16000 || height > 16000 || width * height > 32_000_000) fail(`${page} 图片尺寸无效或超过 3200 万像素`)
69
+ return { width, height, mime }
70
+ }
71
+
72
+ function addAsset(image, assets, cwd, page) {
73
+ if (image.assetId && !image.src) {
74
+ if (!assets[image.assetId]) fail(`${page} 找不到嵌入图片 ${image.assetId}`)
75
+ return image.assetId
76
+ }
77
+ const source = String(image.src ?? '').trim()
78
+ let bytes
79
+ if (/^data:/i.test(source)) {
80
+ const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=\r\n]+)$/.exec(source)
81
+ if (!match || source.length > MAX_IMAGE * 1.4) fail(`${page} 图片 data URI 无效或超过 5 MiB`)
82
+ bytes = Buffer.from(match[2], 'base64')
83
+ } else {
84
+ if (!source || /^[a-z][a-z0-9+.-]*:/i.test(source) && !/^[a-z]:[\\/]/i.test(source)) fail(`${page} 图片需本地路径或 PNG/JPEG data URI,不支持外链`)
85
+ const path = resolve(cwd, source)
86
+ let size
87
+ try { size = statSync(path).size } catch { fail(`${page} 图片不存在:${path}`) }
88
+ if (size > MAX_IMAGE) fail(`${page} 图片超过 5 MiB:${path}`)
89
+ try { bytes = readFileSync(path) } catch { fail(`${page} 图片无法读取:${path}`) }
90
+ }
91
+ if (bytes.length > MAX_IMAGE) fail(`${page} 图片超过 5 MiB`)
92
+ const info = imageInfo(bytes, page), id = 'img-' + hash(bytes)
93
+ assets[id] = { ...info, data: `data:${info.mime};base64,${bytes.toString('base64')}` }
94
+ return id
95
+ }
96
+
97
+ function normalizeBrand(input, assets, cwd) {
98
+ if (input === undefined || input === null) return undefined
99
+ if (typeof input !== 'object' || Array.isArray(input)) fail('brand 必须是对象')
100
+ const brand = {}
101
+ for (const key of ['primaryColor', 'backgroundColor', 'textColor']) {
102
+ if (input[key] !== undefined && input[key] !== '') {
103
+ if (!/^#[\da-f]{6}$/i.test(input[key])) fail(`brand.${key} 必须为 #RRGGBB`)
104
+ brand[key] = input[key].toUpperCase()
105
+ }
106
+ }
107
+ if (input.fontFamily) {
108
+ const font = String(input.fontFamily).trim()
109
+ if (font.length > 80 || !/^[\p{L}\p{N} _-]+$/u.test(font)) fail('brand.fontFamily 需单个字体名称,最多 80 字符')
110
+ brand.fontFamily = font
111
+ }
112
+ if (input.name) brand.name = String(input.name).slice(0, 100)
113
+ if (input.footer) {
114
+ if (String(input.footer).length > 100) fail('brand.footer 最多 100 字符')
115
+ brand.footer = String(input.footer)
116
+ }
117
+ if (input.logo) brand.logoAssetId = addAsset({ src: input.logo }, assets, cwd, '品牌 Logo')
118
+ else if (input.logoAssetId) {
119
+ if (!assets[input.logoAssetId]) fail('品牌 Logo 嵌入资产不存在')
120
+ brand.logoAssetId = input.logoAssetId
121
+ }
122
+ return brand
123
+ }
124
+ function brandedTheme(manifest) {
125
+ const theme = clone(resolveTheme(manifest.theme)), brand = manifest.brand
126
+ if (brand?.primaryColor) theme.palette.accent = brand.primaryColor
127
+ if (brand?.backgroundColor) theme.palette.bg = brand.backgroundColor
128
+ if (brand?.textColor) theme.palette.fg = brand.textColor
129
+ if (brand?.fontFamily) theme.fonts = { heading: brand.fontFamily, body: brand.fontFamily }
130
+ return theme
131
+ }
132
+ function embedSlides(slides, assets, cwd) {
133
+ return slides.map((slide, index) => {
134
+ if (!slide.image) return slide
135
+ const assetId = addAsset(slide.image, assets, cwd, `第 ${index + 1} 页`)
136
+ const { src, ...image } = slide.image
137
+ return { ...slide, image: { ...image, assetId } }
138
+ })
139
+ }
140
+ function validateRawSlides(slides) {
141
+ if (slides === undefined || slides === null) return
142
+ if (!Array.isArray(slides)) fail('slides 必须是数组')
143
+ slides.forEach((s, i) => {
144
+ if (!s || typeof s !== 'object' || Array.isArray(s)) fail(`第 ${i + 1} 页必须是对象`)
145
+ if (s.layout && !layouts.has(s.layout)) fail(`第 ${i + 1} 页未知布局 ${s.layout}`)
146
+ if (s.chart && s.image) fail(`第 ${i + 1} 页不能同时放入 image 和 chart`)
147
+ if (s.image && s.layout && !['image', 'image-left', 'image-right'].includes(s.layout)) fail(`第 ${i + 1} 页图片需使用 image / image-left / image-right 布局`)
148
+ if (s.chart && s.layout && s.layout !== 'chart') fail(`第 ${i + 1} 页图表需使用 chart 布局`)
149
+ })
150
+ }
151
+ function snapshot(manifest) {
152
+ return clone(Object.fromEntries(['title', 'theme', 'language', 'motion', 'slides', 'brand', 'template'].filter(k => manifest[k] !== undefined).map(k => [k, manifest[k]])))
153
+ }
154
+ function collectAssets(manifest) {
155
+ const ids = new Set()
156
+ for (const state of [manifest, ...(manifest.history ?? [])]) {
157
+ if (state.brand?.logoAssetId) ids.add(state.brand.logoAssetId)
158
+ for (const slide of state.slides) if (slide.image?.assetId) ids.add(slide.image.assetId)
159
+ }
160
+ manifest.assets = Object.fromEntries([...ids].map(id => [id, manifest.assets[id]]))
161
+ const size = Object.values(manifest.assets).reduce((sum, asset) => sum + (asset?.data?.length ?? 0), 0)
162
+ if (size > MAX_ASSETS * 1.4) fail('工程内图片总量超过 32 MiB,请压缩图片或减少图片')
163
+ }
164
+
165
+ function embeddedAssetError(asset, id) {
166
+ try {
167
+ if (!asset || typeof asset.data !== 'string' || asset.data.length > MAX_IMAGE * 1.4) fail('嵌入图片数据缺失或过大')
168
+ const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(asset.data)
169
+ if (!match) fail('嵌入图片格式无效')
170
+ const bytes = Buffer.from(match[2], 'base64')
171
+ if (bytes.length > MAX_IMAGE || bytes.toString('base64') !== match[2]) fail('嵌入图片编码无效或过大')
172
+ const info = imageInfo(bytes, '嵌入图片')
173
+ if (asset.width !== info.width || asset.height !== info.height || asset.mime !== info.mime || id !== 'img-' + hash(bytes)) fail('嵌入图片尺寸或内容摘要不匹配')
174
+ return undefined
175
+ } catch (error) { return error.message }
176
+ }
177
+
178
+ // This is a conservative fit estimate. Fonts and PowerPoint/WPS rendering still need visual inspection.
179
+ function textWidth(text) { return [...String(text ?? '')].reduce((n, c) => n + (/[^\x00-\xff]/.test(c) ? 1 : 0.55), 0) }
180
+ function contrast(a, b) {
181
+ const lum = color => {
182
+ const rgb = color.slice(1).match(/../g).map(v => parseInt(v, 16) / 255).map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4)
183
+ return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722
184
+ }
185
+ const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05)
186
+ }
187
+ export function checkManifest(manifest) {
188
+ const issues = [], theme = brandedTheme(manifest)
189
+ const invalidAssets = new Map()
190
+ const assetError = id => {
191
+ if (!invalidAssets.has(id)) invalidAssets.set(id, embeddedAssetError(manifest.assets?.[id], id))
192
+ return invalidAssets.get(id)
193
+ }
194
+ const issue = (index, code, message, suggestion, severity = 'warning') => issues.push({ slide: index + 1, slideId: manifest.slides[index]?.id, severity, code, message, suggestion })
195
+ manifest.slides.forEach((slide, i) => {
196
+ if (!slide.title?.trim()) issue(i, 'missing-title', '页面没有标题', '补充能说明本页内容的标题')
197
+ const titleLimit = ['cover', 'closing', 'statement', 'quote', 'section'].includes(slide.layout) ? 40 : 24
198
+ if (textWidth(slide.title) > titleLimit) issue(i, 'title-overflow', '标题可能换行过多或超过标题区域', '缩短标题,把解释移入正文')
199
+ const mixed = ['image-left', 'image-right'].includes(slide.layout), bullets = slide.bullets ?? []
200
+ if (bullets.length > (mixed ? 5 : 8)) issue(i, 'dense-bullets', `本页有 ${bullets.length} 条要点,放映时过密`, '减少要点或拆成多页')
201
+ const width = mixed ? 19 : 37
202
+ const lines = bullets.reduce((sum, b) => sum + Math.max(1, Math.ceil(textWidth(b) / width)), 0)
203
+ + (slide.subtitle ? Math.ceil(textWidth(slide.subtitle) / width) : 0)
204
+ if (lines > (mixed ? 9 : 11)) issue(i, 'body-overflow', '正文估计超过可用高度', '先精简文字,再分拆页面,避免继续缩小字体')
205
+ if ([slide.title, slide.subtitle, ...bullets].some(s => /待填写|To fill:/i.test(String(s)))) issue(i, 'template-unfilled', '模板中还有待填写内容', '提供实际事实、依据和行动,填完后重新检查')
206
+ if (slide.layout === 'table') {
207
+ if ((slide.rows?.[0]?.length ?? 0) > 6) issue(i, 'small-font', '表格超过 6 列,使用 14pt,小屏放映可能难以阅读', '减少列数或把重点数据改成图表,导出后检查可读性')
208
+ if (slide.rows?.some(row => row.some(c => textWidth(c) > 18))) issue(i, 'table-density', '表格单元格文字较长,可能换行拥挤', '精简单元格或减少列数')
209
+ }
210
+ if (slide.image) {
211
+ const asset = manifest.assets?.[slide.image.assetId], box = mediaBoxes(slide.layout).image
212
+ if (!asset) issue(i, 'missing-image', '找不到本页的嵌入图片', '重新指定图片路径后编辑本页', 'error')
213
+ else {
214
+ if (assetError(slide.image.assetId)) issue(i, 'invalid-image', assetError(slide.image.assetId), '重新提供本地图片后编辑该页', 'error')
215
+ if (asset.width < box.w * 100 || asset.height < box.h * 100) issue(i, 'low-resolution', `图片为 ${asset.width}×${asset.height},放大后可能模糊`, '换成更高分辨率图片;Logo 等小图可忽略此提示')
216
+ }
217
+ if (!slide.image.alt?.trim()) issue(i, 'missing-alt', '图片没有说明文字', '填写 image.alt,方便读屏及理解图片')
218
+ }
219
+ if (slide.chart) {
220
+ try { normalizeChart(slide.chart, i + 1) } catch (error) { issue(i, 'invalid-chart', error.message, '修正图表类别和数值', 'error') }
221
+ if (slide.chart.categories?.length > 10 || slide.chart.categories?.some(c => textWidth(c) > 10)) issue(i, 'chart-density', '图表类别较多或标签较长,可能相互遮挡', '减少类别,或使用横向条形图')
222
+ if (slide.chart.series?.some(s => textWidth(s.name) > 14)) issue(i, 'chart-legend', '图例名称较长', '精简系列名称并检查渲染')
223
+ }
224
+ if (contrast(theme.palette.bg, theme.palette.fg) < 4.5) issue(i, 'low-contrast', '正文与背景对比度低于 4.5:1', '调整品牌背景色或文字色')
225
+ })
226
+ if (manifest.brand?.logoAssetId && !manifest.assets?.[manifest.brand.logoAssetId]) issue(0, 'missing-logo', '品牌 Logo 资产不存在', '重新提供 Logo', 'error')
227
+ else if (manifest.brand?.logoAssetId && assetError(manifest.brand.logoAssetId)) issue(0, 'invalid-logo', assetError(manifest.brand.logoAssetId), '重新提供 Logo', 'error')
228
+ return { ok: !issues.some(i => i.severity === 'error'), slideCount: manifest.slides.length,
229
+ errorCount: issues.filter(i => i.severity === 'error').length, warningCount: issues.filter(i => i.severity === 'warning').length,
230
+ issues, note: '文字尺寸与溢出为静态估算;最终请检查 HTML 放映和 PowerPoint / WPS 实际显示。页脚和页码的小字号不计入正文检查。' }
231
+ }
232
+
233
+ async function exportPptx(manifest, theme, language, signal) {
234
+ const base = buildPptx(manifest, theme, language)
235
+ if (!manifest.brand?.logoAssetId && !manifest.brand?.footer && !manifest.slides.some(s => s.image || s.chart)) return base
236
+ abort(signal)
237
+ let lib
238
+ try { lib = await import('@office-kit/pptx') } catch (error) { fail('图片、图表和品牌导出需要 @office-kit/pptx@0.21.0。插件请重新安装;独立技能请在技能目录运行 npm install --ignore-scripts。' + error.message) }
239
+ abort(signal)
240
+ const presentation = await lib.loadPresentation(base), slides = lib.getSlides(presentation)
241
+ const emu = box => Object.fromEntries(Object.entries(box).map(([k, v]) => [k, Math.round(v * 914400)]))
242
+ const putImage = (slide, id, box, fit, name, alt) => {
243
+ const asset = manifest.assets[id], bytes = Buffer.from(asset.data.split(',')[1], 'base64')
244
+ const shape = lib.addSlideImage(slide, bytes, { ...emu(box), name, fit: fit === 'contain' ? 'contain' : 'fill' })
245
+ lib.setShapeDescription(shape, alt || name)
246
+ if (fit === 'cover') {
247
+ const natural = asset.width / asset.height, target = box.w / box.h
248
+ const fraction = natural > target ? (1 - target / natural) / 2 : (1 - natural / target) / 2
249
+ lib.setShapeImageCrop(shape, natural > target ? { left: fraction, right: fraction } : { top: fraction, bottom: fraction })
250
+ }
251
+ }
252
+ manifest.slides.forEach((source, index) => {
253
+ abort(signal)
254
+ const slide = slides[index], boxes = mediaBoxes(source.layout)
255
+ if (source.image) putImage(slide, source.image.assetId, boxes.image, source.image.fit, 'Slide image', source.image.alt)
256
+ if (source.chart) {
257
+ const colors = chartColors(theme), font = pptxEastAsianFont(theme.fonts.body)
258
+ const style = { font, sizePt: 17, color: theme.palette.fg }
259
+ lib.addSlideChart(slide, { ...emu(boxes.chart), name: 'Editable data chart', spec: {
260
+ kind: source.chart.kind, categories: source.chart.categories,
261
+ series: source.chart.series.map((s, i) => ({ ...s, color: colors[i], ...(source.chart.kind === 'pie' ? { pointColors: source.chart.categories.map((_, j) => colors[j % colors.length]) } : {}) })),
262
+ chartAreaFill: theme.palette.bg, chartAreaStrokeColor: theme.palette.bg, plotAreaFill: theme.palette.bg,
263
+ categoryAxisLabelStyle: style, valueAxisLabelStyle: style,
264
+ valueAxisMinorTickMark: 'none', categoryAxisMinorTickMark: 'none', valueAxisMajorTickMark: 'none', categoryAxisMajorTickMark: 'none',
265
+ valueAxisMajorGridlines: true, valueAxisMajorGridlineColor: theme.palette.panel,
266
+ ...(source.chart.kind === 'bar' ? { categoryAxisOrientation: 'maxMin', valueAxisCrosses: 'max' } : {}),
267
+ ...(source.chart.kind === 'pie' ? { dataLabels: { showPercent: true, showValue: false, showCategory: false, showSeriesName: false, numberFormat: '0.0%', position: 'bestFit', textStyle: style } } : {}),
268
+ ...(source.chart.unit ? { valueAxisTitle: source.chart.unit, valueAxisTitleStyle: style } : {}),
269
+ legend: { position: 'b', textStyle: style },
270
+ } })
271
+ }
272
+ if (manifest.brand?.logoAssetId) putImage(slide, manifest.brand.logoAssetId, LOGO_BOX, 'contain', 'Brand logo', manifest.brand.name)
273
+ if (manifest.brand?.footer) {
274
+ const footer = lib.addSlideTextBox(slide, { ...emu(FOOTER_BOX), text: manifest.brand.footer, name: 'Brand footer' })
275
+ lib.setShapeTextFormat(footer, { size: 10, font: pptxEastAsianFont(theme.fonts.body), color: theme.palette.muted })
276
+ }
277
+ })
278
+ const invalid = lib.validatePresentation(presentation).filter(i => i.severity === 'error')
279
+ if (invalid.length) fail('PPTX 结构校验失败:' + invalid.map(i => i.message).join(';'))
280
+ const bytes = await lib.savePresentation(presentation)
281
+ abort(signal)
282
+ return Buffer.from(bytes)
283
+ }
284
+
285
+ function documentBytes(manifest) {
286
+ const text = JSON.stringify(manifest, null, 2) + '\n'
287
+ if (Buffer.byteLength(text) > MAX_DOCUMENT) fail('JSON 工程含历史超过 64 MiB,请缩减图片或清理历史后另存工程')
288
+ return text
289
+ }
290
+ function result(manifest, paths) {
291
+ return { ok: true, title: manifest.title, theme: manifest.theme, language: manifest.language,
292
+ slideCount: manifest.slides.length, deckId: manifest.deckId, revision: manifest.revision,
293
+ slides: manifest.slides.map((s, i) => ({ slide: i + 1, id: s.id, title: s.title, layout: s.layout })),
294
+ undoAvailable: manifest.history.length, deliveryStatus: manifest.quality?.issues.some(i => i.code === 'template-unfilled') ? 'draft' : 'ready-for-review',
295
+ quality: manifest.quality, verification: { static: 'checked', pptxRender: 'not-verified', visual: 'not-verified' }, outputDir: dirname(paths.json),
296
+ files: paths, htmlPath: paths.html, pptxPath: paths.pptx, jsonPath: paths.json }
297
+ }
298
+ function pathsFor(jsonPath) {
299
+ const base = jsonPath.slice(0, -5)
300
+ return { html: base + '.html', pptx: base + '.pptx', json: jsonPath }
301
+ }
302
+ function withLock(path, commit) {
303
+ const lock = path + '.dsh-ppt-lock'
304
+ let fd
305
+ try { fd = openSync(lock, 'wx') } catch { fail('该工程正在被另一次修改,请稍后重试:' + path) }
306
+ try { return commit() } finally { closeSync(fd); rmSync(lock, { force: true }) }
307
+ }
308
+ async function generate(manifest, signal) {
309
+ abort(signal)
310
+ collectAssets(manifest)
311
+ manifest.slideCount = manifest.slides.length
312
+ manifest.quality = checkManifest(manifest)
313
+ if (manifest.quality.errorCount) fail(manifest.quality.issues.filter(i => i.severity === 'error').map(i => `第 ${i.slide} 页:${i.message}`).join(';'))
314
+ const theme = brandedTheme(manifest), language = resolveLanguage(manifest.language)
315
315
  const json = documentBytes(manifest)
316
- const pptx = await exportPptx(manifest, theme, language, signal)
317
- abort(signal)
316
+ const pptx = await exportPptx(manifest, theme, language, signal)
317
+ abort(signal)
318
318
  return { json, pptx, manifest, theme, language }
319
319
  }
320
320
  function commit(paths, data) {
321
321
  const html = renderHtml(data.manifest, data.theme, data.language, paths)
322
322
  commitDeckArtifacts([{ path: paths.json, data: data.json, encoding: 'utf8' }, { path: paths.html, data: html, encoding: 'utf8' }, { path: paths.pptx, data: data.pptx }])
323
- }
324
-
325
- export async function buildDeckAsync(options = {}) {
326
- abort(options.signal)
327
- validateRawSlides(options.slides)
328
- const scenario = options.template ? scenarios.find(s => s.id === options.template) : undefined
329
- if (options.template && !scenario) fail('未知 template,请使用 weekly / defense / project / pitch')
330
- const input = { ...options, theme: options.theme || scenario?.theme,
331
- ...(!options.content?.trim() && !options.slides?.length && scenario ? { slides: templateSlides(scenario, String(options.title ?? ''), options.lang) } : {}) }
332
- const normalized = normalizeBuildOptions(input), cwd = resolve(options.cwd || '.')
333
- const assets = {}
334
- const brand = normalizeBrand(options.brand, assets, cwd)
335
- const manifest = { version: DECK_VERSION, schemaVersion: 1, deckId: randomUUID(), revision: 0,
336
- title: normalized.title, theme: normalized.theme.id, language: normalized.language.id, motion: normalized.motion,
337
- ...(scenario ? { template: scenario.id } : {}), ...(brand ? { brand } : {}), assets, history: [],
338
- slides: embedSlides(normalized.deck.slides, assets, cwd).map(s => ({ ...s, id: randomUUID() })) }
339
- const data = await generate(manifest, options.signal)
340
- mkdirSync(normalized.outputDir, { recursive: true })
341
- // Resolve collisions after asynchronous rendering; an earlier call may have committed meanwhile.
342
- const file = resolveDeckFileName(normalized.outputDir, normalized.fileName, normalized.overwrite)
343
- const paths = pathsFor(resolve(normalized.outputDir, file + '.json'))
344
- withLock(paths.json, () => { abort(options.signal); commit(paths, data) })
345
- return result(manifest, paths)
346
- }
347
-
348
- function readDocument(pathInput, cwd = '.') {
349
- const path = resolve(cwd, String(pathInput ?? ''))
350
- if (!pathInput || extname(path).toLowerCase() !== '.json') fail('deckPath 需指向本插件生成的 .json 工程')
351
- if (statSync(path).size > MAX_DOCUMENT) fail('工程文件超过 64 MiB')
352
- const bytes = readFileSync(path)
353
- let document
354
- try { document = JSON.parse(bytes) } catch { fail('JSON 工程无法解析:' + path) }
355
- if (!document || typeof document.title !== 'string' || !Array.isArray(document.slides) || !document.slides.length) fail('这不是有效的 dsh-ppt JSON 工程')
356
- if (document.schemaVersion !== undefined && document.schemaVersion !== 1) fail('未知工程 schemaVersion,请升级插件后再修改')
357
- if (document.slides.length > 120) fail('工程页数超过 120')
358
- resolveTheme(document.theme); resolveLanguage(document.language)
359
- document.deckId ||= randomUUID(); document.revision ??= 0
360
- if (!Number.isInteger(document.revision) || document.revision < 0) fail('工程 revision 无效')
361
- document.assets ??= {}; document.history ??= []
362
- if (typeof document.assets !== 'object' || Array.isArray(document.assets)) fail('工程图片资产无效')
363
- if (!Array.isArray(document.history) || document.history.length > MAX_HISTORY || document.history.some(h => !h || !Array.isArray(h.slides))) fail('工程历史无效')
364
- const ids = new Set()
365
- for (const slide of document.slides) {
366
- if (!slide || !layouts.has(slide.layout)) fail('工程含有未知页面布局')
367
- slide.id ||= randomUUID()
368
- if (ids.has(slide.id)) fail('工程含有重复页面 ID')
369
- ids.add(slide.id)
370
- }
371
- return { manifest: document, path, fingerprint: hash(bytes) }
372
- }
373
- function expected(manifest, revision) {
374
- if (revision !== undefined && (!Number.isInteger(revision) || revision !== manifest.revision)) fail(`修订号冲突:当前 revision=${manifest.revision},请重新读取工程后修改`)
375
- }
376
- async function saveRevision(state, next, signal) {
377
- next.version = DECK_VERSION; next.schemaVersion = 1; next.revision = state.manifest.revision + 1
378
- const data = await generate(next, signal), paths = pathsFor(state.path)
379
- withLock(state.path, () => {
380
- abort(signal)
381
- if (hash(readFileSync(state.path)) !== state.fingerprint) fail('工程已被其他操作修改,请重新读取后重试')
382
- commit(paths, data)
383
- })
384
- return result(next, paths)
385
- }
386
-
387
- export async function editDeck(options = {}) {
388
- abort(options.signal)
389
- const state = readDocument(options.deckPath, options.cwd), current = state.manifest
390
- expected(current, options.expectedRevision)
391
- const next = clone(current)
392
- if (!Array.isArray(options.edits) || !options.edits.length) {
393
- if (options.brand === undefined) fail('请提供 edits 或 brand')
394
- }
395
- const used = new Set(), allowed = new Set(['layout', 'title', 'subtitle', 'kicker', 'text', 'bullets', 'rows', 'notes', 'image', 'chart'])
396
- for (const edit of options.edits ?? []) {
397
- const index = typeof edit.slide === 'number' && Number.isInteger(edit.slide) ? edit.slide - 1 : next.slides.findIndex(s => s.id === edit.slide)
398
- if (index < 0 || index >= next.slides.length) fail('找不到要修改的页面:' + edit.slide)
399
- if (used.has(index)) fail('一次修改不能重复指定同一页:' + (index + 1))
400
- used.add(index)
401
- if (!edit.patch || typeof edit.patch !== 'object' || Array.isArray(edit.patch)) fail('第 ' + (index + 1) + ' 页 patch 需为对象')
402
- for (const key of Object.keys(edit.patch)) if (!allowed.has(key)) fail('不支持修改页面字段:' + key)
403
- validateRawSlides([edit.patch])
404
- const changed = { ...next.slides[index], ...edit.patch }
405
- for (const key of Object.keys(changed)) if (changed[key] === null) delete changed[key]
406
- if (edit.patch.chart) { if (!edit.patch.layout) changed.layout = 'chart'; if (changed.layout === 'chart') delete changed.image }
407
- if (edit.patch.image) { if (!edit.patch.layout && !['image', 'image-left', 'image-right'].includes(changed.layout)) changed.layout = 'image-right'; if (['image', 'image-left', 'image-right'].includes(changed.layout)) delete changed.chart }
408
- if (edit.patch.layout && !MEDIA_LAYOUTS.has(edit.patch.layout)) { delete changed.image; delete changed.chart }
409
- validateRawSlides([changed])
410
- let slides
411
- try { slides = normalizeSlides([changed], 120, next.language) } catch (error) { fail(`第 ${index + 1} 页修改失败:${error.message}`) }
412
- if (slides.length !== 1) fail(`第 ${index + 1} 页修改会产生分页,请减少表格行数或另建演示文稿`)
413
- const embedded = embedSlides(slides, next.assets, resolve(options.cwd || '.'))[0]
414
- next.slides[index] = { ...embedded, id: current.slides[index].id }
415
- }
416
- if (options.brand !== undefined) next.brand = normalizeBrand(options.brand === null ? null : { ...next.brand, ...options.brand,
417
- ...(options.brand?.logo === null ? { logo: undefined, logoAssetId: undefined } : {}) }, next.assets, resolve(options.cwd || '.'))
418
- if (JSON.stringify(snapshot(next)) === JSON.stringify(snapshot(current))) return { ...result(current, pathsFor(state.path)), changed: false }
419
- next.history = [...next.history, snapshot(current)].slice(-MAX_HISTORY)
420
- return { ...await saveRevision(state, next, options.signal), changed: true }
421
- }
422
-
423
- export async function undoDeck(options = {}) {
424
- abort(options.signal)
425
- const state = readDocument(options.deckPath, options.cwd)
426
- expected(state.manifest, options.expectedRevision)
427
- if (!state.manifest.history.length) fail('该工程没有可撤销的修改')
428
- const next = clone(state.manifest), prior = next.history.pop()
429
- for (const key of ['brand', 'template']) delete next[key]
430
- Object.assign(next, prior)
431
- return saveRevision(state, next, options.signal)
432
- }
433
-
434
- export function checkDeck(options = {}) {
435
- abort(options.signal)
436
- const { manifest, path } = readDocument(options.deckPath, options.cwd)
437
- return { ...checkManifest(manifest), deckPath: path, deckId: manifest.deckId, revision: manifest.revision,
438
- verification: { static: 'checked', pptxRender: 'not-verified', visual: 'not-verified' },
439
- undoAvailable: manifest.history.length, slides: manifest.slides.map((s, i) => ({ slide: i + 1, id: s.id, title: s.title, layout: s.layout })) }
440
- }
441
-
442
- export async function checkDeckAsync(options = {}) {
443
- const report = checkDeck(options)
444
- if (options.renderReceipt === undefined) return report
445
- if (typeof options.renderReceipt !== 'string' || !options.renderReceipt.trim()) fail('renderReceipt 需为收据文件路径')
446
- const { readRenderReceipt } = await import('./deck-render.mjs')
447
- abort(options.signal)
448
- report.render = await readRenderReceipt(pathsFor(report.deckPath).pptx, resolve(options.cwd || '.', options.renderReceipt))
449
- if (report.render.status === 'rendered') report.verification.pptxRender = 'rendered'
450
- return report
451
- }
452
-
453
- export async function renderDeck(options = {}) {
454
- abort(options.signal)
455
- if (typeof options.pptxPath !== 'string' || !options.pptxPath.trim()) fail('pptxPath 需为 PPTX 文件路径')
456
- if (options.outputDir !== undefined && (typeof options.outputDir !== 'string' || !options.outputDir.trim())) fail('outputDir 需为目录路径')
457
- const renderer = await import('./deck-render.mjs')
458
- abort(options.signal)
459
- const cwd = options.cwd || '.'
460
- return renderer.renderDeck({ ...options, pptxPath: resolve(cwd, options.pptxPath),
461
- ...(options.outputDir !== undefined ? { outputDir: resolve(cwd, options.outputDir) } : {}) })
462
- }
323
+ }
324
+
325
+ export async function buildDeckAsync(options = {}) {
326
+ abort(options.signal)
327
+ validateRawSlides(options.slides)
328
+ const scenario = options.template ? scenarios.find(s => s.id === options.template) : undefined
329
+ if (options.template && !scenario) fail('未知 template,请使用 weekly / defense / project / pitch')
330
+ const input = { ...options, theme: options.theme || scenario?.theme,
331
+ ...(!options.content?.trim() && !options.slides?.length && scenario ? { slides: templateSlides(scenario, String(options.title ?? ''), options.lang) } : {}) }
332
+ const normalized = normalizeBuildOptions(input), cwd = resolve(options.cwd || '.')
333
+ const assets = {}
334
+ const brand = normalizeBrand(options.brand, assets, cwd)
335
+ const manifest = { version: DECK_VERSION, schemaVersion: 1, deckId: randomUUID(), revision: 0,
336
+ title: normalized.title, theme: normalized.theme.id, language: normalized.language.id, motion: normalized.motion,
337
+ ...(scenario ? { template: scenario.id } : {}), ...(brand ? { brand } : {}), assets, history: [],
338
+ slides: embedSlides(normalized.deck.slides, assets, cwd).map(s => ({ ...s, id: randomUUID() })) }
339
+ const data = await generate(manifest, options.signal)
340
+ mkdirSync(normalized.outputDir, { recursive: true })
341
+ // Resolve collisions after asynchronous rendering; an earlier call may have committed meanwhile.
342
+ const file = resolveDeckFileName(normalized.outputDir, normalized.fileName, normalized.overwrite)
343
+ const paths = pathsFor(resolve(normalized.outputDir, file + '.json'))
344
+ withLock(paths.json, () => { abort(options.signal); commit(paths, data) })
345
+ return result(manifest, paths)
346
+ }
347
+
348
+ function readDocument(pathInput, cwd = '.') {
349
+ const path = resolve(cwd, String(pathInput ?? ''))
350
+ if (!pathInput || extname(path).toLowerCase() !== '.json') fail('deckPath 需指向本插件生成的 .json 工程')
351
+ if (statSync(path).size > MAX_DOCUMENT) fail('工程文件超过 64 MiB')
352
+ const bytes = readFileSync(path)
353
+ let document
354
+ try { document = JSON.parse(bytes) } catch { fail('JSON 工程无法解析:' + path) }
355
+ if (!document || typeof document.title !== 'string' || !Array.isArray(document.slides) || !document.slides.length) fail('这不是有效的 dsh-ppt JSON 工程')
356
+ if (document.schemaVersion !== undefined && document.schemaVersion !== 1) fail('未知工程 schemaVersion,请升级插件后再修改')
357
+ if (document.slides.length > 120) fail('工程页数超过 120')
358
+ resolveTheme(document.theme); resolveLanguage(document.language)
359
+ document.deckId ||= randomUUID(); document.revision ??= 0
360
+ if (!Number.isInteger(document.revision) || document.revision < 0) fail('工程 revision 无效')
361
+ document.assets ??= {}; document.history ??= []
362
+ if (typeof document.assets !== 'object' || Array.isArray(document.assets)) fail('工程图片资产无效')
363
+ if (!Array.isArray(document.history) || document.history.length > MAX_HISTORY || document.history.some(h => !h || !Array.isArray(h.slides))) fail('工程历史无效')
364
+ const ids = new Set()
365
+ for (const slide of document.slides) {
366
+ if (!slide || !layouts.has(slide.layout)) fail('工程含有未知页面布局')
367
+ slide.id ||= randomUUID()
368
+ if (ids.has(slide.id)) fail('工程含有重复页面 ID')
369
+ ids.add(slide.id)
370
+ }
371
+ return { manifest: document, path, fingerprint: hash(bytes) }
372
+ }
373
+ /** Validated project snapshot for the operator workbench; no file is modified. */
374
+ export function readDeckProject(options = {}) {
375
+ abort(options.signal)
376
+ const { manifest, path } = readDocument(options.deckPath, options.cwd)
377
+ return { manifest: clone(manifest), path }
378
+ }
379
+ function expected(manifest, revision) {
380
+ if (revision !== undefined && (!Number.isInteger(revision) || revision !== manifest.revision)) fail(`修订号冲突:当前 revision=${manifest.revision},请重新读取工程后修改`)
381
+ }
382
+ async function saveRevision(state, next, signal) {
383
+ next.version = DECK_VERSION; next.schemaVersion = 1; next.revision = state.manifest.revision + 1
384
+ const data = await generate(next, signal), paths = pathsFor(state.path)
385
+ withLock(state.path, () => {
386
+ abort(signal)
387
+ if (hash(readFileSync(state.path)) !== state.fingerprint) fail('工程已被其他操作修改,请重新读取后重试')
388
+ commit(paths, data)
389
+ })
390
+ return result(next, paths)
391
+ }
392
+
393
+ export async function editDeck(options = {}) {
394
+ abort(options.signal)
395
+ const state = readDocument(options.deckPath, options.cwd), current = state.manifest
396
+ expected(current, options.expectedRevision)
397
+ const next = clone(current)
398
+ if (!Array.isArray(options.edits) || !options.edits.length) {
399
+ if (options.brand === undefined && options.order === undefined) fail('请提供 edits、order 或 brand')
400
+ }
401
+ const used = new Set(), allowed = new Set(['layout', 'title', 'subtitle', 'kicker', 'text', 'bullets', 'rows', 'notes', 'image', 'chart'])
402
+ for (const edit of options.edits ?? []) {
403
+ const index = typeof edit.slide === 'number' && Number.isInteger(edit.slide) ? edit.slide - 1 : next.slides.findIndex(s => s.id === edit.slide)
404
+ if (index < 0 || index >= next.slides.length) fail('找不到要修改的页面:' + edit.slide)
405
+ if (used.has(index)) fail('一次修改不能重复指定同一页:' + (index + 1))
406
+ used.add(index)
407
+ if (!edit.patch || typeof edit.patch !== 'object' || Array.isArray(edit.patch)) fail('第 ' + (index + 1) + ' 页 patch 需为对象')
408
+ for (const key of Object.keys(edit.patch)) if (!allowed.has(key)) fail('不支持修改页面字段:' + key)
409
+ validateRawSlides([edit.patch])
410
+ const changed = { ...next.slides[index], ...edit.patch }
411
+ for (const key of Object.keys(changed)) if (changed[key] === null) delete changed[key]
412
+ if (edit.patch.chart) { if (!edit.patch.layout) changed.layout = 'chart'; if (changed.layout === 'chart') delete changed.image }
413
+ if (edit.patch.image) { if (!edit.patch.layout && !['image', 'image-left', 'image-right'].includes(changed.layout)) changed.layout = 'image-right'; if (['image', 'image-left', 'image-right'].includes(changed.layout)) delete changed.chart }
414
+ if (edit.patch.layout && !MEDIA_LAYOUTS.has(edit.patch.layout)) { delete changed.image; delete changed.chart }
415
+ validateRawSlides([changed])
416
+ let slides
417
+ try { slides = normalizeSlides([changed], 120, next.language) } catch (error) { fail(`第 ${index + 1} 页修改失败:${error.message}`) }
418
+ if (slides.length !== 1) fail(`第 ${index + 1} 页修改会产生分页,请减少表格行数或另建演示文稿`)
419
+ const embedded = embedSlides(slides, next.assets, resolve(options.cwd || '.'))[0]
420
+ next.slides[index] = { ...embedded, id: current.slides[index].id }
421
+ }
422
+ if (options.order !== undefined) {
423
+ if (!Array.isArray(options.order) || options.order.length !== next.slides.length || options.order.some(id => typeof id !== 'string') || new Set(options.order).size !== next.slides.length) fail('order 必须包含每个页面 ID,且各出现一次')
424
+ const byId = new Map(next.slides.map(slide => [slide.id, slide]))
425
+ if (options.order.some(id => !byId.has(id))) fail('order 含有不属于本工程的页面 ID')
426
+ next.slides = options.order.map(id => byId.get(id))
427
+ }
428
+ if (options.brand !== undefined) next.brand = normalizeBrand(options.brand === null ? null : { ...next.brand, ...options.brand,
429
+ ...(options.brand?.logo === null ? { logo: undefined, logoAssetId: undefined } : {}) }, next.assets, resolve(options.cwd || '.'))
430
+ if (JSON.stringify(snapshot(next)) === JSON.stringify(snapshot(current))) return { ...result(current, pathsFor(state.path)), changed: false }
431
+ next.history = [...next.history, snapshot(current)].slice(-MAX_HISTORY)
432
+ return { ...await saveRevision(state, next, options.signal), changed: true }
433
+ }
434
+
435
+ export async function undoDeck(options = {}) {
436
+ abort(options.signal)
437
+ const state = readDocument(options.deckPath, options.cwd)
438
+ expected(state.manifest, options.expectedRevision)
439
+ if (!state.manifest.history.length) fail('该工程没有可撤销的修改')
440
+ const next = clone(state.manifest), prior = next.history.pop()
441
+ for (const key of ['brand', 'template']) delete next[key]
442
+ Object.assign(next, prior)
443
+ return saveRevision(state, next, options.signal)
444
+ }
445
+
446
+ export function checkDeck(options = {}) {
447
+ abort(options.signal)
448
+ const { manifest, path } = readDocument(options.deckPath, options.cwd)
449
+ return { ...checkManifest(manifest), deckPath: path, deckId: manifest.deckId, revision: manifest.revision,
450
+ verification: { static: 'checked', pptxRender: 'not-verified', visual: 'not-verified' },
451
+ undoAvailable: manifest.history.length, slides: manifest.slides.map((s, i) => ({ slide: i + 1, id: s.id, title: s.title, layout: s.layout })) }
452
+ }
453
+
454
+ export async function checkDeckAsync(options = {}) {
455
+ const report = checkDeck(options)
456
+ if (options.renderReceipt === undefined) return report
457
+ if (typeof options.renderReceipt !== 'string' || !options.renderReceipt.trim()) fail('renderReceipt 需为收据文件路径')
458
+ const { readRenderReceipt } = await import('./deck-render.mjs')
459
+ abort(options.signal)
460
+ report.render = await readRenderReceipt(pathsFor(report.deckPath).pptx, resolve(options.cwd || '.', options.renderReceipt))
461
+ if (report.render.status === 'rendered') report.verification.pptxRender = 'rendered'
462
+ return report
463
+ }
464
+
465
+ export async function renderDeck(options = {}) {
466
+ abort(options.signal)
467
+ if (typeof options.pptxPath !== 'string' || !options.pptxPath.trim()) fail('pptxPath 需为 PPTX 文件路径')
468
+ if (options.outputDir !== undefined && (typeof options.outputDir !== 'string' || !options.outputDir.trim())) fail('outputDir 需为目录路径')
469
+ const renderer = await import('./deck-render.mjs')
470
+ abort(options.signal)
471
+ const cwd = options.cwd || '.'
472
+ return renderer.renderDeck({ ...options, pptxPath: resolve(cwd, options.pptxPath),
473
+ ...(options.outputDir !== undefined ? { outputDir: resolve(cwd, options.outputDir) } : {}) })
474
+ }