dsh-ppt 0.4.2 → 0.4.4

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.
@@ -1,1646 +1,1771 @@
1
- #! /usr/bin/env node
2
- /**
3
- * deck-core.mjs —— dsh-ppt 的零依赖演示文稿引擎。
4
- *
5
- * 这是插件工具与裸 SKILL.md 共用的唯一事实源:
6
- * - DSH 内:ppt_create 工具动态加载本文件
7
- * - 其他 harness:直接运行同目录的 build-deck.mjs
8
- *
9
- * 能力:Markdown / 结构化 slides → 三件套
10
- * deck.html 独立网页放映(键盘/触屏/打印,无外链)
11
- * deck.pptx 可编辑 PPTX(16:9,OOXML 由本文件手写,zip 用 node:zlib)
12
- * deck.json 结构化 deck manifest
13
- *
14
- * 零运行时依赖,仅使用 node:fs / node:path / node:zlib。
15
- */
16
-
17
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
18
- import { resolve as resolvePath } from 'node:path'
19
- import { deflateRawSync } from 'node:zlib'
20
-
21
- export const DECK_VERSION = '0.3.0'
22
-
23
- // ---------------------------------------------------------------------------
24
- // 主题
25
- // ---------------------------------------------------------------------------
26
-
27
- export const THEMES = {
28
- swiss: {
29
- id: 'swiss',
30
- name: { zh: '瑞士脉冲', en: 'Swiss Pulse' },
31
- mood: { zh: '精准、理性、数据', en: 'Precise, rational, data-driven' },
32
- bestFor: { zh: 'SaaS、数据、开发者工具', en: 'SaaS, data, developer tools' },
33
- dark: true,
34
- palette: {
35
- bg: '#10151B',
36
- panel: '#161D26',
37
- fg: '#F5F7FA',
38
- muted: '#8E9AAA',
39
- accent: '#2F6BFF',
40
- accent2: '#FFB300',
41
- },
42
- fonts: {
43
- heading: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
44
- body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
45
- },
46
- },
47
- velvet: {
48
- id: 'velvet',
49
- name: { zh: '天鹅绒标准', en: 'Velvet Standard' },
50
- mood: { zh: '高级、克制、可信', en: 'Premium, restrained, trustworthy' },
51
- bestFor: { zh: '高管汇报、品牌、融资路演', en: 'Executive decks, brand, investor pitches' },
52
- dark: true,
53
- palette: {
54
- bg: '#111316',
55
- panel: '#1A1D22',
56
- fg: '#F4EFE6',
57
- muted: '#A79F91',
58
- accent: '#C9A84C',
59
- accent2: '#3D4A63',
60
- },
61
- fonts: {
62
- heading: 'Georgia, "Times New Roman", "Songti SC", "SimSun", serif',
63
- body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
64
- },
65
- },
66
- data: {
67
- id: 'data',
68
- name: { zh: '数据漂移', en: 'Data Drift' },
69
- mood: { zh: '未来、沉浸、前沿', en: 'Futuristic, immersive, cutting-edge' },
70
- bestFor: { zh: 'AI、数据、研究、技术发布', en: 'AI, data, research, tech launches' },
71
- dark: true,
72
- palette: {
73
- bg: '#070B14',
74
- panel: '#0D1424',
75
- fg: '#E8F1FF',
76
- muted: '#7E8BA8',
77
- accent: '#7C3AED',
78
- accent2: '#06B6D4',
79
- },
80
- fonts: {
81
- heading: '"Space Grotesk", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
82
- body: '"Space Grotesk", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
83
- },
84
- },
85
- soft: {
86
- id: 'soft',
87
- name: { zh: '柔和信号', en: 'Soft Signal' },
88
- mood: { zh: '温暖、亲近、人本', en: 'Warm, intimate, human' },
89
- bestFor: { zh: '品牌故事、培训、个人分享', en: 'Brand stories, training, personal talks' },
90
- dark: false,
91
- palette: {
92
- bg: '#FFF8EC',
93
- panel: '#FFF2DE',
94
- fg: '#3B2F2A',
95
- muted: '#7E6F68',
96
- accent: '#E58A2F',
97
- accent2: '#8FAF8C',
98
- },
99
- fonts: {
100
- heading: '"Avenir Next", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
101
- body: '"Avenir Next", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
102
- },
103
- },
104
- bold: {
105
- id: 'bold',
106
- name: { zh: '极繁大字', en: 'Maximalist Type' },
107
- mood: { zh: '大声、动能、发布', en: 'Loud, kinetic, launch' },
108
- bestFor: { zh: '产品发布、活动、品牌大事件', en: 'Product launches, events, brand moments' },
109
- dark: true,
110
- palette: {
111
- bg: '#0D0D0D',
112
- panel: '#181818',
113
- fg: '#FFFFFF',
114
- muted: '#B8B8B8',
115
- accent: '#E63946',
116
- accent2: '#FFD60A',
117
- },
118
- fonts: {
119
- heading: 'Impact, "Arial Black", "PingFang SC", "Microsoft YaHei", sans-serif',
120
- body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
121
- },
122
- },
123
- }
124
-
125
- export const THEME_IDS = Object.keys(THEMES)
126
- export const DEFAULT_THEME = 'data'
127
-
128
- export const LANGUAGES = {
129
- zh: {
130
- id: 'zh',
131
- attr: 'zh-CN',
132
- ui: {
133
- slide: '',
134
- of: '/ ',
135
- theme: '主题',
136
- help: '← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
137
- coverKicker: '开场',
138
- pointKicker: '要点',
139
- statementKicker: '核心观点',
140
- quoteKicker: '金句',
1
+ #! /usr/bin/env node
2
+ /**
3
+ * deck-core.mjs —— dsh-ppt 的零依赖演示文稿引擎。
4
+ *
5
+ * 这是插件工具与裸 SKILL.md 共用的唯一事实源:
6
+ * - DSH 内:ppt_create 工具动态加载本文件
7
+ * - 其他 harness:直接运行同目录的 build-deck.mjs
8
+ *
9
+ * 能力:Markdown / 结构化 slides → 三件套
10
+ * deck.html 独立网页放映(键盘/触屏/打印,无外链)
11
+ * deck.pptx 可编辑 PPTX(16:9,OOXML 由本文件手写,zip 用 node:zlib)
12
+ * deck.json 结构化 deck manifest
13
+ *
14
+ * 零运行时依赖,仅使用 node:fs / node:path / node:zlib。
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
18
+ import { resolve as resolvePath } from 'node:path'
19
+ import { deflateRawSync } from 'node:zlib'
20
+
21
+ /**
22
+ * manifest 版本跟随发布包 package.json;把 skills/dsh-ppt 独立复制到其他
23
+ * harness(没有 package.json)时回退为 0.0.0,避免版本号悄悄漂移。
24
+ */
25
+ function readDeckVersion() {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'))
28
+ const version = String(pkg.version ?? '').trim()
29
+ return version !== '' ? version : '0.0.0'
30
+ } catch {
31
+ return '0.0.0'
32
+ }
33
+ }
34
+
35
+ export const DECK_VERSION = readDeckVersion()
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // 主题
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export const THEMES = {
42
+ swiss: {
43
+ id: 'swiss',
44
+ name: { zh: '瑞士脉冲', en: 'Swiss Pulse' },
45
+ mood: { zh: '精准、理性、数据', en: 'Precise, rational, data-driven' },
46
+ bestFor: { zh: 'SaaS、数据、开发者工具', en: 'SaaS, data, developer tools' },
47
+ dark: true,
48
+ palette: {
49
+ bg: '#10151B',
50
+ panel: '#161D26',
51
+ fg: '#F5F7FA',
52
+ muted: '#8E9AAA',
53
+ accent: '#2F6BFF',
54
+ accent2: '#FFB300',
55
+ },
56
+ fonts: {
57
+ heading: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
58
+ body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
59
+ },
60
+ },
61
+ velvet: {
62
+ id: 'velvet',
63
+ name: { zh: '天鹅绒标准', en: 'Velvet Standard' },
64
+ mood: { zh: '高级、克制、可信', en: 'Premium, restrained, trustworthy' },
65
+ bestFor: { zh: '高管汇报、品牌、融资路演', en: 'Executive decks, brand, investor pitches' },
66
+ dark: true,
67
+ palette: {
68
+ bg: '#111316',
69
+ panel: '#1A1D22',
70
+ fg: '#F4EFE6',
71
+ muted: '#A79F91',
72
+ accent: '#C9A84C',
73
+ accent2: '#3D4A63',
74
+ },
75
+ fonts: {
76
+ heading: 'Georgia, "Times New Roman", "Songti SC", "SimSun", serif',
77
+ body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
78
+ },
79
+ },
80
+ data: {
81
+ id: 'data',
82
+ name: { zh: '数据漂移', en: 'Data Drift' },
83
+ mood: { zh: '未来、沉浸、前沿', en: 'Futuristic, immersive, cutting-edge' },
84
+ bestFor: { zh: 'AI、数据、研究、技术发布', en: 'AI, data, research, tech launches' },
85
+ dark: true,
86
+ palette: {
87
+ bg: '#070B14',
88
+ panel: '#0D1424',
89
+ fg: '#E8F1FF',
90
+ muted: '#7E8BA8',
91
+ accent: '#7C3AED',
92
+ accent2: '#06B6D4',
93
+ },
94
+ fonts: {
95
+ heading: '"Space Grotesk", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
96
+ body: '"Space Grotesk", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
97
+ },
98
+ },
99
+ soft: {
100
+ id: 'soft',
101
+ name: { zh: '柔和信号', en: 'Soft Signal' },
102
+ mood: { zh: '温暖、亲近、人本', en: 'Warm, intimate, human' },
103
+ bestFor: { zh: '品牌故事、培训、个人分享', en: 'Brand stories, training, personal talks' },
104
+ dark: false,
105
+ palette: {
106
+ bg: '#FFF8EC',
107
+ panel: '#FFF2DE',
108
+ fg: '#3B2F2A',
109
+ muted: '#7E6F68',
110
+ accent: '#E58A2F',
111
+ accent2: '#8FAF8C',
112
+ },
113
+ fonts: {
114
+ heading: '"Avenir Next", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
115
+ body: '"Avenir Next", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
116
+ },
117
+ },
118
+ bold: {
119
+ id: 'bold',
120
+ name: { zh: '极繁大字', en: 'Maximalist Type' },
121
+ mood: { zh: '大声、动能、发布', en: 'Loud, kinetic, launch' },
122
+ bestFor: { zh: '产品发布、活动、品牌大事件', en: 'Product launches, events, brand moments' },
123
+ dark: true,
124
+ palette: {
125
+ bg: '#0D0D0D',
126
+ panel: '#181818',
127
+ fg: '#FFFFFF',
128
+ muted: '#B8B8B8',
129
+ accent: '#E63946',
130
+ accent2: '#FFD60A',
131
+ },
132
+ fonts: {
133
+ heading: 'Impact, "Arial Black", "PingFang SC", "Microsoft YaHei", sans-serif',
134
+ body: '"Helvetica Neue", Inter, "PingFang SC", "Microsoft YaHei", sans-serif',
135
+ },
136
+ },
137
+ }
138
+
139
+ export const THEME_IDS = Object.keys(THEMES)
140
+ export const DEFAULT_THEME = 'data'
141
+
142
+ export const LANGUAGES = {
143
+ zh: {
144
+ id: 'zh',
145
+ attr: 'zh-CN',
146
+ ui: {
147
+ slide: '第',
148
+ of: '/ 共',
149
+ theme: '主题',
150
+ help: '← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
151
+ coverKicker: '开场',
152
+ pointKicker: '要点',
153
+ statementKicker: '核心观点',
154
+ quoteKicker: '金句',
141
155
  tableKicker: '数据',
142
- notesLabel: '演讲者备注',
143
- notesToggle: '备注',
144
- closingTitle: '谢谢',
145
- closingSubtitle: '讨论与问答',
146
- generatedBy: '由 dsh-ppt 生成',
147
- },
148
- },
149
- en: {
150
- id: 'en',
151
- attr: 'en-US',
152
- ui: {
153
- slide: 'Slide',
154
- of: '/',
155
- theme: 'Theme',
156
- help: '← → navigate · F fullscreen · G overview · S notes · P print',
157
- coverKicker: 'Opening',
158
- pointKicker: 'Key point',
159
- statementKicker: 'Core idea',
160
- quoteKicker: 'Quote',
156
+ continuation: '(续)',
157
+ notesLabel: '演讲者备注',
158
+ notesToggle: '备注',
159
+ closingTitle: '谢谢',
160
+ closingSubtitle: '讨论与问答',
161
+ generatedBy: '由 dsh-ppt 生成',
162
+ },
163
+ },
164
+ en: {
165
+ id: 'en',
166
+ attr: 'en-US',
167
+ ui: {
168
+ slide: 'Slide',
169
+ of: '/',
170
+ theme: 'Theme',
171
+ help: '← → navigate · F fullscreen · G overview · S notes · P print',
172
+ coverKicker: 'Opening',
173
+ pointKicker: 'Key point',
174
+ statementKicker: 'Core idea',
175
+ quoteKicker: 'Quote',
161
176
  tableKicker: 'Data',
162
- notesLabel: 'Speaker notes',
163
- notesToggle: 'Notes',
164
- closingTitle: 'Thank You',
165
- closingSubtitle: 'Discussion & Q&A',
166
- generatedBy: 'Generated with dsh-ppt',
167
- },
168
- },
169
- bilingual: {
170
- id: 'bilingual',
171
- attr: 'zh-CN',
172
- ui: {
173
- slide: '第',
174
- of: '/ 共',
175
- theme: '主题 · Theme',
176
- help: ' → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
177
- coverKicker: '开场 · Opening',
178
- pointKicker: '要点 · Key point',
179
- statementKicker: '核心观点 · Core Idea',
180
- quoteKicker: '金句 · Quote',
177
+ continuation: ' (continued)',
178
+ notesLabel: 'Speaker notes',
179
+ notesToggle: 'Notes',
180
+ closingTitle: 'Thank You',
181
+ closingSubtitle: 'Discussion & Q&A',
182
+ generatedBy: 'Generated with dsh-ppt',
183
+ },
184
+ },
185
+ bilingual: {
186
+ id: 'bilingual',
187
+ attr: 'zh-CN',
188
+ ui: {
189
+ slide: '',
190
+ of: '/ ',
191
+ theme: '主题 · Theme',
192
+ help: ' → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
193
+ coverKicker: '开场 · Opening',
194
+ pointKicker: '要点 · Key point',
195
+ statementKicker: '核心观点 · Core Idea',
196
+ quoteKicker: '金句 · Quote',
181
197
  tableKicker: '数据 · Data',
182
- notesLabel: '演讲者备注 · Speaker notes',
183
- notesToggle: '备注 · Notes',
184
- closingTitle: '谢谢 · Thank You',
185
- closingSubtitle: '讨论与问答 · Q&A',
186
- generatedBy: ' dsh-ppt 生成 · Generated with dsh-ppt',
187
- },
188
- },
189
- }
190
-
191
- export function resolveTheme(input) {
192
- const id = String(input ?? DEFAULT_THEME).trim().toLowerCase()
193
- const theme = THEMES[id]
194
- if (!theme) {
195
- throw new Error('dsh-ppt:未知主题 "' + id + '",可选:' + THEME_IDS.join(' / ') + '(默认 ' + DEFAULT_THEME + ')')
196
- }
197
- return theme
198
- }
199
-
200
- export function resolveLanguage(input) {
201
- const id = String(input ?? 'zh').trim().toLowerCase()
202
- const language = LANGUAGES[id]
203
- if (!language) {
204
- throw new Error('dsh-ppt:未知语言 "' + id + '",可选:zh / en / bilingual')
198
+ continuation: '(续 · continued)',
199
+ notesLabel: '演讲者备注 · Speaker notes',
200
+ notesToggle: '备注 · Notes',
201
+ closingTitle: '谢谢 · Thank You',
202
+ closingSubtitle: '讨论与问答 · Q&A',
203
+ generatedBy: '由 dsh-ppt 生成 · Generated with dsh-ppt',
204
+ },
205
+ },
206
+ }
207
+
208
+ export function resolveTheme(input) {
209
+ const id = String(input ?? DEFAULT_THEME).trim().toLowerCase()
210
+ const theme = THEMES[id]
211
+ if (!theme) {
212
+ throw new Error('dsh-ppt:未知主题 "' + id + '",可选:' + THEME_IDS.join(' / ') + '(默认 ' + DEFAULT_THEME + ')')
213
+ }
214
+ return theme
215
+ }
216
+
217
+ export function resolveLanguage(input) {
218
+ const id = String(input ?? 'zh').trim().toLowerCase()
219
+ const language = LANGUAGES[id]
220
+ if (!language) {
221
+ throw new Error('dsh-ppt:未知语言 "' + id + '",可选:zh / en / bilingual')
222
+ }
223
+ return language
224
+ }
225
+
226
+ export function resolveMotion(input) {
227
+ if (input === undefined || input === null || input === '') return true
228
+ if (input === true || input === 'on') return true
229
+ if (input === false || input === 'off') return false
230
+ throw new Error('dsh-ppt:未知 motion 值 "' + String(input) + '",可选:on / off(默认 on)')
231
+ }
232
+
233
+ export function listThemes(lang = 'zh') {
234
+ const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
235
+ return THEME_IDS.map((id) => {
236
+ const theme = THEMES[id]
237
+ return {
238
+ id,
239
+ name: pick(theme.name),
240
+ mood: pick(theme.mood),
241
+ bestFor: pick(theme.bestFor),
242
+ dark: theme.dark,
243
+ palette: { ...theme.palette },
244
+ fonts: { ...theme.fonts },
245
+ }
246
+ })
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // 文本工具
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export function clampInt(value, fallback, min, max) {
254
+ const n = typeof value === 'number' ? Math.trunc(value) : fallback
255
+ if (!Number.isFinite(n)) return fallback
256
+ return Math.min(max, Math.max(min, n))
257
+ }
258
+
259
+ // Windows 保留设备名:首个点之前的主名命中即不可用(NUL、com1、aux.report.html 都算)。
260
+ const WINDOWS_RESERVED_FILE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i
261
+
262
+ export function sanitizeFileName(input) {
263
+ const base = String(input ?? 'deck').trim().replace(/\.(html?|pptx|json)$/i, '')
264
+ let cleaned = base
265
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
266
+ .replace(/\s+/g, '-')
267
+ .replace(/-+/g, '-')
268
+ .replace(/^-+|-+$/g, '')
269
+ if (WINDOWS_RESERVED_FILE_NAME.test(cleaned)) cleaned = 'deck-' + cleaned
270
+ return cleaned.slice(0, 120) || 'deck'
271
+ }
272
+
273
+ const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
274
+
275
+ /**
276
+ * 为三件套选择同一个可用文件名前缀。默认不覆盖:任一产物已存在时,
277
+ * 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
278
+ */
279
+ function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
280
+ // 一次 readdir 建集,替代逐个候选 existsSync 探测(最坏 ~3000 次 stat);
281
+ // 统一小写比较,避免 Windows 大小写不敏感文件系统把已占用的名字判成可用。
282
+ const existing = new Set(readdirSync(outputDir).map((name) => name.toLowerCase()))
283
+ const isAvailable = (candidate) => DECK_ARTIFACT_EXTENSIONS.every((ext) => !existing.has((candidate + ext).toLowerCase()))
284
+ if (overwrite || isAvailable(requestedFileName)) return requestedFileName
285
+ for (let index = 1; index < 1000; index += 1) {
286
+ const candidate = requestedFileName + '-' + index
287
+ if (isAvailable(candidate)) return candidate
288
+ }
289
+ throw new Error('dsh-ppt:找不到可用的输出文件名(同名前缀超过 999 个),请更换 fileName 或显式允许 overwrite。')
290
+ }
291
+
292
+ export function escapeXml(value) {
293
+ return String(value ?? '')
294
+ .replace(/&/g, '&amp;')
295
+ .replace(/</g, '&lt;')
296
+ .replace(/>/g, '&gt;')
297
+ .replace(/"/g, '&quot;')
298
+ .replace(/'/g, '&apos;')
299
+ }
300
+
301
+ export function escapeHtml(value) {
302
+ return String(value ?? '')
303
+ .replace(/&/g, '&amp;')
304
+ .replace(/</g, '&lt;')
305
+ .replace(/>/g, '&gt;')
306
+ .replace(/"/g, '&quot;')
307
+ }
308
+
309
+ export function stripInlineMarkdown(value) {
310
+ return String(value ?? '')
311
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
312
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
313
+ .replace(/`([^`]*)`/g, '$1')
314
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
315
+ .replace(/__([^_]+)__/g, '$1')
316
+ .replace(/\*([^*\n]+)\*/g, '$1')
317
+ .replace(/_([^_\n]+)_/g, '$1')
318
+ .replace(/<[^>]+>/g, '')
319
+ .replace(/\s+/g, ' ')
320
+ .trim()
321
+ }
322
+
323
+ export function splitSentences(value) {
324
+ const text = String(value ?? '').trim()
325
+ if (text === '') return []
326
+ const parts = text.split(/(?<=[。!?])|(?<=[.!?…])\s+/).map((part) => part.trim()).filter(Boolean)
327
+ return parts.length > 0 ? parts : [text]
328
+ }
329
+
330
+ export function truncate(value, max) {
331
+ const text = String(value ?? '').trim()
332
+ if (text.length <= max) return text
333
+ return text.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'
334
+ }
335
+
336
+ function chunk(items, size) {
337
+ const out = []
338
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
339
+ return out
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Markdown → deck
344
+ // ---------------------------------------------------------------------------
345
+
346
+ function createSection(heading = '', level = 0, coverOnly = false) {
347
+ return { heading, level, coverOnly, bullets: [], paragraphs: [], quote: [], table: null, notes: [] }
348
+ }
349
+
350
+ function splitTableRow(line) {
351
+ const trimmed = line.trim()
352
+ const match = /^\|(.+)\|$/.exec(trimmed)
353
+ if (match === null) return null
354
+ return match[1].split('|').map((cell) => cell.trim())
355
+ }
356
+
357
+ function isTableSeparator(cells) {
358
+ return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell) || /^:?-+:?$/.test(cell))
359
+ }
360
+
361
+ const NOTES_COMMENT = /^<!--\s*(?:notes?|备注)\s*[::]\s*([\s\S]*?)\s*-->$/
362
+
363
+ function quoteSlideFrom(section, ui) {
364
+ const lines = [...section.quote]
365
+ let attribution = ''
366
+ const attrMatch = /^(?:——|—|--|-)\s*(.+)$/.exec(lines[lines.length - 1] ?? '')
367
+ if (attrMatch !== null && lines.length > 1) {
368
+ attribution = attrMatch[1]
369
+ lines.pop()
370
+ }
371
+ return { layout: 'quote', kicker: ui.quoteKicker, title: truncate(lines.join(' '), 220), subtitle: attribution }
372
+ }
373
+
374
+ function paginateTable(slide, continuation = '(续)') {
375
+ const rows = slide.rows
376
+ const columns = Math.max(...rows.map(row => row.length))
377
+ if (columns > 8) throw new Error('dsh-ppt:表格最多 8 列,请拆成多个表格后重试 / tables support up to 8 columns')
378
+ if (rows.some(row => row.some(cell => [...cell].length > 60))) {
379
+ throw new Error('dsh-ppt:表格单元格最多 60 个字符,请精简或拆分内容后重试 / cells support up to 60 characters')
205
380
  }
206
- return language
207
- }
208
-
209
- export function resolveMotion(input) {
210
- if (input === undefined || input === null || input === '') return true
211
- if (input === true || input === 'on') return true
212
- if (input === false || input === 'off') return false
213
- throw new Error('dsh-ppt:未知 motion "' + String(input) + '",可选:on / off(默认 on)')
214
- }
215
-
216
- export function listThemes(lang = 'zh') {
217
- const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
218
- return THEME_IDS.map((id) => {
219
- const theme = THEMES[id]
220
- return {
221
- id,
222
- name: pick(theme.name),
223
- mood: pick(theme.mood),
224
- bestFor: pick(theme.bestFor),
225
- dark: theme.dark,
226
- palette: { ...theme.palette },
227
- fonts: { ...theme.fonts },
228
- }
381
+ const padded = rows.map(row => Array.from({ length: columns }, (_, i) => row[i] ?? ''))
382
+ const pages = chunk(padded.slice(1), 8)
383
+ if (pages.length === 0) pages.push([])
384
+ return pages.map((body, index) => {
385
+ const { notes, ...rest } = slide
386
+ const title = slide.title + (index === 0 ? '' : continuation)
387
+ // 同名 kicker/title 共用紧凑标题;续页不能凭空多出一行大标题挤压表格。
388
+ return { ...rest, title, kicker: slide.kicker === slide.title ? title : slide.kicker, rows: [padded[0], ...body],
389
+ ...(index === 0 && notes ? { notes } : {}) }
229
390
  })
230
391
  }
231
392
 
232
- // ---------------------------------------------------------------------------
233
- // 文本工具
234
- // ---------------------------------------------------------------------------
235
-
236
- export function clampInt(value, fallback, min, max) {
237
- const n = typeof value === 'number' ? Math.trunc(value) : fallback
238
- if (!Number.isFinite(n)) return fallback
239
- return Math.min(max, Math.max(min, n))
240
- }
241
-
242
- export function sanitizeFileName(input) {
243
- const base = String(input ?? 'deck').trim().replace(/\.(html?|pptx|json)$/i, '')
244
- const cleaned = base
245
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
246
- .replace(/\s+/g, '-')
247
- .replace(/-+/g, '-')
248
- .replace(/^-+|-+$/g, '')
249
- return cleaned.slice(0, 120) || 'deck'
250
- }
251
-
252
- const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
253
-
254
- /**
255
- * 为三件套选择同一个可用文件名前缀。默认不覆盖:任一产物已存在时,
256
- * 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
257
- */
258
- function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
259
- const isAvailable = (candidate) => DECK_ARTIFACT_EXTENSIONS.every((ext) => !existsSync(resolvePath(outputDir, candidate + ext)))
260
- if (overwrite || isAvailable(requestedFileName)) return requestedFileName
261
- for (let index = 1; index < 1000; index += 1) {
262
- const candidate = requestedFileName + '-' + index
263
- if (isAvailable(candidate)) return candidate
264
- }
265
- throw new Error('dsh-ppt:找不到可用的输出文件名(同名前缀超过 999 个),请更换 fileName 或显式允许 overwrite。')
266
- }
267
-
268
- export function escapeXml(value) {
269
- return String(value ?? '')
270
- .replace(/&/g, '&amp;')
271
- .replace(/</g, '&lt;')
272
- .replace(/>/g, '&gt;')
273
- .replace(/"/g, '&quot;')
274
- .replace(/'/g, '&apos;')
275
- }
276
-
277
- export function escapeHtml(value) {
278
- return String(value ?? '')
279
- .replace(/&/g, '&amp;')
280
- .replace(/</g, '&lt;')
281
- .replace(/>/g, '&gt;')
282
- .replace(/"/g, '&quot;')
283
- }
284
-
285
- export function stripInlineMarkdown(value) {
286
- return String(value ?? '')
287
- .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
288
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
289
- .replace(/`([^`]*)`/g, '$1')
290
- .replace(/\*\*([^*]+)\*\*/g, '$1')
291
- .replace(/__([^_]+)__/g, '$1')
292
- .replace(/\*([^*\n]+)\*/g, '$1')
293
- .replace(/_([^_\n]+)_/g, '$1')
294
- .replace(/<[^>]+>/g, '')
295
- .replace(/\s+/g, ' ')
296
- .trim()
297
- }
298
-
299
- export function splitSentences(value) {
300
- const text = String(value ?? '').trim()
301
- if (text === '') return []
302
- const parts = text.split(/(?<=[.!?。!?…])\s+/).map((part) => part.trim()).filter(Boolean)
303
- return parts.length > 0 ? parts : [text]
304
- }
305
-
306
- export function truncate(value, max) {
307
- const text = String(value ?? '').trim()
308
- if (text.length <= max) return text
309
- return text.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'
310
- }
311
-
312
- function chunk(items, size) {
313
- const out = []
314
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
315
- return out
316
- }
317
-
318
- // ---------------------------------------------------------------------------
319
- // Markdown → deck
320
- // ---------------------------------------------------------------------------
321
-
322
- function createSection(heading = '', level = 0, coverOnly = false) {
323
- return { heading, level, coverOnly, bullets: [], paragraphs: [], quote: [], table: null, notes: [] }
324
- }
325
-
326
- function splitTableRow(line) {
327
- const trimmed = line.trim()
328
- const match = /^\|(.+)\|$/.exec(trimmed)
329
- if (match === null) return null
330
- return match[1].split('|').map((cell) => cell.trim())
331
- }
332
-
333
- function isTableSeparator(cells) {
334
- return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell) || /^:?-+:?$/.test(cell))
335
- }
336
-
337
- const NOTES_COMMENT = /^<!--\s*(?:notes?|备注)\s*[::]\s*([\s\S]*?)\s*-->$/
338
-
339
- function quoteSlideFrom(section, ui) {
340
- const lines = [...section.quote]
341
- let attribution = ''
342
- const attrMatch = /^(?:——|—|--|-)\s*(.+)$/.exec(lines[lines.length - 1] ?? '')
343
- if (attrMatch !== null && lines.length > 1) {
344
- attribution = attrMatch[1]
345
- lines.pop()
346
- }
347
- return { layout: 'quote', kicker: ui.quoteKicker, title: truncate(lines.join(' '), 220), subtitle: attribution }
348
- }
349
-
350
- function tableSlideFrom(section, ui) {
393
+ function tableSlidesFrom(section, ui) {
351
394
  const table = section.table
352
395
  const rows = [table.header, ...table.rows]
353
- .slice(0, 9)
354
- .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
355
396
  const heading = section.heading || ui.tableKicker
356
- return { layout: 'table', kicker: heading, title: heading, rows }
357
- }
358
-
359
- /** 把一个 section 里的金句/表格拆成独立页;备注附着到第一张产出页。 */
360
- function specialSlidesFor(section, ui) {
361
- const out = []
362
- if (section.quote.length > 0) out.push(quoteSlideFrom(section, ui))
363
- if (section.table !== null) out.push(tableSlideFrom(section, ui))
364
- if (out.length > 0 && section.notes.length > 0) out[0].notes = section.notes.join('\n')
365
- return out
366
- }
367
-
368
- export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
369
- const language = resolveLanguage(lang)
370
- const ui = language.ui
371
- const title = String(titleInput ?? '').trim()
372
- let coverTitle = title
373
- let coverSubtitle = ''
374
- let coverSubtitleConsumed = false
375
-
376
- let body = String(content ?? '')
377
- .replace(/^\uFEFF/, '')
378
- .replace(/\r\n?/g, '\n')
379
- // 去除文档级 YAML frontmatter(如果有)
380
- body = body.replace(/^---[ \t]*\n[\s\S]*?\n---[ \t]*\n?/, '')
381
-
382
- const sections = []
383
- let current = null
384
- let firstH1Seen = false
385
-
386
- const flush = () => {
387
- if (current !== null && (current.heading !== '' || current.bullets.length > 0 || current.paragraphs.length > 0)) {
388
- sections.push(current)
389
- }
390
- current = null
391
- }
392
-
393
- for (const rawLine of body.split('\n')) {
394
- const line = rawLine.trim()
395
- if (line === '') {
396
- // 无标题分组按空行分段,保证「文档无标题」时按段落生成幻灯片
397
- if (current !== null && current.heading === '') flush()
398
- continue
399
- }
400
-
401
- const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line)
402
- if (headingMatch !== null) {
403
- const level = headingMatch[1].length
404
- const heading = stripInlineMarkdown(headingMatch[2])
405
- if (level === 1 && !firstH1Seen) {
406
- firstH1Seen = true
407
- if (coverTitle === '') coverTitle = heading
408
- flush()
409
- current = createSection(heading, level, true)
410
- continue
411
- }
412
- flush()
413
- current = createSection(heading, level, false)
414
- continue
415
- }
416
-
417
- const bulletMatch = /^\s*(?:[-*+]|\d+[.)])\s+(.+)$/.exec(line)
418
- if (bulletMatch !== null) {
419
- const bullet = stripInlineMarkdown(bulletMatch[1])
420
- if (bullet !== '') {
421
- if (current === null) current = createSection()
422
- current.bullets.push(bullet)
423
- }
424
- continue
425
- }
426
-
427
- // 演讲者备注:`<!-- 备注: ... -->` / `<!-- note: ... -->` 附到当前页
428
- const notesMatch = NOTES_COMMENT.exec(line)
429
- if (notesMatch !== null && notesMatch[1].trim() !== '') {
430
- if (current === null) current = createSection()
431
- current.notes.push(notesMatch[1].trim())
432
- continue
433
- }
434
-
435
- // Markdown 表格:连续的 | ... | 行,第二行是分隔线
436
- const tableCells = splitTableRow(line)
437
- if (tableCells !== null) {
438
- if (current === null) current = createSection()
439
- if (isTableSeparator(tableCells)) continue
440
- if (current.table === null) {
441
- current.table = { header: tableCells.map((cell) => stripInlineMarkdown(cell)), rows: [] }
442
- } else if (current.table.rows.length < 12) {
443
- current.table.rows.push(tableCells.map((cell) => stripInlineMarkdown(cell)))
444
- }
445
- continue
446
- }
447
-
448
- // 金句:`>` 引用块(最后一行 `—— 出处` 识别为署名)
449
- const quoteMatch = /^>\s?(.*)$/.exec(line)
450
- if (quoteMatch !== null) {
451
- const quoted = stripInlineMarkdown(quoteMatch[1])
452
- if (quoted !== '') {
453
- if (current === null) current = createSection()
454
- current.quote.push(quoted)
455
- }
456
- continue
457
- }
458
-
459
- const paragraph = stripInlineMarkdown(line)
460
- if (paragraph === '') continue
461
- if (current === null) current = createSection()
462
- current.paragraphs.push(paragraph)
463
- }
464
- flush()
465
-
466
- const slides = []
467
- const coverSource = sections.find((section) => section.coverOnly === true)
468
- if (coverSource !== null && coverSource !== undefined) {
469
- const coverText = coverSource.paragraphs[0] ?? coverSource.bullets[0] ?? ''
470
- if (coverSubtitle === '') {
471
- coverSubtitle = truncate(coverText, 180)
472
- coverSubtitleConsumed = true
473
- }
474
- }
475
-
397
+ return paginateTable({ layout: 'table', kicker: heading, title: heading, rows }, ui.continuation)
398
+ }
399
+
400
+ /** 把一个 section 里的金句/表格拆成独立页;备注附着到第一张产出页。 */
401
+ function specialSlidesFor(section, ui) {
402
+ const out = []
403
+ if (section.quote.length > 0) out.push(quoteSlideFrom(section, ui))
404
+ if (section.table !== null) out.push(...tableSlidesFrom(section, ui))
405
+ if (out.length > 0 && section.notes.length > 0) out[0].notes = section.notes.join('\n')
406
+ return out
407
+ }
408
+
409
+ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
410
+ const language = resolveLanguage(lang)
411
+ const ui = language.ui
412
+ const title = String(titleInput ?? '').trim()
413
+ let coverTitle = title
414
+ let coverSubtitle = ''
415
+ let coverSubtitleConsumed = false
416
+
417
+ let body = String(content ?? '')
418
+ .replace(/^\uFEFF/, '')
419
+ .replace(/\r\n?/g, '\n')
420
+ // 去除文档级 YAML frontmatter(如果有)
421
+ body = body.replace(/^---[ \t]*\n[\s\S]*?\n---[ \t]*\n?/, '')
422
+
423
+ const sections = []
424
+ let current = null
425
+ let firstH1Seen = false
426
+
427
+ const flush = () => {
428
+ if (current !== null && (current.heading !== '' || current.bullets.length > 0 || current.paragraphs.length > 0
429
+ || current.table !== null || current.quote.length > 0)) {
430
+ sections.push(current)
431
+ }
432
+ current = null
433
+ }
434
+
435
+ for (const rawLine of body.split('\n')) {
436
+ const line = rawLine.trim()
437
+ if (line === '') {
438
+ // 无标题分组按空行分段,保证「文档无标题」时按段落生成幻灯片
439
+ if (current !== null && current.heading === '') flush()
440
+ continue
441
+ }
442
+
443
+ const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line)
444
+ if (headingMatch !== null) {
445
+ const level = headingMatch[1].length
446
+ const heading = stripInlineMarkdown(headingMatch[2])
447
+ if (level === 1 && !firstH1Seen) {
448
+ firstH1Seen = true
449
+ if (coverTitle === '') coverTitle = heading
450
+ flush()
451
+ current = createSection(heading, level, true)
452
+ continue
453
+ }
454
+ flush()
455
+ current = createSection(heading, level, false)
456
+ continue
457
+ }
458
+
459
+ const bulletMatch = /^\s*(?:[-*+]|\d+[.)])\s+(.+)$/.exec(line)
460
+ if (bulletMatch !== null) {
461
+ const bullet = stripInlineMarkdown(bulletMatch[1])
462
+ if (bullet !== '') {
463
+ if (current === null) current = createSection()
464
+ current.bullets.push(bullet)
465
+ }
466
+ continue
467
+ }
468
+
469
+ // 演讲者备注:`<!-- 备注: ... -->` / `<!-- note: ... -->` 附到当前页
470
+ const notesMatch = NOTES_COMMENT.exec(line)
471
+ if (notesMatch !== null && notesMatch[1].trim() !== '') {
472
+ if (current === null) current = createSection()
473
+ current.notes.push(notesMatch[1].trim())
474
+ continue
475
+ }
476
+
477
+ // Markdown 表格:连续的 | ... | 行,第二行是分隔线
478
+ const tableCells = splitTableRow(line)
479
+ if (tableCells !== null) {
480
+ if (current === null) current = createSection()
481
+ if (isTableSeparator(tableCells)) continue
482
+ if (current.table === null) {
483
+ current.table = { header: tableCells.map((cell) => stripInlineMarkdown(cell)), rows: [] }
484
+ } else {
485
+ current.table.rows.push(tableCells.map((cell) => stripInlineMarkdown(cell)))
486
+ }
487
+ continue
488
+ }
489
+
490
+ // 金句:`>` 引用块(最后一行 `—— 出处` 识别为署名)
491
+ const quoteMatch = /^>\s?(.*)$/.exec(line)
492
+ if (quoteMatch !== null) {
493
+ const quoted = stripInlineMarkdown(quoteMatch[1])
494
+ if (quoted !== '') {
495
+ if (current === null) current = createSection()
496
+ current.quote.push(quoted)
497
+ }
498
+ continue
499
+ }
500
+
501
+ const paragraph = stripInlineMarkdown(line)
502
+ if (paragraph === '') continue
503
+ if (current === null) current = createSection()
504
+ current.paragraphs.push(paragraph)
505
+ }
506
+ flush()
507
+
508
+ const slides = []
509
+ const coverSource = sections.find((section) => section.coverOnly === true)
510
+ if (coverSource !== null && coverSource !== undefined) {
511
+ const coverText = coverSource.paragraphs[0] ?? coverSource.bullets[0] ?? ''
512
+ if (coverSubtitle === '') {
513
+ coverSubtitle = truncate(coverText, 180)
514
+ coverSubtitleConsumed = true
515
+ }
516
+ }
517
+
476
518
  let bodySections = sections.filter((section) => section.coverOnly !== true)
477
- // 首个 H1 既是封面又把所有要点收在封面节里时,把封面副标题之外的剩余要点
478
- // 提升为一个无标题节,走下面的要点页生成逻辑,避免丢掉内容。
479
- if (bodySections.length === 0 && coverSource !== null && coverSource !== undefined) {
480
- const usedParagraph = coverSource.paragraphs.length > 0
481
- const extras = [
482
- ...(usedParagraph ? coverSource.bullets : coverSource.bullets.slice(1)),
483
- ...coverSource.paragraphs.slice(usedParagraph ? 1 : 0),
484
- ]
485
- if (extras.length > 0) {
486
- const synthetic = createSection()
487
- synthetic.bullets = extras
519
+ // 首个 H1 既是封面又把所有要点收在封面节里时,把封面副标题之外的剩余要点
520
+ // 提升为一个无标题节,走下面的要点页生成逻辑,避免丢掉内容。
521
+ if (bodySections.length === 0 && coverSource !== null && coverSource !== undefined) {
522
+ const usedParagraph = coverSource.paragraphs.length > 0
523
+ const extras = [
524
+ ...(usedParagraph ? coverSource.bullets : coverSource.bullets.slice(1)),
525
+ ...coverSource.paragraphs.slice(usedParagraph ? 1 : 0),
526
+ ]
527
+ if (extras.length > 0) {
528
+ const synthetic = createSection()
529
+ synthetic.bullets = extras
488
530
  bodySections = [synthetic]
489
531
  }
490
532
  }
491
533
 
492
- if (bodySections.length === 0) {
493
- // 一句话 / 无结构输入:封面 + 核心观点 + 结束页(仍是完整的三页演示文稿)
494
- const allText = coverSource !== null && coverSource !== undefined
495
- ? [...coverSource.bullets, ...coverSource.paragraphs].join(' ')
496
- : String(content ?? '').trim()
497
- if (coverSubtitle === '') coverSubtitle = truncate(splitSentences(allText)[0] ?? '', 180)
498
- slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
499
- if (coverSubtitle !== '') {
500
- slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle })
501
- }
502
- slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
503
- return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
504
- }
505
-
506
- const hadHeadings = bodySections.some((section) => section.heading !== '')
507
-
508
- if (!hadHeadings) {
509
- // 无标题文档:第一段摘要作封面副标题,后续段落拆成要点页
510
- const ordered = bodySections.map((section) => ({
511
- section,
512
- sentences: [...section.bullets, ...section.paragraphs].flatMap((part) => splitSentences(part)),
513
- }))
514
- const firstSentence = ordered[0]?.sentences[0] ?? ''
515
- if (coverSubtitle === '') coverSubtitle = truncate(firstSentence, 180)
516
- const bodySlides = []
517
- let pointIndex = 0
518
- ordered.forEach((group, groupIndex) => {
519
- const specials = specialSlidesFor(group.section, ui)
520
- bodySlides.push(...specials)
521
- const sentences = (groupIndex === 0 && !coverSubtitleConsumed) ? group.sentences.slice(1) : group.sentences
522
- for (const points of chunk(sentences, 5)) {
523
- pointIndex += 1
524
- const slide = {
525
- layout: 'bullets',
526
- kicker: ui.pointKicker + ' ' + pointIndex,
527
- title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
528
- bullets: points,
529
- }
530
- if (specials.length === 0 && group.section.notes.length > 0) slide.notes = group.section.notes.join('\n')
531
- bodySlides.push(slide)
532
- }
533
- })
534
- slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
535
- if (bodySlides.length === 0) {
536
- if (coverSubtitle !== '') {
537
- slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle || 'Untitled' })
538
- }
539
- } else {
540
- slides.push(...bodySlides)
541
- }
542
- slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
543
- return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
544
- }
545
-
546
- slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
547
- let pointIndex = 0
548
- for (const section of bodySections) {
549
- const notes = section.notes.join('\n')
550
- const specials = specialSlidesFor(section, ui)
551
- slides.push(...specials)
552
- if (section.heading === '') {
553
- const points = [...section.bullets, ...section.paragraphs]
554
- .flatMap((part) => splitSentences(part))
555
- .slice(0, 8)
556
- if (points.length > 0) {
557
- pointIndex += 1
558
- const slide = {
559
- layout: 'bullets',
560
- kicker: ui.pointKicker + ' ' + pointIndex,
561
- title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
562
- bullets: points,
563
- }
564
- if (specials.length === 0 && notes !== '') slide.notes = notes
565
- slides.push(slide)
566
- }
567
- continue
568
- }
569
- const points = [
570
- ...section.bullets,
571
- ...section.paragraphs.flatMap((part) => splitSentences(part)),
572
- ].slice(0, 8)
573
- if (points.length > 0) {
574
- const slide = { layout: 'bullets', kicker: section.heading, title: section.heading, bullets: points }
575
- if (specials.length === 0 && notes !== '') slide.notes = notes
576
- slides.push(slide)
577
- } else if (specials.length === 0) {
578
- const slide = { layout: 'section', kicker: section.heading, title: section.heading }
579
- if (notes !== '') slide.notes = notes
580
- slides.push(slide)
581
- }
582
- }
583
- slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
584
- return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
585
- }
586
-
587
- const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
588
-
589
- function normalizeSlide(raw, index) {
590
- const source = (raw !== null && typeof raw === 'object') ? raw : {}
591
- const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : 'bullets'
592
- const title = stripInlineMarkdown(source.title ?? '')
593
- const subtitle = stripInlineMarkdown(source.subtitle ?? '')
594
- const kicker = stripInlineMarkdown(source.kicker ?? '')
595
- const text = stripInlineMarkdown(source.text ?? '')
596
- // 备注保留换行(放映面板与备注页都按原文显示),只做转义,不做 Markdown 清洗
597
- const notes = String(source.notes ?? '').trim()
598
- let bullets = []
599
- if (Array.isArray(source.bullets)) {
600
- bullets = source.bullets.map((item) => stripInlineMarkdown(String(item))).filter(Boolean)
601
- } else if (typeof source.bullets === 'string' && source.bullets.trim() !== '') {
602
- bullets = splitSentences(source.bullets)
603
- }
604
- if (layout === 'statement' && title === '' && text !== '') {
605
- return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
606
- }
607
- if (layout === 'quote') {
608
- const quoteText = title !== '' ? title : text
609
- if (quoteText === '') return { layout: 'statement', kicker, title: subtitle, subtitle: '', bullets, ...(notes !== '' ? { notes } : {}) }
610
- return { layout, kicker, title: truncate(quoteText, 220), subtitle, ...(notes !== '' ? { notes } : {}) }
534
+ // 表格和引用不能只挂在封面节上,否则首个 H1 后没有 H2 时会丢失。
535
+ if (coverSource && (coverSource.table !== null || coverSource.quote.length > 0)) {
536
+ const special = createSection()
537
+ special.table = coverSource.table
538
+ special.quote = coverSource.quote
539
+ special.notes = coverSource.notes
540
+ bodySections.unshift(special)
611
541
  }
612
- if (layout === 'table') {
613
- let rows = []
614
- if (Array.isArray(source.rows)) {
615
- rows = source.rows
542
+
543
+ if (bodySections.length === 0) {
544
+ // 一句话 / 无结构输入:封面 + 核心观点 + 结束页(仍是完整的三页演示文稿)
545
+ const allText = coverSource !== null && coverSource !== undefined
546
+ ? [...coverSource.bullets, ...coverSource.paragraphs].join(' ')
547
+ : String(content ?? '').trim()
548
+ if (coverSubtitle === '') coverSubtitle = truncate(splitSentences(allText)[0] ?? '', 180)
549
+ slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
550
+ if (coverSubtitle !== '') {
551
+ slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle })
552
+ }
553
+ slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
554
+ return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
555
+ }
556
+
557
+ const hadHeadings = bodySections.some((section) => section.heading !== '')
558
+
559
+ if (!hadHeadings) {
560
+ // 无标题文档:第一段摘要作封面副标题,后续段落拆成要点页
561
+ const ordered = bodySections.map((section) => ({
562
+ section,
563
+ sentences: [...section.bullets, ...section.paragraphs].flatMap((part) => splitSentences(part)),
564
+ }))
565
+ const firstSentence = ordered[0]?.sentences[0] ?? ''
566
+ if (coverSubtitle === '') coverSubtitle = truncate(firstSentence, 180)
567
+ const bodySlides = []
568
+ let pointIndex = 0
569
+ ordered.forEach((group, groupIndex) => {
570
+ const specials = specialSlidesFor(group.section, ui)
571
+ bodySlides.push(...specials)
572
+ const sentences = (groupIndex === 0 && !coverSubtitleConsumed) ? group.sentences.slice(1) : group.sentences
573
+ for (const points of chunk(sentences, 5)) {
574
+ pointIndex += 1
575
+ const slide = {
576
+ layout: 'bullets',
577
+ kicker: ui.pointKicker + ' ' + pointIndex,
578
+ title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
579
+ bullets: points,
580
+ }
581
+ if (specials.length === 0 && group.section.notes.length > 0) slide.notes = group.section.notes.join('\n')
582
+ bodySlides.push(slide)
583
+ }
584
+ })
585
+ slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
586
+ if (bodySlides.length === 0) {
587
+ if (coverSubtitle !== '') {
588
+ slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle || 'Untitled' })
589
+ }
590
+ } else {
591
+ slides.push(...bodySlides)
592
+ }
593
+ slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
594
+ return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
595
+ }
596
+
597
+ slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
598
+ let pointIndex = 0
599
+ for (const section of bodySections) {
600
+ const notes = section.notes.join('\n')
601
+ const specials = specialSlidesFor(section, ui)
602
+ slides.push(...specials)
603
+ if (section.heading === '') {
604
+ const points = [...section.bullets, ...section.paragraphs]
605
+ .flatMap((part) => splitSentences(part))
606
+ // 超过 8 条自动续页,不再静默丢弃;备注只挂在第一页
607
+ for (const [pageIndex, pagePoints] of chunk(points, 8).entries()) {
608
+ pointIndex += 1
609
+ const slide = {
610
+ layout: 'bullets',
611
+ kicker: ui.pointKicker + ' ' + pointIndex,
612
+ title: truncate(pagePoints[0], 40) || ui.pointKicker + ' ' + pointIndex,
613
+ bullets: pagePoints,
614
+ }
615
+ if (pageIndex === 0 && specials.length === 0 && notes !== '') slide.notes = notes
616
+ slides.push(slide)
617
+ }
618
+ continue
619
+ }
620
+ const points = [
621
+ ...section.bullets,
622
+ ...section.paragraphs.flatMap((part) => splitSentences(part)),
623
+ ]
624
+ const pages = chunk(points, 8)
625
+ if (pages.length > 0) {
626
+ // 超过 8 条自动续页,不再静默丢弃;备注只挂在第一页
627
+ pages.forEach((pagePoints, pageIndex) => {
628
+ const label = pageIndex === 0 ? section.heading : section.heading + ui.continuation
629
+ const slide = { layout: 'bullets', kicker: label, title: label, bullets: pagePoints }
630
+ if (pageIndex === 0 && specials.length === 0 && notes !== '') slide.notes = notes
631
+ slides.push(slide)
632
+ })
633
+ } else if (specials.length === 0) {
634
+ const slide = { layout: 'section', kicker: section.heading, title: section.heading }
635
+ if (notes !== '') slide.notes = notes
636
+ slides.push(slide)
637
+ }
638
+ }
639
+ slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
640
+ return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
641
+ }
642
+
643
+ const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
644
+
645
+ function normalizeSlide(raw, index) {
646
+ const source = (raw !== null && typeof raw === 'object') ? raw : {}
647
+ const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : 'bullets'
648
+ const title = stripInlineMarkdown(source.title ?? '')
649
+ const subtitle = stripInlineMarkdown(source.subtitle ?? '')
650
+ const kicker = stripInlineMarkdown(source.kicker ?? '')
651
+ const text = stripInlineMarkdown(source.text ?? '')
652
+ // 备注保留换行(放映面板与备注页都按原文显示),只做转义,不做 Markdown 清洗
653
+ const notes = String(source.notes ?? '').trim()
654
+ let bullets = []
655
+ if (Array.isArray(source.bullets)) {
656
+ bullets = source.bullets.map((item) => stripInlineMarkdown(String(item))).filter(Boolean)
657
+ } else if (typeof source.bullets === 'string' && source.bullets.trim() !== '') {
658
+ bullets = splitSentences(source.bullets)
659
+ }
660
+ if (layout === 'statement' && title === '' && text !== '') {
661
+ return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
662
+ }
663
+ if (layout === 'quote') {
664
+ const quoteText = title !== '' ? title : text
665
+ if (quoteText === '') return { layout: 'statement', kicker, title: subtitle, subtitle: '', bullets, ...(notes !== '' ? { notes } : {}) }
666
+ return { layout, kicker, title: truncate(quoteText, 220), subtitle, ...(notes !== '' ? { notes } : {}) }
667
+ }
668
+ if (layout === 'table') {
669
+ let rows = []
670
+ if (Array.isArray(source.rows)) {
671
+ rows = source.rows
616
672
  .map((row) => (Array.isArray(row) ? row : [row]).map((cell) => stripInlineMarkdown(String(cell))))
617
673
  .filter((row) => row.some((cell) => cell !== ''))
618
- .slice(0, 9)
619
- .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
620
- }
621
- if (rows.length === 0) {
622
- // 没有表格数据的 table 页退化成要点页,避免产出空白版式
623
- return { layout: 'bullets', kicker, title, subtitle, bullets, ...(notes !== '' ? { notes } : {}) }
624
- }
625
- return { layout, kicker: kicker || title, title, rows, ...(notes !== '' ? { notes } : {}) }
626
- }
627
- return { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }
628
- }
629
-
630
- export function normalizeSlides(rawSlides, maxSlides = 60) {
631
- if (!Array.isArray(rawSlides) || rawSlides.length === 0) {
632
- throw new Error('dsh-ppt:slides 必须是非空数组(每个元素是 { layout, title, subtitle, kicker, bullets } 对象)')
633
- }
634
- const limit = clampInt(maxSlides, 60, 1, 120)
635
- const bounded = rawSlides.length > limit && limit >= 3
636
- ? [rawSlides[0], ...rawSlides.slice(1, limit - 1), rawSlides[rawSlides.length - 1]]
637
- : rawSlides.slice(0, limit)
638
- const slides = bounded.map(normalizeSlide)
639
- if (slides.length === 0) throw new Error('dsh-ppt:slides 规范化后为空')
640
- return slides
641
- }
642
-
643
- // ---------------------------------------------------------------------------
644
- // 构建入口
645
- // ---------------------------------------------------------------------------
646
-
647
- export function normalizeBuildOptions(options = {}) {
648
- const title = String(options.title ?? '').trim()
649
- if (title === '') throw new Error('dsh-ppt:title 不能为空')
650
- const theme = resolveTheme(options.theme)
651
- const language = resolveLanguage(options.lang)
652
- const motion = resolveMotion(options.motion)
653
- const maxSlides = clampInt(options.maxSlides, 60, 3, 120)
654
- let deck
655
- if (Array.isArray(options.slides) && options.slides.length > 0) {
656
- deck = {
657
- title,
658
- subtitle: stripInlineMarkdown(options.subtitle ?? ''),
659
- slides: normalizeSlides(options.slides, maxSlides),
660
- }
661
- } else {
662
- const content = String(options.content ?? '').trim()
663
- if (content === '') throw new Error('dsh-ppt:content 不能为空(或用 slides 传结构化幻灯片)')
664
- deck = parseMarkdownDeck(title, content, language.id)
665
- const slideLimit = clampInt(maxSlides, 60, 3, 120)
666
- if (deck.slides.length > slideLimit) {
667
- deck.slides = [deck.slides[0], ...deck.slides.slice(1, slideLimit - 1), deck.slides[deck.slides.length - 1]]
668
- }
669
- }
670
- if (deck.slides.length < 1) throw new Error('dsh-ppt:没有可生成的幻灯片')
671
- const outputDir = resolvePath(String(options.outputDir ?? '.').trim() || '.')
672
- const fileName = sanitizeFileName(options.fileName ?? title)
673
- const overwrite = options.overwrite === true
674
- return { title, theme, language, motion, deck, outputDir, fileName, overwrite }
675
- }
676
-
677
- export function buildDeck(options = {}) {
678
- const normalized = normalizeBuildOptions(options)
679
- const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
680
- mkdirSync(outputDir, { recursive: true })
681
- const fileName = resolveDeckFileName(outputDir, normalized.fileName, overwrite)
682
-
683
- const manifest = {
684
- version: DECK_VERSION,
685
- title,
686
- theme: theme.id,
687
- language: language.id,
688
- motion,
689
- slideCount: deck.slides.length,
690
- slides: deck.slides,
691
- }
692
- const jsonPath = resolvePath(outputDir, fileName + '.json')
693
- const htmlPath = resolvePath(outputDir, fileName + '.html')
694
- const pptxPath = resolvePath(outputDir, fileName + '.pptx')
695
-
696
- writeFileSync(jsonPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
697
- writeFileSync(htmlPath, renderHtml(manifest, theme, language), 'utf8')
698
- writeFileSync(pptxPath, buildPptx(manifest, theme, language))
699
-
700
- return {
701
- ok: true,
702
- title,
703
- theme: theme.id,
704
- language: language.id,
705
- slideCount: deck.slides.length,
706
- outputDir,
707
- files: {
708
- html: htmlPath,
709
- pptx: pptxPath,
710
- json: jsonPath,
711
- },
712
- htmlPath,
713
- pptxPath,
714
- jsonPath,
715
- }
716
- }
717
-
718
- // ---------------------------------------------------------------------------
719
- // HTML 网页放映
720
- // ---------------------------------------------------------------------------
721
-
722
- export function renderHtml(manifest, theme, language) {
723
- const t = theme
724
- const lang = language
725
- const ui = lang.ui
726
- const motion = manifest.motion !== false
727
- const motionCss = motion
728
- ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
729
- @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
730
- body.motion .bullets li{
731
- opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
732
- animation-delay:calc(var(--i, 0)*90ms + .12s);
733
- }
734
- @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
735
- : ''
736
- const slides = manifest.slides.map((slide, index) => renderHtmlSlide(slide, index, ui, lang.id)).join('\n')
737
- const themeLabel = t.name[lang.id] ?? t.name.en
738
- const total = manifest.slides.length
739
-
740
- return `<!DOCTYPE html>
741
- <html lang="${lang.attr}">
742
- <head>
743
- <meta charset="UTF-8">
744
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
745
- <title>${escapeHtml(manifest.title)}</title>
746
- <style>
747
- :root{
748
- --bg:${t.palette.bg};
749
- --panel:${t.palette.panel};
750
- --fg:${t.palette.fg};
751
- --muted:${t.palette.muted};
752
- --accent:${t.palette.accent};
753
- --accent2:${t.palette.accent2};
754
- --font-heading:${t.fonts.heading};
755
- --font-body:${t.fonts.body};
756
- }
757
- *{box-sizing:border-box}
758
- html,body{height:100%}
759
- body{
760
- margin:0;background:var(--bg);color:var(--fg);
761
- font-family:var(--font-body);overflow:hidden;
762
- -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
763
- }
764
- .slide{
765
- position:fixed;inset:0;display:none;flex-direction:column;justify-content:center;
766
- padding:clamp(34px,7vw,110px);overflow:hidden;
767
- }
768
- .slide::before{
769
- content:"";position:absolute;inset:-20%;pointer-events:none;z-index:-1;
770
- background:
771
- radial-gradient(42% 34% at 82% 18%, ${hexToRgba(t.palette.accent, t.dark ? 0.22 : 0.12)}, transparent 70%),
772
- radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
773
- radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
774
- }
775
- .slide.is-active{display:flex}
776
- ${motionCss}
777
- .kicker{
778
- color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
779
- font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
780
- }
781
- h1,h2,.statement-title{font-family:var(--font-heading);line-height:1.06;letter-spacing:-.015em;margin:0}
782
- h1{font-size:clamp(44px,7.4vw,118px);max-width:20ch}
783
- h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
784
- .subtitle{
785
- color:var(--muted);font-size:clamp(18px,2.3vw,34px);line-height:1.5;
786
- max-width:46em;margin-top:28px;
787
- }
788
- .meta{
789
- color:var(--muted);font-size:clamp(12px,1.2vw,16px);margin-top:48px;
790
- letter-spacing:.06em;
791
- }
792
- .bullets ul{margin:30px 0 0;padding:0;list-style:none;display:grid;gap:clamp(12px,1.6vw,24px)}
793
- .bullets li{
794
- position:relative;padding-left:clamp(28px,2.6vw,44px);
795
- font-size:clamp(20px,2.6vw,40px);line-height:1.35;max-width:24em;
796
- }
797
- .bullets li::before{
798
- content:"";position:absolute;left:0;top:.58em;width:.5em;height:.5em;
799
- background:var(--accent);border-radius:2px;box-shadow:.28em .28em 0 color-mix(in srgb, var(--accent2) 78%, transparent);
800
- }
801
- .section .kicker{margin-bottom:10px}
802
- .section .accent-line{width:min(180px,18vw);height:6px;background:var(--accent);margin:28px 0}
803
- .statement-title{
804
- font-size:clamp(34px,5.4vw,88px);max-width:22ch;font-weight:800;
805
- border-left:6px solid var(--accent);padding-left:clamp(22px,3vw,48px);
806
- }
807
- .closing{text-align:center;align-items:center}
808
- .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
809
- .closing .subtitle{color:var(--accent);font-weight:700}
810
- .cover h1{font-weight:900}
811
- .quote-mark{
812
- font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
813
- color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
814
- }
815
- .quote-text{
816
- font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
817
- font-weight:700;max-width:22ch;margin:0;
818
- }
819
- .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
820
- table.deck-table{
821
- border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:1120px;
822
- font-size:clamp(13px,1.55vw,23px);
823
- }
824
- .deck-table th{
825
- background:var(--accent);color:var(--bg);text-align:left;font-weight:700;
826
- padding:.6em .9em;letter-spacing:.02em;
827
- }
828
- .deck-table td{
829
- padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 34%, transparent);
830
- color:var(--fg);
831
- }
832
- .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
833
- #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
834
- #hud{
835
- position:fixed;right:22px;bottom:18px;z-index:30;display:flex;gap:14px;align-items:center;
836
- color:var(--muted);font-size:13px;letter-spacing:.08em;font-variant-numeric:tabular-nums;
837
- }
838
- #hud button{
839
- background:color-mix(in srgb, var(--panel) 88%, transparent);color:var(--fg);
840
- border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);border-radius:99px;
841
- padding:7px 13px;font:inherit;cursor:pointer;
842
- }
843
- #hud button:hover{border-color:var(--accent);color:var(--accent)}
844
- #notes-panel{
845
- position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;
846
- background:color-mix(in srgb, var(--panel) 96%, black 4%);
847
- border-top:2px solid var(--accent);
848
- padding:16px clamp(22px,4vw,64px) 20px;max-height:38vh;overflow:auto;
849
- }
850
- body.notes-open #notes-panel.has-notes{display:block}
851
- #notes-panel .notes-label{
852
- color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
853
- font-size:12px;margin-bottom:8px;
854
- }
855
- #notes-text{white-space:pre-wrap;line-height:1.65;font-size:15px;color:var(--fg)}
856
- body.overview .slide{display:flex !important;position:relative;inset:auto;width:100%;height:100vh}
857
- body.overview{overflow:auto}
858
- body.overview #progress,body.overview #hud{position:fixed}
859
- body.overview #notes-panel{display:none !important}
860
- @media (max-width:640px){
861
- #hud{right:12px;bottom:10px;gap:8px;font-size:11px}
862
- }
863
- @media print{
864
- html,body{height:auto;overflow:visible;background:#fff}
865
- .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
866
- ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
867
- #progress,#hud,#notes-panel{display:none !important}
868
- }
869
- </style>
870
- </head>
871
- <body class="${motion ? 'motion' : 'no-motion'}">
872
- <div id="progress" aria-hidden="true"></div>
873
- ${slides}
874
- <div id="hud" aria-live="polite">
875
- <span id="counter">${ui.slide} 1 ${ui.of} ${total}</span>
876
- <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
877
- <button id="notes-toggle" title="S">${escapeHtml(ui.notesToggle)}</button>
878
- <button id="fullscreen" title="F">⛶</button>
879
- </div>
880
- <div id="notes-panel" aria-live="polite">
881
- <div class="notes-label">${escapeHtml(ui.notesLabel)}</div>
882
- <div id="notes-text"></div>
883
- </div>
884
- <script>
885
- (() => {
886
- const slides = Array.from(document.querySelectorAll('.slide'));
887
- const counter = document.getElementById('counter');
888
- const progress = document.getElementById('progress');
889
- const fullscreenBtn = document.getElementById('fullscreen');
890
- const notesPanel = document.getElementById('notes-panel');
891
- const notesText = document.getElementById('notes-text');
892
- const notesBtn = document.getElementById('notes-toggle');
893
- let index = 0;
894
- const total = slides.length;
895
- const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
896
-
897
- function refreshNotes() {
898
- const notes = slides[index]?.dataset.notes || '';
899
- notesPanel.classList.toggle('has-notes', notes !== '');
900
- notesText.textContent = notes;
901
- }
902
-
903
- function go(next) {
904
- index = (next + total) % total;
905
- slides.forEach((slide, i) => slide.classList.toggle('is-active', i === index));
906
- counter.textContent = ui.slide + ' ' + (index + 1) + ' ' + ui.of + ' ' + total;
907
- progress.style.width = ((index + 1) / total * 100) + '%';
908
- refreshNotes();
909
- document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
910
- try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch { /* file:// 下个别浏览器可能拒绝 */ }
911
- }
912
-
913
- document.addEventListener('keydown', (event) => {
914
- if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {
915
- event.preventDefault(); go(index + 1);
916
- } else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
917
- event.preventDefault(); go(index - 1);
918
- } else if (event.key === 'Home') {
919
- event.preventDefault(); go(0);
920
- } else if (event.key === 'End') {
921
- event.preventDefault(); go(total - 1);
922
- } else if (event.key.toLowerCase() === 'f') {
923
- if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
924
- else document.exitFullscreen?.();
925
- } else if (event.key.toLowerCase() === 'g') {
926
- document.body.classList.toggle('overview');
927
- go(index);
928
- } else if (event.key.toLowerCase() === 's') {
929
- document.body.classList.toggle('notes-open');
930
- } else if (event.key.toLowerCase() === 'p') {
931
- window.print();
932
- }
933
- });
934
-
935
- let wheelLock = 0;
936
- document.addEventListener('wheel', (event) => {
937
- const now = Date.now();
938
- if (now - wheelLock < 550 || document.body.classList.contains('overview')) return;
939
- wheelLock = now;
940
- if (Math.abs(event.deltaY) > 12) go(index + (event.deltaY > 0 ? 1 : -1));
941
- }, { passive: true });
942
-
943
- let touchStartY = 0;
944
- document.addEventListener('touchstart', (event) => { touchStartY = event.touches[0].clientY; }, { passive: true });
945
- document.addEventListener('touchend', (event) => {
946
- const delta = event.changedTouches[0].clientY - touchStartY;
947
- if (Math.abs(delta) > 48) go(index + (delta < 0 ? 1 : -1));
948
- }, { passive: true });
949
-
950
- fullscreenBtn.addEventListener('click', () => {
951
- if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
952
- else document.exitFullscreen?.();
953
- });
954
-
955
- notesBtn.addEventListener('click', () => {
956
- document.body.classList.toggle('notes-open');
957
- });
958
-
959
- const start = Number.parseInt(location.hash?.replace('#slide-', ''), 10);
960
- go(Number.isInteger(start) ? start - 1 : 0);
961
- })();
962
- </script>
963
- </body>
964
- </html>`
965
- }
966
-
967
- function renderHtmlSlide(slide, index, ui, langId) {
968
- const kicker = slide.kicker || (slide.layout === 'cover' ? ui.coverKicker : '')
969
- const title = slide.title || ''
970
- const subtitle = slide.subtitle || ''
971
- const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
972
- const number = String(index + 1).padStart(2, '0')
973
- let inner = ''
974
- switch (slide.layout) {
975
- case 'cover':
976
- inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
977
- '<h1>' + escapeHtml(title) + '</h1>' +
978
- (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
979
- '<div class="meta">' + escapeHtml(ui.generatedBy) + '</div>'
980
- break
981
- case 'section':
982
- inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
983
- '<h2>' + escapeHtml(title) + '</h2>' +
984
- '<div class="accent-line"></div>' +
985
- (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
986
- break
987
- case 'statement':
988
- inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
989
- '<div class="statement-title">' + escapeHtml(title || slide.text || '') + '</div>' +
990
- (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
991
- break
992
- case 'quote':
993
- inner = '<div class="kicker">' + escapeHtml(kicker || ui.quoteKicker) + '</div>' +
994
- '<div class="quote-mark" aria-hidden="true">\u201C</div>' +
995
- '<blockquote class="quote-text">' + escapeHtml(title || slide.text || '') + '</blockquote>' +
996
- (subtitle !== '' ? '<div class="quote-attr">\u2014\u2014 ' + escapeHtml(subtitle) + '</div>' : '')
997
- break
998
- case 'table': {
999
- const rows = Array.isArray(slide.rows) ? slide.rows : []
1000
- const head = rows[0] ?? []
1001
- const body = rows.slice(1)
1002
- inner = '<div class="kicker">' + escapeHtml(kicker || ui.tableKicker) + '</div>' +
1003
- (title !== '' && title !== kicker ? '<h2>' + escapeHtml(title) + '</h2>' : '') +
1004
- '<table class="deck-table"><thead><tr>' +
1005
- head.map((cell) => '<th>' + escapeHtml(cell) + '</th>').join('') +
1006
- '</tr></thead><tbody>' +
1007
- body.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
1008
- '</tbody></table>'
1009
- break
1010
- }
1011
- case 'closing':
1012
- inner = '<h1>' + escapeHtml(title || ui.closingTitle) + '</h1>' +
1013
- (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
1014
- '<div class="meta">' + escapeHtml(ui.generatedBy) + '</div>'
1015
- break
1016
- case 'bullets':
1017
- default:
1018
- inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1019
- '<h2>' + escapeHtml(title) + '</h2>' +
1020
- '<ul>' + bullets.map((bullet, bulletIndex) => '<li style="--i:' + bulletIndex + '">' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
1021
- break
1022
- }
1023
- const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
1024
- ? ' data-notes="' + escapeHtml(slide.notes) + '"'
1025
- : ''
1026
- return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
1027
- '<div class="slide-inner">' + inner + '</div></section>'
1028
- }
1029
-
1030
- function hexToRgba(hex, alpha) {
1031
- const value = String(hex).replace('#', '')
1032
- const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value
1033
- const num = Number.parseInt(full, 16)
1034
- const r = (num >> 16) & 255
1035
- const g = (num >> 8) & 255
1036
- const b = num & 255
1037
- return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
1038
- }
1039
-
1040
- // ---------------------------------------------------------------------------
1041
- // PPTX(OOXML 手写 + node:zlib zip)
1042
- // ---------------------------------------------------------------------------
1043
-
1044
- const EMU_W = 12192000
1045
- const EMU_H = 6858000
1046
-
1047
- function hex(value) {
1048
- return String(value).replace('#', '').toUpperCase()
1049
- }
1050
-
1051
- function pptxFontName(value) {
1052
- const match = /"?([^",]+)"?/.exec(String(value ?? ''))
1053
- return match?.[1]?.trim() || 'Arial'
1054
- }
1055
-
1056
- function paragraphXml(text, options = {}) {
1057
- const {
1058
- size = 2000,
1059
- color = 'FFFFFF',
1060
- bold = false,
1061
- align = 'l',
1062
- bullet = false,
1063
- font = 'Arial',
1064
- spaceBefore = 0,
1065
- spaceAfter = 0,
1066
- } = options
1067
- const runFont = pptxFontName(font)
1068
- let pPr = ''
1069
- if (bullet) {
1070
- pPr = '<a:pPr marL="285750" indent="-285750"><a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/></a:pPr>'
1071
- } else {
1072
- const parts = [] // align 与 spacing 二选一
1073
- if (align !== 'l') parts.push('algn="' + align + '"')
1074
- if (spaceBefore > 0) parts.push('<a:spcBef><a:spcPts val="' + (spaceBefore / 100) + '"/></a:spcBef>')
1075
- if (spaceAfter > 0) parts.push('<a:spcAft><a:spcPts val="' + (spaceAfter / 100) + '"/></a:spcAft>')
1076
- pPr = '<a:pPr' + (parts.length > 0 ? ' ' + parts.join(' ') : '') + '><a:buNone/></a:pPr>'
674
+ }
675
+ if (rows.length === 0) {
676
+ // 没有表格数据的 table 页退化成要点页,避免产出空白版式
677
+ return { layout: 'bullets', kicker, title, subtitle, bullets, ...(notes !== '' ? { notes } : {}) }
678
+ }
679
+ return { layout, kicker: kicker || title, title, rows, ...(notes !== '' ? { notes } : {}) }
680
+ }
681
+ return { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }
682
+ }
683
+
684
+ function checkSlideLimit(slides, limit) {
685
+ if (slides.length > limit) {
686
+ throw new Error('dsh-ppt:生成后共 ' + slides.length + ' 页,超过 maxSlides=' + limit
687
+ + ',请提高 maxSlides(最多 120)或拆分内容 / generated ' + slides.length + ' slides; raise maxSlides or split the deck')
1077
688
  }
1078
- return '<a:p>' + pPr +
1079
- '<a:r><a:rPr lang="zh-CN" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1080
- '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1081
- '<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(runFont) + '"/></a:rPr>' +
1082
- '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p>'
1083
- }
1084
-
1085
- function textShapeXml(id, name, box, paragraphs, options = {}) {
1086
- const { x = 0, y = 0, w = EMU_W, h = EMU_H } = box
1087
- return '<p:sp>' +
1088
- '<p:nvSpPr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>' +
1089
- '<p:spPr><a:xfrm><a:off x="' + x + '" y="' + y + '"/><a:ext cx="' + w + '" cy="' + h + '"/></a:xfrm>' +
1090
- '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln/></p:spPr>' +
1091
- '<p:txBody><a:bodyPr wrap="square" rtlCol="0"><a:normAutofit/></a:bodyPr><a:lstStyle/>' +
1092
- paragraphs.join('') + '</p:txBody></p:sp>'
1093
- }
1094
-
1095
- function accentBarXml(id, name, box, color) {
1096
- const { x = 0, y = 0, w = EMU_W, h = EMU_H } = box
1097
- return '<p:sp>' +
1098
- '<p:nvSpPr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>' +
1099
- '<p:spPr><a:xfrm><a:off x="' + x + '" y="' + y + '"/><a:ext cx="' + w + '" cy="' + h + '"/></a:xfrm>' +
1100
- '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>' +
1101
- '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill><a:ln/></p:spPr>' +
1102
- '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>'
1103
- }
1104
-
1105
- /** 原生 OOXML 表格(graphicFrame + a:tbl):首行主题色表头,隔行面板色。 */
1106
- function tableGraphicXml(id, name, box, rows, theme) {
1107
- const p = theme.palette
1108
- const font = pptxFontName(theme.fonts.body)
1109
- const safeRows = rows.length > 0 ? rows : [['']]
1110
- const cols = Math.max(1, safeRows[0].length)
1111
- const colW = Math.floor(box.w / cols)
1112
- const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
1113
- const rowH = 420000
1114
- const cellXml = (text, fill, color, bold) =>
1115
- '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1116
- '<a:r><a:rPr lang="zh-CN" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1117
- '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1118
- '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1119
- '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
1120
- '<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
1121
- (fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
1122
- '</a:tcPr></a:tc>'
1123
- const trs = safeRows.map((row, rowIndex) => {
1124
- const padded = Array.from({ length: cols }, (_, i) => row[i] ?? '')
1125
- const cells = rowIndex === 0
1126
- ? padded.map((c) => cellXml(c, p.accent, p.bg, true)).join('')
1127
- : padded.map((c) => cellXml(c, rowIndex % 2 === 1 ? p.panel : '', p.fg, false)).join('')
1128
- return '<a:tr h="' + rowH + '">' + cells + '</a:tr>'
1129
- }).join('')
1130
- const height = Math.max(1, safeRows.length) * rowH
1131
- return '<p:graphicFrame>' +
1132
- '<p:nvGraphicFramePr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/>' +
1133
- '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>' +
1134
- '<p:xfrm><a:off x="' + box.x + '" y="' + box.y + '"/><a:ext cx="' + box.w + '" cy="' + height + '"/></p:xfrm>' +
1135
- '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">' +
1136
- '<a:tbl><a:tblPr firstRow="1" bandRow="1"/>' +
1137
- '<a:tblGrid>' + grid + '</a:tblGrid>' + trs + '</a:tbl>' +
1138
- '</a:graphicData></a:graphic></p:graphicFrame>'
1139
689
  }
1140
690
 
1141
- function slideShapeList(slide, index, total, theme, langId) {
1142
- const p = theme.palette
1143
- const font = pptxFontName(theme.fonts.heading)
1144
- const bodyFont = pptxFontName(theme.fonts.body) // 保留:v0.1 后续用于统一正文字体
1145
- const shapes = []
1146
- const kicker = slide.kicker || ''
1147
- const title = slide.title || ''
1148
- const subtitle = slide.subtitle || ''
1149
- const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1150
- const idBase = (index + 1) * 10
1151
- const footer = String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0')
1152
-
1153
- if (slide.layout === 'cover') {
1154
- shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 2250000, w: 240000, h: 1600000 }, p.accent))
1155
- shapes.push(textShapeXml(idBase + 2, 'Title', { x: 1550000, y: 2160000, w: 9250000, h: 1800000 }, [
1156
- paragraphXml(title, { size: 4400, color: p.fg, bold: true, font }),
1157
- ]))
1158
- if (subtitle !== '') {
1159
- shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1570000, y: 4150000, w: 9000000, h: 1200000 }, [
1160
- paragraphXml(subtitle, { size: 2200, color: p.muted, font: theme.fonts.body }),
1161
- ]))
1162
- }
1163
- if (kicker !== '') {
1164
- shapes.push(textShapeXml(idBase + 4, 'Kicker', { x: 1570000, y: 5800000, w: 7000000, h: 500000 }, [
1165
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1166
- ]))
1167
- }
1168
- } else if (slide.layout === 'section') {
1169
- if (kicker !== '') {
1170
- shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 1050000, y: 2250000, w: 9000000, h: 500000 }, [
1171
- paragraphXml(kicker, { size: 1600, color: p.accent, bold: true, font: theme.fonts.body }),
1172
- ]))
1173
- }
1174
- shapes.push(accentBarXml(idBase + 2, 'Accent bar', { x: 1050000, y: 2850000, w: 1600000, h: 160000 }, p.accent2))
1175
- shapes.push(textShapeXml(idBase + 3, 'Title', { x: 1050000, y: 3150000, w: 10200000, h: 1400000 }, [
1176
- paragraphXml(title, { size: 4000, color: p.fg, bold: true, font }),
1177
- ]))
1178
- if (subtitle !== '') {
1179
- shapes.push(textShapeXml(idBase + 4, 'Subtitle', { x: 1070000, y: 4750000, w: 9000000, h: 900000 }, [
1180
- paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1181
- ]))
1182
- }
1183
- } else if (slide.layout === 'statement') {
1184
- shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 1900000, w: 200000, h: 2800000 }, p.accent))
1185
- shapes.push(textShapeXml(idBase + 2, 'Statement', { x: 1500000, y: 1950000, w: 9400000, h: 2700000 }, [
1186
- paragraphXml(title || slide.text || '', { size: 3600, color: p.fg, bold: true, font }),
1187
- ]))
1188
- if (subtitle !== '') {
1189
- shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1520000, y: 4900000, w: 9000000, h: 800000 }, [
1190
- paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1191
- ]))
1192
- }
1193
- } else if (slide.layout === 'quote') {
1194
- shapes.push(textShapeXml(idBase + 1, 'Quote mark', { x: 900000, y: 1050000, w: 1700000, h: 1900000 }, [
1195
- paragraphXml('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1196
- ]))
1197
- shapes.push(textShapeXml(idBase + 2, 'Quote', { x: 1050000, y: 2550000, w: 10100000, h: 2600000 }, [
1198
- paragraphXml(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1199
- ]))
1200
- if (subtitle !== '') {
1201
- shapes.push(textShapeXml(idBase + 3, 'Attribution', { x: 1070000, y: 5300000, w: 9000000, h: 600000 }, [
1202
- paragraphXml('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1203
- ]))
1204
- }
1205
- } else if (slide.layout === 'table') {
1206
- const hasTitle = title !== '' && title !== kicker
1207
- if (kicker !== '') {
1208
- shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1209
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1210
- ]))
1211
- }
1212
- if (hasTitle) {
1213
- shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1214
- paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1215
- ]))
1216
- }
1217
- shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme))
1218
- } else if (slide.layout === 'closing') {
1219
- shapes.push(textShapeXml(idBase + 1, 'Title', { x: 1050000, y: 2300000, w: 10200000, h: 1700000 }, [
1220
- paragraphXml(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
1221
- ]))
1222
- if (subtitle !== '') {
1223
- shapes.push(textShapeXml(idBase + 2, 'Subtitle', { x: 1050000, y: 4200000, w: 10200000, h: 900000 }, [
1224
- paragraphXml(subtitle, { size: 2000, color: p.accent, bold: true, align: 'ctr', font: theme.fonts.body }),
1225
- ]))
1226
- }
1227
- } else {
1228
- // bullets(默认布局)
1229
- if (kicker !== '') {
1230
- shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1231
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1232
- ]))
1233
- }
1234
- shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1235
- paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1236
- ]))
1237
- const bodyParagraphs = bullets.length > 0
1238
- ? bullets.map((bullet) => paragraphXml(bullet, { size: 2000, color: p.fg, bullet: true, font: theme.fonts.body, spaceAfter: 600 }))
1239
- : [paragraphXml(subtitle || '', { size: 2000, color: p.fg, font: theme.fonts.body })]
1240
- shapes.push(textShapeXml(idBase + 3, 'Body', { x: 1050000, y: 1850000, w: 10100000, h: 4500000 }, bodyParagraphs))
1241
- }
1242
-
1243
- shapes.push(textShapeXml(idBase + 9, 'Page number', { x: 10600000, y: 6250000, w: 1200000, h: 400000 }, [
1244
- paragraphXml(footer, { size: 1000, color: p.muted, align: 'r', font: theme.fonts.body }),
1245
- ]))
1246
-
1247
- return shapes.join('')
1248
- }
1249
-
1250
- /**
1251
- * 原生逐条点击显现动画(PowerPoint「出现,按段落」的标准时序树):
1252
- * bldP 把正文文本框标记为按段落构建,主序列里每条要点一个点击节点。
1253
- */
1254
- function bulletTimingXml(spid, clicks) {
1255
- let id = 2
1256
- const nextId = () => { id += 1; return String(id) }
1257
- const clickPars = []
1258
- for (let i = 0; i < clicks; i += 1) {
1259
- clickPars.push(
1260
- '<p:par><p:cTn id="' + nextId() + '" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>' +
1261
- '<p:par><p:cTn id="' + nextId() + '" presetID="1" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="clickEffect">' +
1262
- '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>' +
1263
- '<p:set><p:cBhvr><p:cTn id="' + nextId() + '" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>' +
1264
- '<p:tgtEl><p:spTgt spid="' + spid + '"/></p:tgtEl>' +
1265
- '<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr>' +
1266
- '<p:to><p:strVal val="visible"/></p:to></p:set>' +
1267
- '</p:childTnLst></p:cTn></p:par>' +
1268
- '</p:childTnLst></p:cTn></p:par>',
1269
- )
1270
- }
1271
- return '<p:timing><p:tnLst><p:par>' +
1272
- '<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>' +
1273
- '<p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>' +
1274
- clickPars.join('') +
1275
- '</p:childTnLst></p:cTn>' +
1276
- '<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>' +
1277
- '<p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>' +
1278
- '</p:seq></p:childTnLst></p:cTn></p:par></p:tnLst>' +
1279
- '<p:bldLst><p:bldP spid="' + spid + '" grpId="0"/></p:bldLst></p:timing>'
1280
- }
1281
-
1282
- function slideXml(slide, index, total, theme, langId, motion) {
1283
- const p = theme.palette
1284
- let motionXml = ''
1285
- if (motion) {
1286
- motionXml = '<p:transition spd="med"><p:fade/></p:transition>'
1287
- const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1288
- if (slide.layout === 'bullets' && bullets.length >= 2) {
1289
- motionXml += bulletTimingXml((index + 1) * 10 + 3, bullets.length)
1290
- }
1291
- }
1292
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1293
- '<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1294
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1295
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1296
- '<p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="' + hex(p.bg) + '"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>' +
1297
- '<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1298
- '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1299
- slideShapeList(slide, index, total, theme, langId) +
1300
- '</p:spTree></p:cSld>' +
1301
- '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1302
- motionXml +
1303
- '</p:sld>'
1304
- }
1305
-
1306
- function slideRelXml(hasNotes, slideIndex) {
1307
- const notesRel = hasNotes
1308
- ? '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide' + slideIndex + '.xml"/>'
1309
- : ''
1310
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1311
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1312
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1313
- notesRel +
1314
- '</Relationships>'
1315
- }
1316
-
1317
- function presentationXml(slideCount, hasNotesMaster) {
1318
- const slideIds = Array.from({ length: slideCount }, (_, i) =>
1319
- '<p:sldId id="' + (256 + i) + '" r:id="rId' + (i + 2) + '"/>').join('')
1320
- const notesMasterId = hasNotesMaster
1321
- ? '<p:notesMasterIdLst><p:notesMasterId id="2147483649" r:id="rId' + (slideCount + 2) + '"/></p:notesMasterIdLst>'
1322
- : ''
1323
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1324
- '<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1325
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1326
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1327
- '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>' +
1328
- '<p:sldIdLst>' + slideIds + '</p:sldIdLst>' +
1329
- notesMasterId +
1330
- '<p:sldSz cx="' + EMU_W + '" cy="' + EMU_H + '" type="screen16x9"/>' +
1331
- '<p:notesSz cx="6858000" cy="9144000"/>' +
1332
- '</p:presentation>'
1333
- }
1334
-
1335
- function presentationRelXml(slideCount, hasNotesMaster) {
1336
- const slideRels = Array.from({ length: slideCount }, (_, i) =>
1337
- '<Relationship Id="rId' + (i + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide' + (i + 1) + '.xml"/>').join('')
1338
- const notesMasterRel = hasNotesMaster
1339
- ? '<Relationship Id="rId' + (slideCount + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>'
1340
- : ''
1341
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1342
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1343
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>' +
1344
- slideRels +
1345
- notesMasterRel +
1346
- '</Relationships>'
1347
- }
1348
-
1349
- function notesMasterXml() {
1350
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1351
- '<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1352
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1353
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1354
- '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1355
- '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1356
- '</p:spTree></p:cSld>' +
1357
- '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' +
1358
- '<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle>' +
1359
- '</p:notesMaster>'
1360
- }
1361
-
1362
- function notesMasterRelXml() {
1363
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1364
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1365
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1366
- '</Relationships>'
1367
- }
1368
-
1369
- function notesSlideXml(notes) {
1370
- const paragraphs = String(notes ?? '').split('\n')
1371
- .filter((line) => line.trim() !== '')
1372
- .map((line) => '<a:p><a:r><a:rPr lang="zh-CN" sz="1200" dirty="0"/><a:t>' + escapeXml(line) + '</a:t></a:r></a:p>')
1373
- .join('')
1374
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1375
- '<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1376
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1377
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1378
- '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1379
- '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1380
- '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr/><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp>' +
1381
- '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/>' +
1382
- '<p:txBody><a:bodyPr/><a:lstStyle/>' + paragraphs + '</p:txBody></p:sp>' +
1383
- '</p:spTree></p:cSld>' +
1384
- '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1385
- '</p:notes>'
1386
- }
1387
-
1388
- function notesSlideRelXml(slideIndex) {
1389
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1390
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1391
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide' + slideIndex + '.xml"/>' +
1392
- '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>' +
1393
- '</Relationships>'
1394
- }
1395
-
1396
- function slideMasterXml(theme) {
1397
- const p = theme.palette
1398
- const levels = Array.from({ length: 9 }, (_, i) => {
1399
- const sz = Math.max(1100, 2000 - i * 100)
1400
- return '<a:lvl' + (i + 1) + 'pPr marL="' + (342900 + i * 342900) + '" indent="' + (-342900 - i * 0) + '">' +
1401
- '<a:defRPr sz="' + sz + '"><a:solidFill><a:srgbClr val="' + hex(p.fg) + '"/></a:solidFill><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/></a:defRPr></a:lvl' + (i + 1) + 'pPr>'
1402
- }).join('')
1403
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1404
- '<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1405
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1406
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1407
- '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1408
- '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1409
- '</p:spTree></p:cSld>' +
1410
- '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' +
1411
- '<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>' +
1412
- '<p:txStyles><p:titleStyle>' + levels + '</p:titleStyle><p:bodyStyle>' + levels + '</p:bodyStyle><p:otherStyle>' + levels + '</p:otherStyle></p:txStyles>' +
1413
- '</p:sldMaster>'
1414
- }
1415
-
1416
- function slideMasterRelXml() {
1417
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1418
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1419
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1420
- '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1421
- '</Relationships>'
1422
- }
1423
-
1424
- function slideLayoutXml(theme) {
1425
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1426
- '<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1427
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1428
- 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">' +
1429
- '<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1430
- '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1431
- '</p:spTree></p:cSld>' +
1432
- '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1433
- '</p:sldLayout>'
1434
- }
1435
-
1436
- function slideLayoutRelXml() {
1437
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1438
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1439
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>' +
1440
- '</Relationships>'
1441
- }
1442
-
1443
- function themeXml(theme) {
1444
- const p = theme.palette
1445
- const color = (name, value) => '<a:' + name + '><a:srgbClr val="' + hex(value) + '"/></a:' + name + '>'
1446
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1447
- '<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="dsh-ppt ' + escapeXml(theme.name.en) + '">' +
1448
- '<a:themeElements>' +
1449
- '<a:clrScheme name="dsh-ppt">' +
1450
- color('dk1', p.fg) + color('lt1', p.bg) + color('dk2', p.muted) + color('lt2', p.panel) +
1451
- color('accent1', p.accent) + color('accent2', p.accent2) + color('accent3', p.fg) +
1452
- color('accent4', p.muted) + color('accent5', p.accent) + color('accent6', p.accent2) +
1453
- color('hlink', p.accent) + color('folHlink', p.accent2) +
1454
- '</a:clrScheme>' +
1455
- '<a:fontScheme name="dsh-ppt">' +
1456
- '<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>' +
1457
- '<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>' +
1458
- '</a:fontScheme>' +
1459
- '<a:fmtScheme name="dsh-ppt">' +
1460
- '<a:fillStyleLst>' +
1461
- '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1462
- '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1463
- '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1464
- '</a:fillStyleLst>' +
1465
- '<a:lnStyleLst><a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst>' +
1466
- '<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>' +
1467
- '<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>' +
1468
- '</a:fmtScheme>' +
1469
- '</a:themeElements>' +
1470
- '<a:objectDefaults/><a:extraClrSchemeLst/>' +
1471
- '</a:theme>'
1472
- }
1473
-
1474
- function contentTypesXml(slideCount, noteIndexes) {
1475
- const overrides = Array.from({ length: slideCount }, (_, i) =>
1476
- '<Override PartName="/ppt/slides/slide' + (i + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>').join('')
1477
- const notesOverrides = noteIndexes.map((index) =>
1478
- '<Override PartName="/ppt/notesSlides/notesSlide' + (index + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>').join('')
1479
- const notesMasterOverride = noteIndexes.length > 0
1480
- ? '<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
1481
- : ''
1482
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1483
- '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
1484
- '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
1485
- '<Default Extension="xml" ContentType="application/xml"/>' +
1486
- '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>' +
1487
- '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>' +
1488
- '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>' +
1489
- '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>' +
1490
- '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
1491
- '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
1492
- notesMasterOverride +
1493
- overrides +
1494
- notesOverrides +
1495
- '</Types>'
1496
- }
1497
-
1498
- function rootRelXml() {
1499
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1500
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1501
- '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>' +
1502
- '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
1503
- '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' +
1504
- '</Relationships>'
1505
- }
1506
-
1507
- function coreXml(title) {
1508
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1509
- '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' +
1510
- 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' +
1511
- 'xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
1512
- '<dc:title>' + escapeXml(title) + '</dc:title><dc:creator>dsh-ppt</dc:creator>' +
1513
- '<cp:lastModifiedBy>dsh-ppt</cp:lastModifiedBy>' +
1514
- '</cp:coreProperties>'
1515
- }
1516
-
1517
- function appXml(slideCount, notesCount) {
1518
- return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1519
- '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' +
1520
- 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
1521
- '<Application>dsh-ppt</Application><PresentationFormat>Widescreen</PresentationFormat>' +
1522
- '<Slides>' + slideCount + '</Slides><Notes>' + notesCount + '</Notes><HiddenSlides>0</HiddenSlides>' +
1523
- '</Properties>'
1524
- }
1525
-
1526
- function buildZip(entries) {
1527
- const chunks = []
1528
- const central = []
1529
- let offset = 0
1530
-
1531
- for (const entry of entries) {
1532
- const entryOffset = offset
1533
- const name = Buffer.from(entry.name, 'utf8')
1534
- const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), 'utf8')
1535
- const crc = crc32(data)
1536
- const compressed = deflateRawSync(data)
1537
- const method = 8
1538
- const nameLength = name.length
1539
- const localHeader = Buffer.alloc(30)
1540
- localHeader.writeUInt32LE(0x04034b50, 0)
1541
- localHeader.writeUInt16LE(20, 4)
1542
- localHeader.writeUInt16LE(0x0800, 6) // UTF-8 文件名
1543
- localHeader.writeUInt16LE(method, 8)
1544
- localHeader.writeUInt16LE(0, 10) // DOS time
1545
- localHeader.writeUInt16LE(0x21, 12) // DOS date 1980-01-01
1546
- localHeader.writeUInt32LE(crc, 14)
1547
- localHeader.writeUInt32LE(compressed.length, 18)
1548
- localHeader.writeUInt32LE(data.length, 22)
1549
- localHeader.writeUInt16LE(nameLength, 26)
1550
- localHeader.writeUInt16LE(0, 28)
1551
-
1552
- chunks.push(localHeader, name, compressed)
1553
- offset += 30 + nameLength + compressed.length
1554
-
1555
- const centralHeader = Buffer.alloc(46)
1556
- centralHeader.writeUInt32LE(0x02014b50, 0)
1557
- centralHeader.writeUInt16LE(20, 4)
1558
- centralHeader.writeUInt16LE(20, 6)
1559
- centralHeader.writeUInt16LE(0x0800, 8)
1560
- centralHeader.writeUInt16LE(method, 10)
1561
- centralHeader.writeUInt16LE(0, 12)
1562
- centralHeader.writeUInt16LE(0x21, 14)
1563
- centralHeader.writeUInt32LE(crc, 16)
1564
- centralHeader.writeUInt32LE(compressed.length, 20)
1565
- centralHeader.writeUInt32LE(data.length, 24)
1566
- centralHeader.writeUInt16LE(nameLength, 28)
1567
- centralHeader.writeUInt16LE(0, 30)
1568
- centralHeader.writeUInt16LE(0, 32)
1569
- centralHeader.writeUInt16LE(0, 34)
1570
- centralHeader.writeUInt16LE(0, 36)
1571
- centralHeader.writeUInt32LE(0, 38)
1572
- centralHeader.writeUInt32LE(entryOffset, 42)
1573
- central.push(centralHeader, name)
1574
- }
1575
-
1576
- const centralOffset = offset
1577
- const centralSize = central.reduce((sum, part) => sum + part.length, 0)
1578
- const end = Buffer.alloc(22)
1579
- end.writeUInt32LE(0x06054b50, 0)
1580
- end.writeUInt16LE(0, 4)
1581
- end.writeUInt16LE(0, 6)
1582
- end.writeUInt16LE(entries.length, 8)
1583
- end.writeUInt16LE(entries.length, 10)
1584
- end.writeUInt32LE(centralSize, 12)
1585
- end.writeUInt32LE(centralOffset, 16)
1586
- end.writeUInt16LE(0, 20)
1587
-
1588
- chunks.push(...central, end)
1589
- return Buffer.concat(chunks)
1590
- }
1591
-
1592
- let CRC_TABLE = null
1593
- function crc32(buffer) {
1594
- if (CRC_TABLE === null) {
1595
- CRC_TABLE = new Int32Array(256)
1596
- for (let n = 0; n < 256; n += 1) {
1597
- let c = n
1598
- for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
1599
- CRC_TABLE[n] = c
1600
- }
1601
- }
1602
- let crc = 0xffffffff
1603
- for (let i = 0; i < buffer.length; i += 1) {
1604
- crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8)
1605
- }
1606
- return (crc ^ 0xffffffff) >>> 0
1607
- }
1608
-
1609
- export function buildPptx(manifest, themeInput, languageInput) {
1610
- const theme = themeInput?.id ? themeInput : resolveTheme(themeInput)
1611
- const language = languageInput?.id ? languageInput : resolveLanguage(languageInput)
1612
- const motion = manifest.motion !== false
1613
- const slides = manifest.slides
1614
- const noteIndexes = []
1615
- slides.forEach((slide, index) => {
1616
- if (typeof slide.notes === 'string' && slide.notes.trim() !== '') noteIndexes.push(index)
1617
- })
1618
- const hasNotesMaster = noteIndexes.length > 0
1619
- const entries = [
1620
- { name: '[Content_Types].xml', data: contentTypesXml(slides.length, noteIndexes) },
1621
- { name: '_rels/.rels', data: rootRelXml() },
1622
- { name: 'docProps/app.xml', data: appXml(slides.length, noteIndexes.length) },
1623
- { name: 'docProps/core.xml', data: coreXml(manifest.title) },
1624
- { name: 'ppt/presentation.xml', data: presentationXml(slides.length, hasNotesMaster) },
1625
- { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length, hasNotesMaster) },
1626
- { name: 'ppt/slideMasters/slideMaster1.xml', data: slideMasterXml(theme) },
1627
- { name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: slideMasterRelXml() },
1628
- { name: 'ppt/slideLayouts/slideLayout1.xml', data: slideLayoutXml(theme) },
1629
- { name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: slideLayoutRelXml() },
1630
- { name: 'ppt/theme/theme1.xml', data: themeXml(theme) },
1631
- ]
1632
- if (hasNotesMaster) {
1633
- entries.push({ name: 'ppt/notesMasters/notesMaster1.xml', data: notesMasterXml() })
1634
- entries.push({ name: 'ppt/notesMasters/_rels/notesMaster1.xml.rels', data: notesMasterRelXml() })
1635
- }
1636
- slides.forEach((slide, index) => {
1637
- const hasNotes = noteIndexes.includes(index)
1638
- entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id, motion) })
1639
- entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1640
- if (hasNotes) {
1641
- entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes) })
1642
- entries.push({ name: 'ppt/notesSlides/_rels/notesSlide' + (index + 1) + '.xml.rels', data: notesSlideRelXml(index + 1) })
1643
- }
1644
- })
1645
- return buildZip(entries)
1646
- }
691
+ export function normalizeSlides(rawSlides, maxSlides = 60, lang = 'zh') {
692
+ if (!Array.isArray(rawSlides) || rawSlides.length === 0) {
693
+ throw new Error('dsh-ppt:slides 必须是非空数组(每个元素是 { layout, title, subtitle, kicker, bullets } 对象)')
694
+ }
695
+ const limit = clampInt(maxSlides, 60, 1, 120)
696
+ const ui = resolveLanguage(lang).ui
697
+ const slides = rawSlides.map(normalizeSlide).flatMap(slide => slide.layout === 'table'
698
+ ? paginateTable(slide, ui.continuation) : [slide])
699
+ checkSlideLimit(slides, limit)
700
+ if (slides.length === 0) throw new Error('dsh-ppt:slides 规范化后为空')
701
+ return slides
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // 构建入口
706
+ // ---------------------------------------------------------------------------
707
+
708
+ export function normalizeBuildOptions(options = {}) {
709
+ const title = String(options.title ?? '').trim()
710
+ if (title === '') throw new Error('dsh-ppt:title 不能为空')
711
+ const theme = resolveTheme(options.theme)
712
+ const language = resolveLanguage(options.lang)
713
+ const motion = resolveMotion(options.motion)
714
+ const maxSlides = clampInt(options.maxSlides, 60, 3, 120)
715
+ let deck
716
+ if (Array.isArray(options.slides) && options.slides.length > 0) {
717
+ deck = {
718
+ title,
719
+ subtitle: stripInlineMarkdown(options.subtitle ?? ''),
720
+ slides: normalizeSlides(options.slides, maxSlides, language.id),
721
+ }
722
+ } else {
723
+ const content = String(options.content ?? '').trim()
724
+ if (content === '') throw new Error('dsh-ppt:content 不能为空(或用 slides 传结构化幻灯片)')
725
+ deck = parseMarkdownDeck(title, content, language.id)
726
+ checkSlideLimit(deck.slides, maxSlides)
727
+ }
728
+ if (deck.slides.length < 1) throw new Error('dsh-ppt:没有可生成的幻灯片')
729
+ const outputDir = resolvePath(String(options.outputDir ?? '.').trim() || '.')
730
+ const fileName = sanitizeFileName(options.fileName ?? title)
731
+ const overwrite = options.overwrite === true
732
+ return { title, theme, language, motion, deck, outputDir, fileName, overwrite }
733
+ }
734
+
735
+ /**
736
+ * 三件套落盘:三份内容全部先写同目录临时文件,再逐个 rename 提交。
737
+ * 任一步失败都清理临时文件,并把已提交的目标恢复为调用前的内容,
738
+ * 避免出现「json/html 已更新、pptx 还是旧的」这类混版状态。
739
+ */
740
+ function commitDeckArtifacts(artifacts) {
741
+ const stamp = process.pid.toString(36) + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
742
+ const items = artifacts.map((item, index) => ({ ...item, tempPath: item.path + '.' + stamp + '-' + index + '.tmp' }))
743
+ const removeTemp = (item) => {
744
+ try {
745
+ rmSync(item.tempPath, { force: true })
746
+ } catch {}
747
+ }
748
+
749
+ // 阶段 0:记下已有产物内容用于回滚;读不出来(如被同名目录占用)先报错,不碰任何文件。
750
+ const previous = new Map()
751
+ for (const item of items) {
752
+ if (!existsSync(item.path)) continue
753
+ try {
754
+ previous.set(item.path, readFileSync(item.path))
755
+ } catch (error) {
756
+ throw new Error('dsh-ppt:三件套写入失败(' + item.path + ' 已存在但无法读取),原文件未改动:' + error.message)
757
+ }
758
+ }
759
+
760
+ // 阶段 1:全部写临时文件;这一步失败时目标文件尚未被改动。
761
+ let written = 0
762
+ try {
763
+ for (const item of items) {
764
+ writeFileSync(item.tempPath, item.data, item.encoding)
765
+ written += 1
766
+ }
767
+ } catch (error) {
768
+ for (const item of items.slice(0, written + 1)) removeTemp(item)
769
+ throw new Error('dsh-ppt:写入临时文件失败(' + items[written].path + '),原三件套未改动:' + error.message)
770
+ }
771
+
772
+ // 阶段 2:逐个 rename 提交;中途失败则把已提交的目标回滚成旧内容。
773
+ const committed = []
774
+ try {
775
+ for (const item of items) {
776
+ renameSync(item.tempPath, item.path)
777
+ committed.push(item)
778
+ }
779
+ } catch (error) {
780
+ const failedPath = items[committed.length].path
781
+ for (const item of items) removeTemp(item)
782
+ for (const item of committed) {
783
+ try {
784
+ const previousData = previous.get(item.path)
785
+ if (previousData === undefined) rmSync(item.path, { force: true })
786
+ else writeFileSync(item.path, previousData)
787
+ } catch {}
788
+ }
789
+ throw new Error('dsh-ppt:提交三件套失败(' + failedPath + '),已回滚为原文件:' + error.message)
790
+ }
791
+ }
792
+
793
+ export function buildDeck(options = {}) {
794
+ const normalized = normalizeBuildOptions(options)
795
+ const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
796
+ mkdirSync(outputDir, { recursive: true })
797
+ const fileName = resolveDeckFileName(outputDir, normalized.fileName, overwrite)
798
+
799
+ const manifest = {
800
+ version: DECK_VERSION,
801
+ title,
802
+ theme: theme.id,
803
+ language: language.id,
804
+ motion,
805
+ slideCount: deck.slides.length,
806
+ slides: deck.slides,
807
+ }
808
+ const jsonPath = resolvePath(outputDir, fileName + '.json')
809
+ const htmlPath = resolvePath(outputDir, fileName + '.html')
810
+ const pptxPath = resolvePath(outputDir, fileName + '.pptx')
811
+
812
+ // 先生成三份内容再统一提交:生成阶段抛错时也不会留下半套文件。
813
+ commitDeckArtifacts([
814
+ { path: jsonPath, data: JSON.stringify(manifest, null, 2) + '\n', encoding: 'utf8' },
815
+ { path: htmlPath, data: renderHtml(manifest, theme, language), encoding: 'utf8' },
816
+ { path: pptxPath, data: buildPptx(manifest, theme, language) },
817
+ ])
818
+
819
+ return {
820
+ ok: true,
821
+ title,
822
+ theme: theme.id,
823
+ language: language.id,
824
+ slideCount: deck.slides.length,
825
+ outputDir,
826
+ files: {
827
+ html: htmlPath,
828
+ pptx: pptxPath,
829
+ json: jsonPath,
830
+ },
831
+ htmlPath,
832
+ pptxPath,
833
+ jsonPath,
834
+ }
835
+ }
836
+
837
+ // ---------------------------------------------------------------------------
838
+ // HTML 网页放映
839
+ // ---------------------------------------------------------------------------
840
+
841
+ export function renderHtml(manifest, theme, language) {
842
+ const t = theme
843
+ const lang = language
844
+ const ui = lang.ui
845
+ const motion = manifest.motion !== false
846
+ const motionCss = motion
847
+ ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
848
+ @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
849
+ body.motion .bullets li{
850
+ opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
851
+ animation-delay:calc(var(--i, 0)*90ms + .12s);
852
+ }
853
+ @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
854
+ : ''
855
+ const slides = manifest.slides.map((slide, index) => renderHtmlSlide(slide, index, ui, lang.id)).join('\n')
856
+ const themeLabel = t.name[lang.id] ?? t.name.en
857
+ const total = manifest.slides.length
858
+
859
+ return `<!DOCTYPE html>
860
+ <html lang="${lang.attr}">
861
+ <head>
862
+ <meta charset="UTF-8">
863
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
864
+ <title>${escapeHtml(manifest.title)}</title>
865
+ <style>
866
+ :root{
867
+ --bg:${t.palette.bg};
868
+ --panel:${t.palette.panel};
869
+ --fg:${t.palette.fg};
870
+ --muted:${t.palette.muted};
871
+ --accent:${t.palette.accent};
872
+ --accent2:${t.palette.accent2};
873
+ --font-heading:${t.fonts.heading};
874
+ --font-body:${t.fonts.body};
875
+ }
876
+ *{box-sizing:border-box}
877
+ html,body{height:100%}
878
+ body{
879
+ margin:0;background:var(--bg);color:var(--fg);
880
+ font-family:var(--font-body);overflow:hidden;
881
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
882
+ }
883
+ .slide{
884
+ position:fixed;inset:0;display:none;flex-direction:column;justify-content:center;
885
+ padding:clamp(34px,7vw,110px);overflow:hidden;
886
+ }
887
+ .slide::before{
888
+ content:"";position:absolute;inset:-20%;pointer-events:none;z-index:-1;
889
+ background:
890
+ radial-gradient(42% 34% at 82% 18%, ${hexToRgba(t.palette.accent, t.dark ? 0.22 : 0.12)}, transparent 70%),
891
+ radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
892
+ radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
893
+ }
894
+ .slide.is-active{display:flex}
895
+ ${motionCss}
896
+ .kicker{
897
+ color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
898
+ font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
899
+ }
900
+ h1,h2,.statement-title{font-family:var(--font-heading);line-height:1.06;letter-spacing:-.015em;margin:0}
901
+ h1{font-size:clamp(44px,7.4vw,118px);max-width:20ch}
902
+ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
903
+ .subtitle{
904
+ color:var(--muted);font-size:clamp(18px,2.3vw,34px);line-height:1.5;
905
+ max-width:46em;margin-top:28px;
906
+ }
907
+ .meta{
908
+ color:var(--muted);font-size:clamp(12px,1.2vw,16px);margin-top:48px;
909
+ letter-spacing:.06em;
910
+ }
911
+ .bullets ul{margin:30px 0 0;padding:0;list-style:none;display:grid;gap:clamp(12px,1.6vw,24px)}
912
+ .bullets li{
913
+ position:relative;padding-left:clamp(28px,2.6vw,44px);
914
+ font-size:clamp(20px,2.6vw,40px);line-height:1.35;max-width:24em;
915
+ }
916
+ .bullets li::before{
917
+ content:"";position:absolute;left:0;top:.58em;width:.5em;height:.5em;
918
+ background:var(--accent);border-radius:2px;box-shadow:.28em .28em 0 color-mix(in srgb, var(--accent2) 78%, transparent);
919
+ }
920
+ .section .kicker{margin-bottom:10px}
921
+ .section .accent-line{width:min(180px,18vw);height:6px;background:var(--accent);margin:28px 0}
922
+ .statement-title{
923
+ font-size:clamp(34px,5.4vw,88px);max-width:22ch;font-weight:800;
924
+ border-left:6px solid var(--accent);padding-left:clamp(22px,3vw,48px);
925
+ }
926
+ .closing{text-align:center;align-items:center}
927
+ .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
928
+ .closing .subtitle{color:var(--accent);font-weight:700}
929
+ .cover h1{font-weight:900}
930
+ .quote-mark{
931
+ font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
932
+ color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
933
+ }
934
+ .quote-text{
935
+ font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
936
+ font-weight:700;max-width:22ch;margin:0;
937
+ }
938
+ .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
939
+ table.deck-table{
940
+ border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:1120px;
941
+ font-size:clamp(13px,1.55vw,23px);
942
+ }
943
+ .deck-table th{
944
+ background:var(--accent);color:var(--bg);text-align:left;font-weight:700;
945
+ padding:.6em .9em;letter-spacing:.02em;
946
+ }
947
+ .deck-table td{
948
+ padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 34%, transparent);
949
+ color:var(--fg);
950
+ }
951
+ .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
952
+ #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
953
+ #hud{
954
+ position:fixed;right:22px;bottom:18px;z-index:30;display:flex;gap:14px;align-items:center;
955
+ color:var(--muted);font-size:13px;letter-spacing:.08em;font-variant-numeric:tabular-nums;
956
+ }
957
+ #hud button{
958
+ background:color-mix(in srgb, var(--panel) 88%, transparent);color:var(--fg);
959
+ border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);border-radius:99px;
960
+ padding:7px 13px;font:inherit;cursor:pointer;
961
+ }
962
+ #hud button:hover{border-color:var(--accent);color:var(--accent)}
963
+ #notes-panel{
964
+ position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;
965
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);
966
+ border-top:2px solid var(--accent);
967
+ padding:16px clamp(22px,4vw,64px) 20px;max-height:38vh;overflow:auto;
968
+ }
969
+ body.notes-open #notes-panel.has-notes{display:block}
970
+ #notes-panel .notes-label{
971
+ color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
972
+ font-size:12px;margin-bottom:8px;
973
+ }
974
+ #notes-text{white-space:pre-wrap;line-height:1.65;font-size:15px;color:var(--fg)}
975
+ body.overview .slide{display:flex !important;position:relative;inset:auto;width:100%;height:100vh}
976
+ body.overview{overflow:auto}
977
+ body.overview #progress,body.overview #hud{position:fixed}
978
+ body.overview #notes-panel{display:none !important}
979
+ @media (max-width:640px){
980
+ #hud{right:12px;bottom:10px;gap:8px;font-size:11px}
981
+ }
982
+ @media print{
983
+ html,body{height:auto;overflow:visible;background:#fff}
984
+ .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
985
+ ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
986
+ #progress,#hud,#notes-panel{display:none !important}
987
+ }
988
+ </style>
989
+ </head>
990
+ <body class="${motion ? 'motion' : 'no-motion'}">
991
+ <div id="progress" aria-hidden="true"></div>
992
+ ${slides}
993
+ <div id="hud" aria-live="polite">
994
+ <span id="counter">${ui.slide} 1 ${ui.of} ${total}</span>
995
+ <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
996
+ <button id="notes-toggle" title="S">${escapeHtml(ui.notesToggle)}</button>
997
+ <button id="fullscreen" title="F">⛶</button>
998
+ </div>
999
+ <div id="notes-panel" aria-live="polite">
1000
+ <div class="notes-label">${escapeHtml(ui.notesLabel)}</div>
1001
+ <div id="notes-text"></div>
1002
+ </div>
1003
+ <script>
1004
+ (() => {
1005
+ const slides = Array.from(document.querySelectorAll('.slide'));
1006
+ const counter = document.getElementById('counter');
1007
+ const progress = document.getElementById('progress');
1008
+ const fullscreenBtn = document.getElementById('fullscreen');
1009
+ const notesPanel = document.getElementById('notes-panel');
1010
+ const notesText = document.getElementById('notes-text');
1011
+ const notesBtn = document.getElementById('notes-toggle');
1012
+ let index = 0;
1013
+ const total = slides.length;
1014
+ const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
1015
+
1016
+ function refreshNotes() {
1017
+ const notes = slides[index]?.dataset.notes || '';
1018
+ notesPanel.classList.toggle('has-notes', notes !== '');
1019
+ notesText.textContent = notes;
1020
+ }
1021
+
1022
+ function go(next) {
1023
+ index = (next + total) % total;
1024
+ slides.forEach((slide, i) => slide.classList.toggle('is-active', i === index));
1025
+ counter.textContent = ui.slide + ' ' + (index + 1) + ' ' + ui.of + ' ' + total;
1026
+ progress.style.width = ((index + 1) / total * 100) + '%';
1027
+ refreshNotes();
1028
+ document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
1029
+ try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch { /* file:// 下个别浏览器可能拒绝 */ }
1030
+ }
1031
+
1032
+ document.addEventListener('keydown', (event) => {
1033
+ if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {
1034
+ event.preventDefault(); go(index + 1);
1035
+ } else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
1036
+ event.preventDefault(); go(index - 1);
1037
+ } else if (event.key === 'Home') {
1038
+ event.preventDefault(); go(0);
1039
+ } else if (event.key === 'End') {
1040
+ event.preventDefault(); go(total - 1);
1041
+ } else if (event.key.toLowerCase() === 'f') {
1042
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
1043
+ else document.exitFullscreen?.();
1044
+ } else if (event.key.toLowerCase() === 'g') {
1045
+ document.body.classList.toggle('overview');
1046
+ go(index);
1047
+ } else if (event.key.toLowerCase() === 's') {
1048
+ document.body.classList.toggle('notes-open');
1049
+ } else if (event.key.toLowerCase() === 'p') {
1050
+ window.print();
1051
+ }
1052
+ });
1053
+
1054
+ let wheelLock = 0;
1055
+ document.addEventListener('wheel', (event) => {
1056
+ const now = Date.now();
1057
+ if (now - wheelLock < 550 || document.body.classList.contains('overview')) return;
1058
+ wheelLock = now;
1059
+ if (Math.abs(event.deltaY) > 12) go(index + (event.deltaY > 0 ? 1 : -1));
1060
+ }, { passive: true });
1061
+
1062
+ let touchStartY = 0;
1063
+ document.addEventListener('touchstart', (event) => { touchStartY = event.touches[0].clientY; }, { passive: true });
1064
+ document.addEventListener('touchend', (event) => {
1065
+ const delta = event.changedTouches[0].clientY - touchStartY;
1066
+ if (Math.abs(delta) > 48) go(index + (delta < 0 ? 1 : -1));
1067
+ }, { passive: true });
1068
+
1069
+ fullscreenBtn.addEventListener('click', () => {
1070
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
1071
+ else document.exitFullscreen?.();
1072
+ });
1073
+
1074
+ notesBtn.addEventListener('click', () => {
1075
+ document.body.classList.toggle('notes-open');
1076
+ });
1077
+
1078
+ const start = Number.parseInt(location.hash?.replace('#slide-', ''), 10);
1079
+ go(Number.isInteger(start) ? start - 1 : 0);
1080
+ })();
1081
+ </script>
1082
+ </body>
1083
+ </html>`
1084
+ }
1085
+
1086
+ function renderHtmlSlide(slide, index, ui, langId) {
1087
+ const kicker = slide.kicker || (slide.layout === 'cover' ? ui.coverKicker : '')
1088
+ const title = slide.title || ''
1089
+ const subtitle = slide.subtitle || ''
1090
+ const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1091
+ const number = String(index + 1).padStart(2, '0')
1092
+ let inner = ''
1093
+ switch (slide.layout) {
1094
+ case 'cover':
1095
+ inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1096
+ '<h1>' + escapeHtml(title) + '</h1>' +
1097
+ (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
1098
+ '<div class="meta">' + escapeHtml(ui.generatedBy) + '</div>'
1099
+ break
1100
+ case 'section':
1101
+ inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1102
+ '<h2>' + escapeHtml(title) + '</h2>' +
1103
+ '<div class="accent-line"></div>' +
1104
+ (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
1105
+ break
1106
+ case 'statement':
1107
+ inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1108
+ '<div class="statement-title">' + escapeHtml(title || slide.text || '') + '</div>' +
1109
+ (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
1110
+ break
1111
+ case 'quote':
1112
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.quoteKicker) + '</div>' +
1113
+ '<div class="quote-mark" aria-hidden="true">\u201C</div>' +
1114
+ '<blockquote class="quote-text">' + escapeHtml(title || slide.text || '') + '</blockquote>' +
1115
+ (subtitle !== '' ? '<div class="quote-attr">\u2014\u2014 ' + escapeHtml(subtitle) + '</div>' : '')
1116
+ break
1117
+ case 'table': {
1118
+ const rows = Array.isArray(slide.rows) ? slide.rows : []
1119
+ const head = rows[0] ?? []
1120
+ const body = rows.slice(1)
1121
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.tableKicker) + '</div>' +
1122
+ (title !== '' && title !== kicker ? '<h2>' + escapeHtml(title) + '</h2>' : '') +
1123
+ '<table class="deck-table"><thead><tr>' +
1124
+ head.map((cell) => '<th>' + escapeHtml(cell) + '</th>').join('') +
1125
+ '</tr></thead><tbody>' +
1126
+ body.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
1127
+ '</tbody></table>'
1128
+ break
1129
+ }
1130
+ case 'closing':
1131
+ inner = '<h1>' + escapeHtml(title || ui.closingTitle) + '</h1>' +
1132
+ (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
1133
+ '<div class="meta">' + escapeHtml(ui.generatedBy) + '</div>'
1134
+ break
1135
+ case 'bullets':
1136
+ default:
1137
+ inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1138
+ '<h2>' + escapeHtml(title) + '</h2>' +
1139
+ '<ul>' + bullets.map((bullet, bulletIndex) => '<li style="--i:' + bulletIndex + '">' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
1140
+ break
1141
+ }
1142
+ const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
1143
+ ? ' data-notes="' + escapeHtml(slide.notes) + '"'
1144
+ : ''
1145
+ return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
1146
+ '<div class="slide-inner">' + inner + '</div></section>'
1147
+ }
1148
+
1149
+ function hexToRgba(hex, alpha) {
1150
+ const value = String(hex).replace('#', '')
1151
+ const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value
1152
+ const num = Number.parseInt(full, 16)
1153
+ const r = (num >> 16) & 255
1154
+ const g = (num >> 8) & 255
1155
+ const b = num & 255
1156
+ return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
1157
+ }
1158
+
1159
+ // ---------------------------------------------------------------------------
1160
+ // PPTX(OOXML 手写 + node:zlib zip)
1161
+ // ---------------------------------------------------------------------------
1162
+
1163
+ const EMU_W = 12192000
1164
+ const EMU_H = 6858000
1165
+
1166
+ function hex(value) {
1167
+ return String(value).replace('#', '').toUpperCase()
1168
+ }
1169
+
1170
+ function pptxFontName(value) {
1171
+ const match = /"?([^",]+)"?/.exec(String(value ?? ''))
1172
+ return match?.[1]?.trim() || 'Arial'
1173
+ }
1174
+
1175
+ function paragraphXml(text, options = {}) {
1176
+ const {
1177
+ size = 2000,
1178
+ color = 'FFFFFF',
1179
+ bold = false,
1180
+ lang = 'zh-CN',
1181
+ align = 'l',
1182
+ bullet = false,
1183
+ font = 'Arial',
1184
+ spaceBefore = 0,
1185
+ spaceAfter = 0,
1186
+ } = options
1187
+ const runFont = pptxFontName(font)
1188
+ let pPr = ''
1189
+ if (bullet) {
1190
+ pPr = '<a:pPr marL="285750" indent="-285750"><a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/></a:pPr>'
1191
+ } else {
1192
+ const parts = [] // align spacing 二选一
1193
+ if (align !== 'l') parts.push('algn="' + align + '"')
1194
+ if (spaceBefore > 0) parts.push('<a:spcBef><a:spcPts val="' + (spaceBefore / 100) + '"/></a:spcBef>')
1195
+ if (spaceAfter > 0) parts.push('<a:spcAft><a:spcPts val="' + (spaceAfter / 100) + '"/></a:spcAft>')
1196
+ pPr = '<a:pPr' + (parts.length > 0 ? ' ' + parts.join(' ') : '') + '><a:buNone/></a:pPr>'
1197
+ }
1198
+ return '<a:p>' + pPr +
1199
+ '<a:r><a:rPr lang="' + lang + '" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1200
+ '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1201
+ '<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(runFont) + '"/></a:rPr>' +
1202
+ '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p>'
1203
+ }
1204
+
1205
+ function textShapeXml(id, name, box, paragraphs, options = {}) {
1206
+ const { x = 0, y = 0, w = EMU_W, h = EMU_H } = box
1207
+ return '<p:sp>' +
1208
+ '<p:nvSpPr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>' +
1209
+ '<p:spPr><a:xfrm><a:off x="' + x + '" y="' + y + '"/><a:ext cx="' + w + '" cy="' + h + '"/></a:xfrm>' +
1210
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln/></p:spPr>' +
1211
+ '<p:txBody><a:bodyPr wrap="square" rtlCol="0"><a:normAutofit/></a:bodyPr><a:lstStyle/>' +
1212
+ paragraphs.join('') + '</p:txBody></p:sp>'
1213
+ }
1214
+
1215
+ function accentBarXml(id, name, box, color) {
1216
+ const { x = 0, y = 0, w = EMU_W, h = EMU_H } = box
1217
+ return '<p:sp>' +
1218
+ '<p:nvSpPr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>' +
1219
+ '<p:spPr><a:xfrm><a:off x="' + x + '" y="' + y + '"/><a:ext cx="' + w + '" cy="' + h + '"/></a:xfrm>' +
1220
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>' +
1221
+ '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill><a:ln/></p:spPr>' +
1222
+ '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>'
1223
+ }
1224
+
1225
+ /** 原生 OOXML 表格(graphicFrame + a:tbl):首行主题色表头,隔行面板色。 */
1226
+ function tableGraphicXml(id, name, box, rows, theme, langId) {
1227
+ const p = theme.palette
1228
+ const lang = langId || 'zh-CN'
1229
+ const font = pptxFontName(theme.fonts.body)
1230
+ const safeRows = rows.length > 0 ? rows : [['']]
1231
+ const cols = Math.max(1, safeRows[0].length)
1232
+ const colW = Math.floor(box.w / cols)
1233
+ const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
1234
+ const rowH = 420000
1235
+ const cellXml = (text, fill, color, bold) =>
1236
+ '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1237
+ '<a:r><a:rPr lang="' + lang + '" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1238
+ '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1239
+ '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1240
+ '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
1241
+ '<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
1242
+ (fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
1243
+ '</a:tcPr></a:tc>'
1244
+ const trs = safeRows.map((row, rowIndex) => {
1245
+ const padded = Array.from({ length: cols }, (_, i) => row[i] ?? '')
1246
+ const cells = rowIndex === 0
1247
+ ? padded.map((c) => cellXml(c, p.accent, p.bg, true)).join('')
1248
+ : padded.map((c) => cellXml(c, rowIndex % 2 === 1 ? p.panel : '', p.fg, false)).join('')
1249
+ return '<a:tr h="' + rowH + '">' + cells + '</a:tr>'
1250
+ }).join('')
1251
+ const height = Math.max(1, safeRows.length) * rowH
1252
+ return '<p:graphicFrame>' +
1253
+ '<p:nvGraphicFramePr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/>' +
1254
+ '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>' +
1255
+ '<p:xfrm><a:off x="' + box.x + '" y="' + box.y + '"/><a:ext cx="' + box.w + '" cy="' + height + '"/></p:xfrm>' +
1256
+ '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">' +
1257
+ '<a:tbl><a:tblPr firstRow="1" bandRow="1"/>' +
1258
+ '<a:tblGrid>' + grid + '</a:tblGrid>' + trs + '</a:tbl>' +
1259
+ '</a:graphicData></a:graphic></p:graphicFrame>'
1260
+ }
1261
+
1262
+ function slideShapeList(slide, index, total, theme, langId) {
1263
+ const p = theme.palette
1264
+ const font = pptxFontName(theme.fonts.heading)
1265
+ const bodyFont = pptxFontName(theme.fonts.body) // 保留:v0.1 后续用于统一正文字体
1266
+ const lang = langId || 'zh-CN'
1267
+ // PPTX 每个文本 run 的语言跟随 deck.lang(zh-CN / en-US),不再写死中文
1268
+ const para = (content, options = {}) => paragraphXml(content, { lang, ...options })
1269
+ const shapes = []
1270
+ const kicker = slide.kicker || ''
1271
+ const title = slide.title || ''
1272
+ const subtitle = slide.subtitle || ''
1273
+ const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1274
+ const idBase = (index + 1) * 10
1275
+ const footer = String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0')
1276
+
1277
+ if (slide.layout === 'cover') {
1278
+ shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 2250000, w: 240000, h: 1600000 }, p.accent))
1279
+ shapes.push(textShapeXml(idBase + 2, 'Title', { x: 1550000, y: 2160000, w: 9250000, h: 1800000 }, [
1280
+ para(title, { size: 4400, color: p.fg, bold: true, font }),
1281
+ ]))
1282
+ if (subtitle !== '') {
1283
+ shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1570000, y: 4150000, w: 9000000, h: 1200000 }, [
1284
+ para(subtitle, { size: 2200, color: p.muted, font: theme.fonts.body }),
1285
+ ]))
1286
+ }
1287
+ if (kicker !== '') {
1288
+ shapes.push(textShapeXml(idBase + 4, 'Kicker', { x: 1570000, y: 5800000, w: 7000000, h: 500000 }, [
1289
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1290
+ ]))
1291
+ }
1292
+ } else if (slide.layout === 'section') {
1293
+ if (kicker !== '') {
1294
+ shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 1050000, y: 2250000, w: 9000000, h: 500000 }, [
1295
+ para(kicker, { size: 1600, color: p.accent, bold: true, font: theme.fonts.body }),
1296
+ ]))
1297
+ }
1298
+ shapes.push(accentBarXml(idBase + 2, 'Accent bar', { x: 1050000, y: 2850000, w: 1600000, h: 160000 }, p.accent2))
1299
+ shapes.push(textShapeXml(idBase + 3, 'Title', { x: 1050000, y: 3150000, w: 10200000, h: 1400000 }, [
1300
+ para(title, { size: 4000, color: p.fg, bold: true, font }),
1301
+ ]))
1302
+ if (subtitle !== '') {
1303
+ shapes.push(textShapeXml(idBase + 4, 'Subtitle', { x: 1070000, y: 4750000, w: 9000000, h: 900000 }, [
1304
+ para(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1305
+ ]))
1306
+ }
1307
+ } else if (slide.layout === 'statement') {
1308
+ shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 1900000, w: 200000, h: 2800000 }, p.accent))
1309
+ shapes.push(textShapeXml(idBase + 2, 'Statement', { x: 1500000, y: 1950000, w: 9400000, h: 2700000 }, [
1310
+ para(title || slide.text || '', { size: 3600, color: p.fg, bold: true, font }),
1311
+ ]))
1312
+ if (subtitle !== '') {
1313
+ shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1520000, y: 4900000, w: 9000000, h: 800000 }, [
1314
+ para(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1315
+ ]))
1316
+ }
1317
+ } else if (slide.layout === 'quote') {
1318
+ shapes.push(textShapeXml(idBase + 1, 'Quote mark', { x: 900000, y: 1050000, w: 1700000, h: 1900000 }, [
1319
+ para('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1320
+ ]))
1321
+ shapes.push(textShapeXml(idBase + 2, 'Quote', { x: 1050000, y: 2550000, w: 10100000, h: 2600000 }, [
1322
+ para(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1323
+ ]))
1324
+ if (subtitle !== '') {
1325
+ shapes.push(textShapeXml(idBase + 3, 'Attribution', { x: 1070000, y: 5300000, w: 9000000, h: 600000 }, [
1326
+ para('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1327
+ ]))
1328
+ }
1329
+ } else if (slide.layout === 'table') {
1330
+ const hasTitle = title !== '' && title !== kicker
1331
+ if (kicker !== '') {
1332
+ shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1333
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1334
+ ]))
1335
+ }
1336
+ if (hasTitle) {
1337
+ shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1338
+ para(title, { size: 3400, color: p.fg, bold: true, font }),
1339
+ ]))
1340
+ }
1341
+ shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme, lang))
1342
+ } else if (slide.layout === 'closing') {
1343
+ shapes.push(textShapeXml(idBase + 1, 'Title', { x: 1050000, y: 2300000, w: 10200000, h: 1700000 }, [
1344
+ para(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
1345
+ ]))
1346
+ if (subtitle !== '') {
1347
+ shapes.push(textShapeXml(idBase + 2, 'Subtitle', { x: 1050000, y: 4200000, w: 10200000, h: 900000 }, [
1348
+ para(subtitle, { size: 2000, color: p.accent, bold: true, align: 'ctr', font: theme.fonts.body }),
1349
+ ]))
1350
+ }
1351
+ } else {
1352
+ // bullets(默认布局)
1353
+ if (kicker !== '') {
1354
+ shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1355
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1356
+ ]))
1357
+ }
1358
+ shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1359
+ para(title, { size: 3400, color: p.fg, bold: true, font }),
1360
+ ]))
1361
+ const bodyParagraphs = bullets.length > 0
1362
+ ? bullets.map((bullet) => para(bullet, { size: 2000, color: p.fg, bullet: true, font: theme.fonts.body, spaceAfter: 600 }))
1363
+ : [para(subtitle || '', { size: 2000, color: p.fg, font: theme.fonts.body })]
1364
+ shapes.push(textShapeXml(idBase + 3, 'Body', { x: 1050000, y: 1850000, w: 10100000, h: 4500000 }, bodyParagraphs))
1365
+ }
1366
+
1367
+ shapes.push(textShapeXml(idBase + 9, 'Page number', { x: 10600000, y: 6250000, w: 1200000, h: 400000 }, [
1368
+ para(footer, { size: 1000, color: p.muted, align: 'r', font: theme.fonts.body }),
1369
+ ]))
1370
+
1371
+ return shapes.join('')
1372
+ }
1373
+
1374
+ /**
1375
+ * 原生逐条点击显现动画(PowerPoint「出现,按段落」的标准时序树):
1376
+ * bldP 把正文文本框标记为按段落构建,主序列里每条要点一个点击节点。
1377
+ */
1378
+ function bulletTimingXml(spid, clicks) {
1379
+ let id = 2
1380
+ const nextId = () => { id += 1; return String(id) }
1381
+ const clickPars = []
1382
+ for (let i = 0; i < clicks; i += 1) {
1383
+ clickPars.push(
1384
+ '<p:par><p:cTn id="' + nextId() + '" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>' +
1385
+ '<p:par><p:cTn id="' + nextId() + '" presetID="1" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="clickEffect">' +
1386
+ '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>' +
1387
+ '<p:set><p:cBhvr><p:cTn id="' + nextId() + '" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>' +
1388
+ '<p:tgtEl><p:spTgt spid="' + spid + '"/></p:tgtEl>' +
1389
+ '<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr>' +
1390
+ '<p:to><p:strVal val="visible"/></p:to></p:set>' +
1391
+ '</p:childTnLst></p:cTn></p:par>' +
1392
+ '</p:childTnLst></p:cTn></p:par>',
1393
+ )
1394
+ }
1395
+ return '<p:timing><p:tnLst><p:par>' +
1396
+ '<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>' +
1397
+ '<p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>' +
1398
+ clickPars.join('') +
1399
+ '</p:childTnLst></p:cTn>' +
1400
+ '<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>' +
1401
+ '<p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>' +
1402
+ '</p:seq></p:childTnLst></p:cTn></p:par></p:tnLst>' +
1403
+ '<p:bldLst><p:bldP spid="' + spid + '" grpId="0"/></p:bldLst></p:timing>'
1404
+ }
1405
+
1406
+ function slideXml(slide, index, total, theme, langId, motion) {
1407
+ const p = theme.palette
1408
+ let motionXml = ''
1409
+ if (motion) {
1410
+ motionXml = '<p:transition spd="med"><p:fade/></p:transition>'
1411
+ const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1412
+ if (slide.layout === 'bullets' && bullets.length >= 2) {
1413
+ motionXml += bulletTimingXml((index + 1) * 10 + 3, bullets.length)
1414
+ }
1415
+ }
1416
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1417
+ '<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1418
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1419
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1420
+ '<p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="' + hex(p.bg) + '"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>' +
1421
+ '<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1422
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1423
+ slideShapeList(slide, index, total, theme, langId) +
1424
+ '</p:spTree></p:cSld>' +
1425
+ '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1426
+ motionXml +
1427
+ '</p:sld>'
1428
+ }
1429
+
1430
+ function slideRelXml(hasNotes, slideIndex) {
1431
+ const notesRel = hasNotes
1432
+ ? '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide' + slideIndex + '.xml"/>'
1433
+ : ''
1434
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1435
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1436
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1437
+ notesRel +
1438
+ '</Relationships>'
1439
+ }
1440
+
1441
+ function presentationXml(slideCount, hasNotesMaster) {
1442
+ const slideIds = Array.from({ length: slideCount }, (_, i) =>
1443
+ '<p:sldId id="' + (256 + i) + '" r:id="rId' + (i + 2) + '"/>').join('')
1444
+ const notesMasterId = hasNotesMaster
1445
+ ? '<p:notesMasterIdLst><p:notesMasterId id="2147483649" r:id="rId' + (slideCount + 2) + '"/></p:notesMasterIdLst>'
1446
+ : ''
1447
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1448
+ '<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1449
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1450
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1451
+ '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>' +
1452
+ '<p:sldIdLst>' + slideIds + '</p:sldIdLst>' +
1453
+ notesMasterId +
1454
+ '<p:sldSz cx="' + EMU_W + '" cy="' + EMU_H + '" type="screen16x9"/>' +
1455
+ '<p:notesSz cx="6858000" cy="9144000"/>' +
1456
+ '</p:presentation>'
1457
+ }
1458
+
1459
+ function presentationRelXml(slideCount, hasNotesMaster) {
1460
+ const slideRels = Array.from({ length: slideCount }, (_, i) =>
1461
+ '<Relationship Id="rId' + (i + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide' + (i + 1) + '.xml"/>').join('')
1462
+ const notesMasterRel = hasNotesMaster
1463
+ ? '<Relationship Id="rId' + (slideCount + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>'
1464
+ : ''
1465
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1466
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1467
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>' +
1468
+ slideRels +
1469
+ notesMasterRel +
1470
+ '</Relationships>'
1471
+ }
1472
+
1473
+ function notesMasterXml() {
1474
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1475
+ '<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1476
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1477
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1478
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1479
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1480
+ '</p:spTree></p:cSld>' +
1481
+ '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' +
1482
+ '<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle>' +
1483
+ '</p:notesMaster>'
1484
+ }
1485
+
1486
+ function notesMasterRelXml() {
1487
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1488
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1489
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1490
+ '</Relationships>'
1491
+ }
1492
+
1493
+ function notesSlideXml(notes, langId) {
1494
+ const lang = langId || 'zh-CN'
1495
+ const paragraphs = String(notes ?? '').split('\n')
1496
+ .filter((line) => line.trim() !== '')
1497
+ .map((line) => '<a:p><a:r><a:rPr lang="' + lang + '" sz="1200" dirty="0"/><a:t>' + escapeXml(line) + '</a:t></a:r></a:p>')
1498
+ .join('')
1499
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1500
+ '<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1501
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1502
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1503
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1504
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1505
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr/><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp>' +
1506
+ '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/>' +
1507
+ '<p:txBody><a:bodyPr/><a:lstStyle/>' + paragraphs + '</p:txBody></p:sp>' +
1508
+ '</p:spTree></p:cSld>' +
1509
+ '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1510
+ '</p:notes>'
1511
+ }
1512
+
1513
+ function notesSlideRelXml(slideIndex) {
1514
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1515
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1516
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide' + slideIndex + '.xml"/>' +
1517
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>' +
1518
+ '</Relationships>'
1519
+ }
1520
+
1521
+ function slideMasterXml(theme) {
1522
+ const p = theme.palette
1523
+ const levels = Array.from({ length: 9 }, (_, i) => {
1524
+ const sz = Math.max(1100, 2000 - i * 100)
1525
+ return '<a:lvl' + (i + 1) + 'pPr marL="' + (342900 + i * 342900) + '" indent="' + (-342900 - i * 0) + '">' +
1526
+ '<a:defRPr sz="' + sz + '"><a:solidFill><a:srgbClr val="' + hex(p.fg) + '"/></a:solidFill><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/></a:defRPr></a:lvl' + (i + 1) + 'pPr>'
1527
+ }).join('')
1528
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1529
+ '<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1530
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1531
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1532
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1533
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1534
+ '</p:spTree></p:cSld>' +
1535
+ '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' +
1536
+ '<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>' +
1537
+ '<p:txStyles><p:titleStyle>' + levels + '</p:titleStyle><p:bodyStyle>' + levels + '</p:bodyStyle><p:otherStyle>' + levels + '</p:otherStyle></p:txStyles>' +
1538
+ '</p:sldMaster>'
1539
+ }
1540
+
1541
+ function slideMasterRelXml() {
1542
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1543
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1544
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1545
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1546
+ '</Relationships>'
1547
+ }
1548
+
1549
+ function slideLayoutXml(theme) {
1550
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1551
+ '<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1552
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1553
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">' +
1554
+ '<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1555
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1556
+ '</p:spTree></p:cSld>' +
1557
+ '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1558
+ '</p:sldLayout>'
1559
+ }
1560
+
1561
+ function slideLayoutRelXml() {
1562
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1563
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1564
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>' +
1565
+ '</Relationships>'
1566
+ }
1567
+
1568
+ function themeXml(theme) {
1569
+ const p = theme.palette
1570
+ const color = (name, value) => '<a:' + name + '><a:srgbClr val="' + hex(value) + '"/></a:' + name + '>'
1571
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1572
+ '<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="dsh-ppt ' + escapeXml(theme.name.en) + '">' +
1573
+ '<a:themeElements>' +
1574
+ '<a:clrScheme name="dsh-ppt">' +
1575
+ color('dk1', p.fg) + color('lt1', p.bg) + color('dk2', p.muted) + color('lt2', p.panel) +
1576
+ color('accent1', p.accent) + color('accent2', p.accent2) + color('accent3', p.fg) +
1577
+ color('accent4', p.muted) + color('accent5', p.accent) + color('accent6', p.accent2) +
1578
+ color('hlink', p.accent) + color('folHlink', p.accent2) +
1579
+ '</a:clrScheme>' +
1580
+ '<a:fontScheme name="dsh-ppt">' +
1581
+ '<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>' +
1582
+ '<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>' +
1583
+ '</a:fontScheme>' +
1584
+ '<a:fmtScheme name="dsh-ppt">' +
1585
+ '<a:fillStyleLst>' +
1586
+ '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1587
+ '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1588
+ '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' +
1589
+ '</a:fillStyleLst>' +
1590
+ '<a:lnStyleLst><a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst>' +
1591
+ '<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>' +
1592
+ '<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>' +
1593
+ '</a:fmtScheme>' +
1594
+ '</a:themeElements>' +
1595
+ '<a:objectDefaults/><a:extraClrSchemeLst/>' +
1596
+ '</a:theme>'
1597
+ }
1598
+
1599
+ function contentTypesXml(slideCount, noteIndexes) {
1600
+ const overrides = Array.from({ length: slideCount }, (_, i) =>
1601
+ '<Override PartName="/ppt/slides/slide' + (i + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>').join('')
1602
+ const notesOverrides = noteIndexes.map((index) =>
1603
+ '<Override PartName="/ppt/notesSlides/notesSlide' + (index + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>').join('')
1604
+ const notesMasterOverride = noteIndexes.length > 0
1605
+ ? '<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
1606
+ : ''
1607
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1608
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
1609
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
1610
+ '<Default Extension="xml" ContentType="application/xml"/>' +
1611
+ '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>' +
1612
+ '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>' +
1613
+ '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>' +
1614
+ '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>' +
1615
+ '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
1616
+ '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
1617
+ notesMasterOverride +
1618
+ overrides +
1619
+ notesOverrides +
1620
+ '</Types>'
1621
+ }
1622
+
1623
+ function rootRelXml() {
1624
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1625
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1626
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>' +
1627
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
1628
+ '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' +
1629
+ '</Relationships>'
1630
+ }
1631
+
1632
+ function coreXml(title) {
1633
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1634
+ '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' +
1635
+ 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' +
1636
+ 'xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
1637
+ '<dc:title>' + escapeXml(title) + '</dc:title><dc:creator>dsh-ppt</dc:creator>' +
1638
+ '<cp:lastModifiedBy>dsh-ppt</cp:lastModifiedBy>' +
1639
+ '</cp:coreProperties>'
1640
+ }
1641
+
1642
+ function appXml(slideCount, notesCount) {
1643
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1644
+ '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' +
1645
+ 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
1646
+ '<Application>dsh-ppt</Application><PresentationFormat>Widescreen</PresentationFormat>' +
1647
+ '<Slides>' + slideCount + '</Slides><Notes>' + notesCount + '</Notes><HiddenSlides>0</HiddenSlides>' +
1648
+ '</Properties>'
1649
+ }
1650
+
1651
+ function buildZip(entries) {
1652
+ const chunks = []
1653
+ const central = []
1654
+ let offset = 0
1655
+
1656
+ for (const entry of entries) {
1657
+ const entryOffset = offset
1658
+ const name = Buffer.from(entry.name, 'utf8')
1659
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), 'utf8')
1660
+ const crc = crc32(data)
1661
+ const compressed = deflateRawSync(data)
1662
+ const method = 8
1663
+ const nameLength = name.length
1664
+ const localHeader = Buffer.alloc(30)
1665
+ localHeader.writeUInt32LE(0x04034b50, 0)
1666
+ localHeader.writeUInt16LE(20, 4)
1667
+ localHeader.writeUInt16LE(0x0800, 6) // UTF-8 文件名
1668
+ localHeader.writeUInt16LE(method, 8)
1669
+ localHeader.writeUInt16LE(0, 10) // DOS time
1670
+ localHeader.writeUInt16LE(0x21, 12) // DOS date 1980-01-01
1671
+ localHeader.writeUInt32LE(crc, 14)
1672
+ localHeader.writeUInt32LE(compressed.length, 18)
1673
+ localHeader.writeUInt32LE(data.length, 22)
1674
+ localHeader.writeUInt16LE(nameLength, 26)
1675
+ localHeader.writeUInt16LE(0, 28)
1676
+
1677
+ chunks.push(localHeader, name, compressed)
1678
+ offset += 30 + nameLength + compressed.length
1679
+
1680
+ const centralHeader = Buffer.alloc(46)
1681
+ centralHeader.writeUInt32LE(0x02014b50, 0)
1682
+ centralHeader.writeUInt16LE(20, 4)
1683
+ centralHeader.writeUInt16LE(20, 6)
1684
+ centralHeader.writeUInt16LE(0x0800, 8)
1685
+ centralHeader.writeUInt16LE(method, 10)
1686
+ centralHeader.writeUInt16LE(0, 12)
1687
+ centralHeader.writeUInt16LE(0x21, 14)
1688
+ centralHeader.writeUInt32LE(crc, 16)
1689
+ centralHeader.writeUInt32LE(compressed.length, 20)
1690
+ centralHeader.writeUInt32LE(data.length, 24)
1691
+ centralHeader.writeUInt16LE(nameLength, 28)
1692
+ centralHeader.writeUInt16LE(0, 30)
1693
+ centralHeader.writeUInt16LE(0, 32)
1694
+ centralHeader.writeUInt16LE(0, 34)
1695
+ centralHeader.writeUInt16LE(0, 36)
1696
+ centralHeader.writeUInt32LE(0, 38)
1697
+ centralHeader.writeUInt32LE(entryOffset, 42)
1698
+ central.push(centralHeader, name)
1699
+ }
1700
+
1701
+ const centralOffset = offset
1702
+ const centralSize = central.reduce((sum, part) => sum + part.length, 0)
1703
+ const end = Buffer.alloc(22)
1704
+ end.writeUInt32LE(0x06054b50, 0)
1705
+ end.writeUInt16LE(0, 4)
1706
+ end.writeUInt16LE(0, 6)
1707
+ end.writeUInt16LE(entries.length, 8)
1708
+ end.writeUInt16LE(entries.length, 10)
1709
+ end.writeUInt32LE(centralSize, 12)
1710
+ end.writeUInt32LE(centralOffset, 16)
1711
+ end.writeUInt16LE(0, 20)
1712
+
1713
+ chunks.push(...central, end)
1714
+ return Buffer.concat(chunks)
1715
+ }
1716
+
1717
+ let CRC_TABLE = null
1718
+ function crc32(buffer) {
1719
+ if (CRC_TABLE === null) {
1720
+ CRC_TABLE = new Int32Array(256)
1721
+ for (let n = 0; n < 256; n += 1) {
1722
+ let c = n
1723
+ for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
1724
+ CRC_TABLE[n] = c
1725
+ }
1726
+ }
1727
+ let crc = 0xffffffff
1728
+ for (let i = 0; i < buffer.length; i += 1) {
1729
+ crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8)
1730
+ }
1731
+ return (crc ^ 0xffffffff) >>> 0
1732
+ }
1733
+
1734
+ export function buildPptx(manifest, themeInput, languageInput) {
1735
+ const theme = themeInput?.id ? themeInput : resolveTheme(themeInput)
1736
+ const language = languageInput?.id ? languageInput : resolveLanguage(languageInput)
1737
+ const motion = manifest.motion !== false
1738
+ const slides = manifest.slides
1739
+ const noteIndexes = []
1740
+ slides.forEach((slide, index) => {
1741
+ if (typeof slide.notes === 'string' && slide.notes.trim() !== '') noteIndexes.push(index)
1742
+ })
1743
+ const hasNotesMaster = noteIndexes.length > 0
1744
+ const entries = [
1745
+ { name: '[Content_Types].xml', data: contentTypesXml(slides.length, noteIndexes) },
1746
+ { name: '_rels/.rels', data: rootRelXml() },
1747
+ { name: 'docProps/app.xml', data: appXml(slides.length, noteIndexes.length) },
1748
+ { name: 'docProps/core.xml', data: coreXml(manifest.title) },
1749
+ { name: 'ppt/presentation.xml', data: presentationXml(slides.length, hasNotesMaster) },
1750
+ { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length, hasNotesMaster) },
1751
+ { name: 'ppt/slideMasters/slideMaster1.xml', data: slideMasterXml(theme) },
1752
+ { name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: slideMasterRelXml() },
1753
+ { name: 'ppt/slideLayouts/slideLayout1.xml', data: slideLayoutXml(theme) },
1754
+ { name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: slideLayoutRelXml() },
1755
+ { name: 'ppt/theme/theme1.xml', data: themeXml(theme) },
1756
+ ]
1757
+ if (hasNotesMaster) {
1758
+ entries.push({ name: 'ppt/notesMasters/notesMaster1.xml', data: notesMasterXml() })
1759
+ entries.push({ name: 'ppt/notesMasters/_rels/notesMaster1.xml.rels', data: notesMasterRelXml() })
1760
+ }
1761
+ slides.forEach((slide, index) => {
1762
+ const hasNotes = noteIndexes.includes(index)
1763
+ entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.attr, motion) })
1764
+ entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1765
+ if (hasNotes) {
1766
+ entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes, language.attr) })
1767
+ entries.push({ name: 'ppt/notesSlides/_rels/notesSlide' + (index + 1) + '.xml.rels', data: notesSlideRelXml(index + 1) })
1768
+ }
1769
+ })
1770
+ return buildZip(entries)
1771
+ }