dsh-ppt 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,19 +17,23 @@
17
17
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
18
18
  import { resolve as resolvePath, join as joinPath } from 'node:path'
19
19
  import { deflateRawSync } from 'node:zlib'
20
+ import { MEDIA_LAYOUTS, MEDIA_CSS, normalizeMediaSlide, renderMediaHtml, mediaBoxes } from './deck-media.mjs'
21
+ import { renderDelivery } from './deck-delivery.mjs'
20
22
 
21
23
  /**
22
24
  * manifest 版本跟随发布包 package.json;把 skills/dsh-ppt 独立复制到其他
23
25
  * harness(没有 package.json)时回退为 0.0.0,避免版本号悄悄漂移。
24
26
  */
25
27
  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'
28
+ for (const file of ['../../../package.json', '../package.json']) {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(new URL(file, import.meta.url), 'utf8'))
31
+ if (!['dsh-ppt', 'dsh-ppt-skill'].includes(pkg.name)) continue
32
+ const version = String(pkg.version ?? '').trim()
33
+ if (version) return version
34
+ } catch { /* Copied skill may have no parent manifest. */ }
32
35
  }
36
+ return '0.0.0'
33
37
  }
34
38
 
35
39
  export const DECK_VERSION = readDeckVersion()
@@ -158,21 +162,21 @@ export const LANGUAGES = {
158
162
  notesToggle: '备注',
159
163
  closingTitle: '谢谢',
160
164
  closingSubtitle: '讨论与问答',
161
- generatedBy: '由 dsh-ppt 生成',
162
- overviewToggle: '总览',
163
- presenterToggle: '演讲者',
164
- presenterLabel: '演讲者视图 · Presenter view',
165
- helpTitle: '快捷键 · Shortcuts',
166
- noNotes: '(本页无备注)',
167
- closeLabel: '关闭',
168
- currentLabel: '当前页 · Current',
169
- nextLabel: '下一页 · Next',
170
- timerLabel: '计时 · Timer',
165
+ generatedBy: '由 dsh-ppt 生成',
166
+ overviewToggle: '总览',
167
+ presenterToggle: '演讲者',
168
+ presenterLabel: '演讲者视图 · Presenter view',
169
+ helpTitle: '快捷键 · Shortcuts',
170
+ noNotes: '(本页无备注)',
171
+ closeLabel: '关闭',
172
+ currentLabel: '当前页 · Current',
173
+ nextLabel: '下一页 · Next',
174
+ timerLabel: '计时 · Timer',
171
175
  presenterHint: '备注:未开演讲者窗口时按 S 显示在顶部;打开后只在演讲者窗口显示,观众看不到',
172
- wrapHint: '已环绕到',
173
- popupBlocked: '浏览器拦截了演讲者窗口,请允许弹出窗口后按 V 重试',
174
- fullscreenUnavailable: '当前窗口无法进入全屏,请在浏览器中独立打开演示文件后重试。',
175
- notesCloseLabel: '关闭备注',
176
+ wrapHint: '已环绕到',
177
+ popupBlocked: '浏览器拦截了演讲者窗口,请允许弹出窗口后按 V 重试',
178
+ fullscreenUnavailable: '当前窗口无法进入全屏,请在浏览器中独立打开演示文件后重试。',
179
+ notesCloseLabel: '关闭备注',
176
180
  overviewHint: '点击缩略图跳页,方向键切换,Enter 进入,Esc 退出'
177
181
  },
178
182
  },
@@ -194,21 +198,21 @@ export const LANGUAGES = {
194
198
  notesToggle: 'Notes',
195
199
  closingTitle: 'Thank You',
196
200
  closingSubtitle: 'Discussion & Q&A',
197
- generatedBy: 'Generated with dsh-ppt',
198
- overviewToggle: 'Overview',
199
- presenterToggle: 'Presenter',
200
- presenterLabel: 'Presenter view',
201
- helpTitle: 'Keyboard shortcuts',
202
- noNotes: '(No notes on this slide)',
203
- closeLabel: 'Close',
204
- currentLabel: 'Current',
205
- nextLabel: 'Next',
206
- timerLabel: 'Timer',
201
+ generatedBy: 'Generated with dsh-ppt',
202
+ overviewToggle: 'Overview',
203
+ presenterToggle: 'Presenter',
204
+ presenterLabel: 'Presenter view',
205
+ helpTitle: 'Keyboard shortcuts',
206
+ noNotes: '(No notes on this slide)',
207
+ closeLabel: 'Close',
208
+ currentLabel: 'Current',
209
+ nextLabel: 'Next',
210
+ timerLabel: 'Timer',
207
211
  presenterHint: 'Notes: S shows them on top until a presenter window is open; afterwards only in the presenter window.',
208
- wrapHint: 'Wrapped to',
209
- popupBlocked: 'The presenter window was blocked. Allow pop-ups and press V to retry.',
210
- fullscreenUnavailable: 'Fullscreen is unavailable here. Open the presentation in a browser tab and try again.',
211
- notesCloseLabel: 'Close notes',
212
+ wrapHint: 'Wrapped to',
213
+ popupBlocked: 'The presenter window was blocked. Allow pop-ups and press V to retry.',
214
+ fullscreenUnavailable: 'Fullscreen is unavailable here. Open the presentation in a browser tab and try again.',
215
+ notesCloseLabel: 'Close notes',
212
216
  overviewHint: 'Click a thumbnail to jump; arrows move, Enter opens, Esc exits'
213
217
  },
214
218
  },
@@ -230,135 +234,135 @@ export const LANGUAGES = {
230
234
  notesToggle: '备注 · Notes',
231
235
  closingTitle: '谢谢 · Thank You',
232
236
  closingSubtitle: '讨论与问答 · Q&A',
233
- generatedBy: '由 dsh-ppt 生成 · Generated with dsh-ppt',
234
- overviewToggle: '总览 · Overview',
235
- presenterToggle: '演讲者 · Presenter',
236
- presenterLabel: '演讲者视图 · Presenter view',
237
- helpTitle: '快捷键 · Shortcuts',
238
- noNotes: '(本页无备注 · No notes on this slide)',
239
- closeLabel: '关闭 · Close',
240
- currentLabel: '当前页 · Current',
241
- nextLabel: '下一页 · Next',
242
- timerLabel: '计时 · Timer',
237
+ generatedBy: '由 dsh-ppt 生成 · Generated with dsh-ppt',
238
+ overviewToggle: '总览 · Overview',
239
+ presenterToggle: '演讲者 · Presenter',
240
+ presenterLabel: '演讲者视图 · Presenter view',
241
+ helpTitle: '快捷键 · Shortcuts',
242
+ noNotes: '(本页无备注 · No notes on this slide)',
243
+ closeLabel: '关闭 · Close',
244
+ currentLabel: '当前页 · Current',
245
+ nextLabel: '下一页 · Next',
246
+ timerLabel: '计时 · Timer',
243
247
  presenterHint: '备注:未开演讲者窗口时按 S 显示在顶部;打开后只在演讲者窗口显示 · Notes: S shows on top until a presenter window is open',
244
- wrapHint: '已环绕到 · Wrapped to',
245
- popupBlocked: '浏览器拦截了演讲者窗口 · Presenter window blocked; allow pop-ups and press V',
246
- fullscreenUnavailable: '当前窗口无法全屏,请独立打开演示文件 · Open this presentation in a browser tab for fullscreen.',
247
- notesCloseLabel: '关闭备注 · Close notes',
248
- overviewHint: '点击缩略图跳页 · Click to jump, arrows move, Enter opens, Esc exits'
249
- },
250
- },
251
- }
252
-
253
- // ---------------------------------------------------------------------------
254
- // 主题预览(ppt_themes preview=true / buildThemePreview)
255
- // ---------------------------------------------------------------------------
256
-
257
- function themePages(lang) {
258
- return lang === 'en'
259
- ? { title: 'dsh-ppt theme gallery', hint: 'Pick a theme id for ppt_create', usage: 'theme', dark: 'dark', light: 'light', palette: 'Palette' }
260
- : { title: 'dsh-ppt 主题预览', hint: '把 id 填给 ppt_create 的 theme 参数', usage: 'theme', dark: '深色', light: '浅色', palette: '色板' }
261
- }
262
-
263
- /** 单套主题的 960x540 SVG 色板卡:可直接放进 README / npm 首屏。 */
264
- export function renderThemeSvg(input, lang = 'zh') {
265
- const theme = typeof input === 'string' ? resolveTheme(input) : input
266
- const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
267
- const p = theme.palette
268
- const L = themePages(lang === 'en' ? 'en' : 'zh')
269
- const font = theme.fonts.heading.replace(/"/g, "'")
270
- const bodyFont = theme.fonts.body.replace(/"/g, "'")
271
- const out = []
272
- out.push('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-label="' + escapeXml(theme.id + ' ' + pick(theme.name)) + '">')
273
- out.push('<rect width="960" height="540" fill="' + p.bg + '"/>')
274
- out.push('<rect x="0" y="0" width="960" height="6" fill="' + p.accent + '"/><rect x="0" y="534" width="960" height="6" fill="' + p.accent2 + '"/>')
275
- out.push('<rect x="36" y="40" width="560" height="360" rx="16" fill="' + p.panel + '" stroke="' + hexToRgba(p.muted, 0.45) + '"/>')
276
- out.push('<text x="64" y="92" fill="' + p.accent + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700" letter-spacing="3">' + escapeXml(pick(theme.mood).toUpperCase()) + '</text>')
277
- out.push('<text x="64" y="158" fill="' + p.fg + '" font-family="' + escapeXml(font) + '" font-size="42" font-weight="800">' + escapeXml('Aa ' + pick(theme.name)) + '</text>')
278
- out.push('<circle cx="72" cy="206" r="5" fill="' + p.accent + '"/><rect x="92" y="199" width="360" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.75) + '"/>')
279
- out.push('<circle cx="72" cy="240" r="5" fill="' + p.accent + '"/><rect x="92" y="233" width="300" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.55) + '"/>')
280
- out.push('<circle cx="72" cy="274" r="5" fill="' + p.accent + '"/><rect x="92" y="267" width="330" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.55) + '"/>')
281
- out.push('<rect x="64" y="312" width="500" height="34" rx="6" fill="' + p.accent + '"/>')
282
- out.push('<text x="78" y="334" fill="' + p.bg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700">dsh-ppt</text>')
283
- out.push('<text x="470" y="334" fill="' + p.bg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700" text-anchor="end">1,234</text>')
284
- out.push('<rect x="64" y="346" width="500" height="2" fill="' + hexToRgba(p.muted, 0.5) + '"/>')
285
- out.push('<text x="64" y="378" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13">' + escapeXml(theme.id + ' · ' + (theme.dark ? L.dark : L.light) + ' · v' + DECK_VERSION) + '</text>')
286
- const swatches = ['bg', 'panel', 'fg', 'muted', 'accent', 'accent2']
287
- const labels = ['bg', 'panel', 'fg', 'muted', 'accent', 'accent2']
288
- out.push('<text x="632" y="76" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13" font-weight="700" letter-spacing="2">' + escapeXml(L.palette.toUpperCase()) + '</text>')
289
- swatches.forEach((key, i) => {
290
- const y = 96 + i * 62
291
- out.push('<rect x="632" y="' + y + '" width="44" height="44" rx="10" fill="' + p[key] + '" stroke="' + hexToRgba(p.muted, 0.45) + '"/>')
292
- out.push('<text x="692" y="' + (y + 20) + '" fill="' + p.fg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="600">' + escapeXml(labels[i]) + '</text>')
293
- out.push('<text x="692" y="' + (y + 38) + '" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13">' + escapeXml(String(p[key]).toUpperCase()) + '</text>')
294
- })
295
- out.push('<text x="632" y="508" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="12">' + escapeXml(pick(theme.bestFor)).slice(0, 44) + '</text>')
296
- out.push('</svg>')
297
- return out.join('')
298
- }
299
-
300
- /** 自包含主题对比页:5 套主题并排,含色板与用法。 */
301
- export function renderThemePreviewHtml(lang = 'zh') {
302
- const L = themePages(lang === 'en' ? 'en' : 'zh')
303
- const cards = THEME_IDS.map((id) => {
304
- const theme = THEMES[id]
305
- const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
306
- const chips = Object.keys(theme.palette).map((key) =>
307
- '<span class="chip"><i style="background:' + theme.palette[key] + '"></i>' + escapeHtml(String(theme.palette[key]).toUpperCase()) + '</span>').join('')
308
- return '<figure class="card" id="theme-' + escapeHtml(id) + '">' +
309
- renderThemeSvg(theme, lang === 'en' ? 'en' : 'zh') +
310
- '<figcaption><div class="row"><strong>' + escapeHtml(id) + '</strong><span class="badge">' + escapeHtml(pick(theme.name)) + '</span></div>' +
311
- '<p class="muted">' + escapeHtml(pick(theme.mood)) + ' · ' + escapeHtml(pick(theme.bestFor)) + '</p>' +
312
- '<p class="chips">' + chips + '</p>' +
313
- '<p class="usage">' + escapeHtml(L.usage) + ': <code>' + escapeHtml(id) + '</code></p></figcaption></figure>'
314
- }).join('')
315
- return '<!DOCTYPE html>' + eolSafe('<html lang="zh-CN">') +
316
- '<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">' +
317
- '<title>' + escapeHtml(L.title) + '</title><style>' +
318
- 'body{margin:0;background:#0b0f17;color:#e8f1ff;font-family:"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:28px}' +
319
- 'h1{font-size:26px;margin:0 0 6px}p.sub{color:#8b98ad;margin:0 0 24px;font-size:14px}' +
320
- '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(430px,1fr));gap:22px}' +
321
- '.card{margin:0;background:#111827;border:1px solid #263043;border-radius:16px;overflow:hidden}' +
322
- '.card svg{display:block;width:100%;height:auto}' +
323
- 'figcaption{padding:14px 16px 16px}.row{display:flex;align-items:center;gap:10px;font-size:16px}' +
324
- '.badge{color:#8b98ad;font-size:13px}.muted{color:#8b98ad;font-size:13px;margin:6px 0 10px;line-height:1.5}' +
325
- '.chips{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#aab6c9;background:#0b0f17;border:1px solid #263043;border-radius:99px;padding:3px 9px}' +
326
- '.chip i{width:10px;height:10px;border-radius:3px;display:inline-block}' +
327
- '.usage{margin:0;font-size:13px;color:#aab6c9}code{background:#0b0f17;border:1px solid #263043;border-radius:6px;padding:1px 6px}' +
328
- '@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}' +
329
- '</style></head><body><h1>' + escapeHtml(L.title) + '</h1><p class="sub">' + escapeHtml(L.hint) + ' · dsh-ppt v' + escapeHtml(DECK_VERSION) + '</p>' +
330
- '<div class="grid">' + cards + '</div></body></html>'
331
- }
332
-
333
- function eolSafe(text) { return text }
334
-
335
- /** 写主题对比页 + 每套主题 SVG 色板卡;默认不覆盖同名文件。 */
336
- export function buildThemePreview(options = {}) {
337
- const langInput = typeof options.lang === 'string' && options.lang.trim() !== '' ? options.lang.trim() : 'zh'
338
- const language = resolveLanguage(langInput)
339
- const outputDir = resolvePath(String(options.outputDir ?? '.'))
340
- mkdirSync(outputDir, { recursive: true })
341
- const overwrite = options.overwrite === true
342
- const pickPath = (base, ext) => {
343
- const first = resolvePath(joinPath(outputDir, base + ext))
344
- if (overwrite || !existsSync(first)) return first
345
- for (let i = 1; i < 1000; i += 1) {
346
- const candidate = resolvePath(joinPath(outputDir, base + '-' + i + ext))
347
- if (!existsSync(candidate)) return candidate
348
- }
349
- throw new Error('dsh-ppt:找不到可用的预览文件名(同名前缀超过 999 个),请允许 overwrite。')
350
- }
351
- const htmlPath = pickPath('themes-preview', '.html')
352
- writeFileSync(htmlPath, renderThemePreviewHtml(language.id), 'utf8')
353
- const svgs = THEME_IDS.map((id) => {
354
- const svgPath = pickPath('theme-' + id, '.svg')
355
- writeFileSync(svgPath, renderThemeSvg(id, language.id), 'utf8')
356
- return { id, path: svgPath }
357
- })
358
- return { ok: true, outputDir, htmlPath, svgs, themeCount: THEME_IDS.length, language: language.id }
359
- }
360
-
361
-
248
+ wrapHint: '已环绕到 · Wrapped to',
249
+ popupBlocked: '浏览器拦截了演讲者窗口 · Presenter window blocked; allow pop-ups and press V',
250
+ fullscreenUnavailable: '当前窗口无法全屏,请独立打开演示文件 · Open this presentation in a browser tab for fullscreen.',
251
+ notesCloseLabel: '关闭备注 · Close notes',
252
+ overviewHint: '点击缩略图跳页 · Click to jump, arrows move, Enter opens, Esc exits'
253
+ },
254
+ },
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // 主题预览(ppt_themes preview=true / buildThemePreview)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function themePages(lang) {
262
+ return lang === 'en'
263
+ ? { title: 'dsh-ppt theme gallery', hint: 'Pick a theme id for ppt_create', usage: 'theme', dark: 'dark', light: 'light', palette: 'Palette' }
264
+ : { title: 'dsh-ppt 主题预览', hint: '把 id 填给 ppt_create 的 theme 参数', usage: 'theme', dark: '深色', light: '浅色', palette: '色板' }
265
+ }
266
+
267
+ /** 单套主题的 960x540 SVG 色板卡:可直接放进 README / npm 首屏。 */
268
+ export function renderThemeSvg(input, lang = 'zh') {
269
+ const theme = typeof input === 'string' ? resolveTheme(input) : input
270
+ const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
271
+ const p = theme.palette
272
+ const L = themePages(lang === 'en' ? 'en' : 'zh')
273
+ const font = theme.fonts.heading.replace(/"/g, "'")
274
+ const bodyFont = theme.fonts.body.replace(/"/g, "'")
275
+ const out = []
276
+ out.push('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-label="' + escapeXml(theme.id + ' ' + pick(theme.name)) + '">')
277
+ out.push('<rect width="960" height="540" fill="' + p.bg + '"/>')
278
+ out.push('<rect x="0" y="0" width="960" height="6" fill="' + p.accent + '"/><rect x="0" y="534" width="960" height="6" fill="' + p.accent2 + '"/>')
279
+ out.push('<rect x="36" y="40" width="560" height="360" rx="16" fill="' + p.panel + '" stroke="' + hexToRgba(p.muted, 0.45) + '"/>')
280
+ out.push('<text x="64" y="92" fill="' + p.accent + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700" letter-spacing="3">' + escapeXml(pick(theme.mood).toUpperCase()) + '</text>')
281
+ out.push('<text x="64" y="158" fill="' + p.fg + '" font-family="' + escapeXml(font) + '" font-size="42" font-weight="800">' + escapeXml('Aa ' + pick(theme.name)) + '</text>')
282
+ out.push('<circle cx="72" cy="206" r="5" fill="' + p.accent + '"/><rect x="92" y="199" width="360" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.75) + '"/>')
283
+ out.push('<circle cx="72" cy="240" r="5" fill="' + p.accent + '"/><rect x="92" y="233" width="300" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.55) + '"/>')
284
+ out.push('<circle cx="72" cy="274" r="5" fill="' + p.accent + '"/><rect x="92" y="267" width="330" height="12" rx="6" fill="' + hexToRgba(p.fg, 0.55) + '"/>')
285
+ out.push('<rect x="64" y="312" width="500" height="34" rx="6" fill="' + p.accent + '"/>')
286
+ out.push('<text x="78" y="334" fill="' + p.bg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700">dsh-ppt</text>')
287
+ out.push('<text x="470" y="334" fill="' + p.bg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="700" text-anchor="end">1,234</text>')
288
+ out.push('<rect x="64" y="346" width="500" height="2" fill="' + hexToRgba(p.muted, 0.5) + '"/>')
289
+ out.push('<text x="64" y="378" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13">' + escapeXml(theme.id + ' · ' + (theme.dark ? L.dark : L.light) + ' · v' + DECK_VERSION) + '</text>')
290
+ const swatches = ['bg', 'panel', 'fg', 'muted', 'accent', 'accent2']
291
+ const labels = ['bg', 'panel', 'fg', 'muted', 'accent', 'accent2']
292
+ out.push('<text x="632" y="76" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13" font-weight="700" letter-spacing="2">' + escapeXml(L.palette.toUpperCase()) + '</text>')
293
+ swatches.forEach((key, i) => {
294
+ const y = 96 + i * 62
295
+ out.push('<rect x="632" y="' + y + '" width="44" height="44" rx="10" fill="' + p[key] + '" stroke="' + hexToRgba(p.muted, 0.45) + '"/>')
296
+ out.push('<text x="692" y="' + (y + 20) + '" fill="' + p.fg + '" font-family="' + escapeXml(bodyFont) + '" font-size="14" font-weight="600">' + escapeXml(labels[i]) + '</text>')
297
+ out.push('<text x="692" y="' + (y + 38) + '" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="13">' + escapeXml(String(p[key]).toUpperCase()) + '</text>')
298
+ })
299
+ out.push('<text x="632" y="508" fill="' + p.muted + '" font-family="' + escapeXml(bodyFont) + '" font-size="12">' + escapeXml(pick(theme.bestFor)).slice(0, 44) + '</text>')
300
+ out.push('</svg>')
301
+ return out.join('')
302
+ }
303
+
304
+ /** 自包含主题对比页:5 套主题并排,含色板与用法。 */
305
+ export function renderThemePreviewHtml(lang = 'zh') {
306
+ const L = themePages(lang === 'en' ? 'en' : 'zh')
307
+ const cards = THEME_IDS.map((id) => {
308
+ const theme = THEMES[id]
309
+ const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
310
+ const chips = Object.keys(theme.palette).map((key) =>
311
+ '<span class="chip"><i style="background:' + theme.palette[key] + '"></i>' + escapeHtml(String(theme.palette[key]).toUpperCase()) + '</span>').join('')
312
+ return '<figure class="card" id="theme-' + escapeHtml(id) + '">' +
313
+ renderThemeSvg(theme, lang === 'en' ? 'en' : 'zh') +
314
+ '<figcaption><div class="row"><strong>' + escapeHtml(id) + '</strong><span class="badge">' + escapeHtml(pick(theme.name)) + '</span></div>' +
315
+ '<p class="muted">' + escapeHtml(pick(theme.mood)) + ' · ' + escapeHtml(pick(theme.bestFor)) + '</p>' +
316
+ '<p class="chips">' + chips + '</p>' +
317
+ '<p class="usage">' + escapeHtml(L.usage) + ': <code>' + escapeHtml(id) + '</code></p></figcaption></figure>'
318
+ }).join('')
319
+ return '<!DOCTYPE html>' + eolSafe('<html lang="zh-CN">') +
320
+ '<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">' +
321
+ '<title>' + escapeHtml(L.title) + '</title><style>' +
322
+ 'body{margin:0;background:#0b0f17;color:#e8f1ff;font-family:"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:28px}' +
323
+ 'h1{font-size:26px;margin:0 0 6px}p.sub{color:#8b98ad;margin:0 0 24px;font-size:14px}' +
324
+ '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(430px,1fr));gap:22px}' +
325
+ '.card{margin:0;background:#111827;border:1px solid #263043;border-radius:16px;overflow:hidden}' +
326
+ '.card svg{display:block;width:100%;height:auto}' +
327
+ 'figcaption{padding:14px 16px 16px}.row{display:flex;align-items:center;gap:10px;font-size:16px}' +
328
+ '.badge{color:#8b98ad;font-size:13px}.muted{color:#8b98ad;font-size:13px;margin:6px 0 10px;line-height:1.5}' +
329
+ '.chips{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#aab6c9;background:#0b0f17;border:1px solid #263043;border-radius:99px;padding:3px 9px}' +
330
+ '.chip i{width:10px;height:10px;border-radius:3px;display:inline-block}' +
331
+ '.usage{margin:0;font-size:13px;color:#aab6c9}code{background:#0b0f17;border:1px solid #263043;border-radius:6px;padding:1px 6px}' +
332
+ '@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}' +
333
+ '</style></head><body><h1>' + escapeHtml(L.title) + '</h1><p class="sub">' + escapeHtml(L.hint) + ' · dsh-ppt v' + escapeHtml(DECK_VERSION) + '</p>' +
334
+ '<div class="grid">' + cards + '</div></body></html>'
335
+ }
336
+
337
+ function eolSafe(text) { return text }
338
+
339
+ /** 写主题对比页 + 每套主题 SVG 色板卡;默认不覆盖同名文件。 */
340
+ export function buildThemePreview(options = {}) {
341
+ const langInput = typeof options.lang === 'string' && options.lang.trim() !== '' ? options.lang.trim() : 'zh'
342
+ const language = resolveLanguage(langInput)
343
+ const outputDir = resolvePath(String(options.outputDir ?? '.'))
344
+ mkdirSync(outputDir, { recursive: true })
345
+ const overwrite = options.overwrite === true
346
+ const pickPath = (base, ext) => {
347
+ const first = resolvePath(joinPath(outputDir, base + ext))
348
+ if (overwrite || !existsSync(first)) return first
349
+ for (let i = 1; i < 1000; i += 1) {
350
+ const candidate = resolvePath(joinPath(outputDir, base + '-' + i + ext))
351
+ if (!existsSync(candidate)) return candidate
352
+ }
353
+ throw new Error('dsh-ppt:找不到可用的预览文件名(同名前缀超过 999 个),请允许 overwrite。')
354
+ }
355
+ const htmlPath = pickPath('themes-preview', '.html')
356
+ writeFileSync(htmlPath, renderThemePreviewHtml(language.id), 'utf8')
357
+ const svgs = THEME_IDS.map((id) => {
358
+ const svgPath = pickPath('theme-' + id, '.svg')
359
+ writeFileSync(svgPath, renderThemeSvg(id, language.id), 'utf8')
360
+ return { id, path: svgPath }
361
+ })
362
+ return { ok: true, outputDir, htmlPath, svgs, themeCount: THEME_IDS.length, language: language.id }
363
+ }
364
+
365
+
362
366
  export function resolveTheme(input) {
363
367
  const id = String(input ?? DEFAULT_THEME).trim().toLowerCase()
364
368
  const theme = THEMES[id]
@@ -430,7 +434,7 @@ const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
430
434
  * 为三件套选择同一个可用文件名前缀。默认不覆盖:任一产物已存在时,
431
435
  * 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
432
436
  */
433
- function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
437
+ export function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
434
438
  // 一次 readdir 建集,替代逐个候选 existsSync 探测(最坏 ~3000 次 stat);
435
439
  // 统一小写比较,避免 Windows 大小写不敏感文件系统把已占用的名字判成可用。
436
440
  const existing = new Set(readdirSync(outputDir).map((name) => name.toLowerCase()))
@@ -794,11 +798,11 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
794
798
  return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
795
799
  }
796
800
 
797
- const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
801
+ const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table', ...MEDIA_LAYOUTS])
798
802
 
799
803
  function normalizeSlide(raw, index) {
800
804
  const source = (raw !== null && typeof raw === 'object') ? raw : {}
801
- const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : 'bullets'
805
+ const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : source.chart ? 'chart' : source.image ? 'image-right' : 'bullets'
802
806
  const title = stripInlineMarkdown(source.title ?? '')
803
807
  const subtitle = stripInlineMarkdown(source.subtitle ?? '')
804
808
  const kicker = stripInlineMarkdown(source.kicker ?? '')
@@ -811,6 +815,7 @@ function normalizeSlide(raw, index) {
811
815
  } else if (typeof source.bullets === 'string' && source.bullets.trim() !== '') {
812
816
  bullets = splitSentences(source.bullets)
813
817
  }
818
+ if (MEDIA_LAYOUTS.has(layout)) return normalizeMediaSlide(source, { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }, index)
814
819
  if (layout === 'statement' && title === '' && text !== '') {
815
820
  return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
816
821
  }
@@ -891,7 +896,7 @@ export function normalizeBuildOptions(options = {}) {
891
896
  * 任一步失败都清理临时文件,并把已提交的目标恢复为调用前的内容,
892
897
  * 避免出现「json/html 已更新、pptx 还是旧的」这类混版状态。
893
898
  */
894
- function commitDeckArtifacts(artifacts) {
899
+ export function commitDeckArtifacts(artifacts) {
895
900
  const stamp = process.pid.toString(36) + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
896
901
  const items = artifacts.map((item, index) => ({ ...item, tempPath: item.path + '.' + stamp + '-' + index + '.tmp' }))
897
902
  const removeTemp = (item) => {
@@ -945,6 +950,9 @@ function commitDeckArtifacts(artifacts) {
945
950
  }
946
951
 
947
952
  export function buildDeck(options = {}) {
953
+ if (options.brand || options.template || options.slides?.some(slide => MEDIA_LAYOUTS.has(slide.layout) || slide.image || slide.chart)) {
954
+ throw new Error('dsh-ppt:图片、图表、模板与品牌请使用异步 buildDeckAsync 或 ppt_create / CLI')
955
+ }
948
956
  const normalized = normalizeBuildOptions(options)
949
957
  const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
950
958
  mkdirSync(outputDir, { recursive: true })
@@ -966,7 +974,7 @@ export function buildDeck(options = {}) {
966
974
  // 先生成三份内容再统一提交:生成阶段抛错时也不会留下半套文件。
967
975
  commitDeckArtifacts([
968
976
  { path: jsonPath, data: JSON.stringify(manifest, null, 2) + '\n', encoding: 'utf8' },
969
- { path: htmlPath, data: renderHtml(manifest, theme, language), encoding: 'utf8' },
977
+ { path: htmlPath, data: renderHtml(manifest, theme, language, { pptx: pptxPath, json: jsonPath }), encoding: 'utf8' },
970
978
  { path: pptxPath, data: buildPptx(manifest, theme, language) },
971
979
  ])
972
980
 
@@ -992,189 +1000,192 @@ export function buildDeck(options = {}) {
992
1000
  // HTML 网页放映
993
1001
  // ---------------------------------------------------------------------------
994
1002
 
995
- export function renderHtml(manifest, theme, language) {
996
- const t = theme
997
- const lang = language
998
- const ui = lang.ui
999
- const motion = manifest.motion !== false
1000
- const motionCss = motion
1001
- ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
1002
- @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
1003
- body.motion .bullets li{
1004
- opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
1005
- animation-delay:calc(var(--i, 0)*90ms + .12s);
1006
- }
1007
- @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
1008
- : ''
1009
- const slides = manifest.slides.map((slide, index) => {
1010
- const label = slide.title || slide.kicker || (ui.slide + ' ' + (index + 1))
1011
- return '<div class="slide-frame" data-frame="' + (index + 1) + '" tabindex="-1" role="button" aria-label="' + escapeHtml(String(label)) + '">' +
1012
- renderHtmlSlide(slide, index, ui, lang.id) + '</div>'
1013
- }).join('\n')
1014
- const themeLabel = t.name[lang.id] ?? t.name.en
1003
+ export function renderHtml(manifest, theme, language, files = {}) {
1004
+ const t = theme
1005
+ const lang = language
1006
+ const ui = lang.ui
1007
+ const motion = manifest.motion !== false
1008
+ const motionCss = motion
1009
+ ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
1010
+ @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
1011
+ body.motion .bullets li{
1012
+ opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
1013
+ animation-delay:calc(var(--i, 0)*90ms + .12s);
1014
+ }
1015
+ @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
1016
+ : ''
1017
+ const slides = manifest.slides.map((slide, index) => {
1018
+ const label = slide.title || slide.kicker || (ui.slide + ' ' + (index + 1))
1019
+ return '<div class="slide-frame" data-frame="' + (index + 1) + '" tabindex="-1" role="button" aria-label="' + escapeHtml(String(label)) + '">' +
1020
+ renderHtmlSlide(slide, index, ui, lang.id, manifest, theme) + '</div>'
1021
+ }).join('\n')
1022
+ const themeLabel = t.name[lang.id] ?? t.name.en
1015
1023
  const total = manifest.slides.length
1016
-
1017
- return `<!DOCTYPE html>
1018
- <html lang="${lang.attr}">
1019
- <head>
1020
- <meta charset="UTF-8">
1021
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1022
- <link rel="icon" href="data:,">
1023
- <title>${escapeHtml(manifest.title)}</title>
1024
- <style>
1025
- :root{
1026
- --bg:${t.palette.bg};
1027
- --panel:${t.palette.panel};
1028
- --fg:${t.palette.fg};
1029
- --muted:${t.palette.muted};
1030
- --accent:${t.palette.accent};
1031
- --accent2:${t.palette.accent2};
1032
- --font-heading:${t.fonts.heading};
1033
- --font-body:${t.fonts.body};
1034
- }
1035
- *{box-sizing:border-box}
1036
- html,body{height:100%}
1037
- body{
1038
- margin:0;background:var(--bg);color:var(--fg);
1039
- font-family:var(--font-body);overflow:hidden;
1040
- -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
1041
- }
1042
- #stage{position:fixed;inset:0}
1043
- .slide-frame{position:fixed;inset:0}
1044
- .slide{
1045
- position:absolute;inset:0;display:none;flex-direction:column;justify-content:center;
1046
- padding:clamp(34px,7vw,110px);overflow:hidden;
1047
- }
1048
- .slide::before{
1049
- content:"";position:absolute;inset:-20%;pointer-events:none;z-index:-1;
1050
- background:
1051
- radial-gradient(42% 34% at 82% 18%, ${hexToRgba(t.palette.accent, t.dark ? 0.22 : 0.12)}, transparent 70%),
1052
- radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
1053
- radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
1054
- }
1055
- .slide.is-active{display:flex}
1056
- ${motionCss}
1057
- .kicker{
1058
- color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
1059
- font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
1060
- }
1061
- h1,h2,.statement-title{font-family:var(--font-heading);line-height:1.06;letter-spacing:-.015em;margin:0}
1062
- h1{font-size:clamp(44px,7.4vw,118px);max-width:20ch}
1063
- h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
1064
- .subtitle{
1065
- color:var(--muted);font-size:clamp(18px,2.3vw,34px);line-height:1.5;
1066
- max-width:46em;margin-top:28px;
1067
- }
1068
- .meta{
1069
- color:var(--muted);font-size:clamp(12px,1.2vw,16px);margin-top:48px;
1070
- letter-spacing:.06em;
1071
- }
1072
- .bullets ul{margin:30px 0 0;padding:0;list-style:none;display:grid;gap:clamp(12px,1.6vw,24px)}
1073
- .bullets li{
1074
- position:relative;padding-left:clamp(28px,2.6vw,44px);
1075
- font-size:clamp(20px,2.6vw,40px);line-height:1.35;max-width:24em;
1076
- }
1077
- .bullets li::before{
1078
- content:"";position:absolute;left:0;top:.58em;width:.5em;height:.5em;
1079
- background:var(--accent);border-radius:2px;box-shadow:.28em .28em 0 color-mix(in srgb, var(--accent2) 78%, transparent);
1080
- }
1081
- .section .kicker{margin-bottom:10px}
1082
- .section .accent-line{width:min(180px,18vw);height:6px;background:var(--accent);margin:28px 0}
1083
- .statement-title{
1084
- font-size:clamp(34px,5.4vw,88px);max-width:22ch;font-weight:800;
1085
- border-left:6px solid var(--accent);padding-left:clamp(22px,3vw,48px);
1086
- }
1087
- .closing{text-align:center;align-items:center}
1088
- .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
1089
- .closing .subtitle{color:var(--accent);font-weight:700}
1090
- .cover h1{font-weight:900}
1091
- .quote-mark{
1092
- font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
1093
- color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
1094
- }
1095
- .quote-text{
1096
- font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
1097
- font-weight:700;max-width:22ch;margin:0;
1098
- }
1099
- .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
1100
- table.deck-table{
1101
- border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:100%;
1102
- font-size:clamp(14px,1.55vw,23px);
1103
- }
1104
- .deck-table th{
1105
- background:color-mix(in srgb, var(--accent) 26%, var(--bg));color:var(--fg);
1106
- text-align:left;font-weight:700;padding:.6em .9em;letter-spacing:.02em;
1107
- border-bottom:2px solid var(--accent);
1108
- }
1109
- .deck-table td{
1110
- padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 55%, transparent);
1111
- color:var(--fg);
1112
- }
1113
- .deck-table .num{text-align:right;font-variant-numeric:tabular-nums}
1114
- .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
1115
- #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
1116
- #hud{
1117
- position:fixed;right:22px;bottom:18px;z-index:50;display:flex;gap:10px;align-items:center;
1118
- color:var(--muted);font-size:13px;letter-spacing:.08em;font-variant-numeric:tabular-nums;
1119
- }
1120
- #hud button{
1121
- background:color-mix(in srgb, var(--panel) 88%, transparent);color:var(--fg);
1122
- border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);border-radius:99px;
1123
- padding:8px 14px;min-height:34px;font:inherit;cursor:pointer;
1124
- }
1125
- #hud button:hover{border-color:var(--accent);color:var(--accent)}
1126
- #notes-panel{
1127
- position:fixed;left:0;right:0;bottom:64px;z-index:40;display:none;
1128
- background:color-mix(in srgb, var(--panel) 96%, black 4%);
1129
- border-top:2px solid var(--accent);
1130
- padding:14px clamp(22px,4vw,64px) 18px;max-height:38vh;overflow:auto;
1131
- }
1132
- body.notes-open #notes-panel{display:block}
1133
- #notes-panel .notes-head{display:flex;align-items:center;justify-content:space-between;gap:16px}
1134
- #notes-close{
1135
- background:transparent;color:var(--muted);border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);
1136
- border-radius:99px;padding:4px 12px;font:inherit;cursor:pointer;min-height:30px;
1137
- }
1138
- #notes-close:hover{color:var(--accent);border-color:var(--accent)}
1139
- #notes-panel .notes-label{
1140
- color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
1141
- font-size:12px;margin-bottom:8px;
1142
- }
1143
- #notes-text{white-space:pre-wrap;line-height:1.65;font-size:clamp(15px,1.5vw,18px);color:var(--fg)}
1144
- #notes-text.is-empty{color:var(--muted);font-style:italic}
1145
- #help-panel{
1146
- position:fixed;right:22px;bottom:64px;z-index:60;max-width:390px;
1147
- background:color-mix(in srgb, var(--panel) 96%, black 4%);color:var(--fg);
1148
- border:1px solid color-mix(in srgb, var(--accent) 55%, transparent);border-radius:14px;
1149
- padding:16px 20px;box-shadow:0 18px 50px rgba(0,0,0,.45);font-size:14px;line-height:1.8;
1150
- }
1151
- #help-panel[hidden]{display:none}
1152
- #help-panel kbd{
1153
- background:color-mix(in srgb, var(--accent) 22%, transparent);border-radius:5px;
1154
- padding:1px 6px;font-family:inherit;font-size:12px;
1155
- }
1156
- #toast{
1157
- position:fixed;left:50%;bottom:84px;transform:translateX(-50%);z-index:70;
1158
- background:color-mix(in srgb, var(--panel) 96%, black 4%);color:var(--fg);
1159
- border:1px solid color-mix(in srgb, var(--accent) 55%, transparent);border-radius:99px;
1160
- padding:9px 18px;font-size:13px;
1161
- }
1162
- #toast[hidden]{display:none}
1163
- #blank{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}
1164
- body.blank-black #blank{display:block;background:#000}
1165
- body.blank-white #blank{display:block;background:#fff}
1166
- body.blank-black #hud,body.blank-white #hud{visibility:hidden;pointer-events:none}
1167
- :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1168
- body.overview #stage{
1169
- display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px;
1170
- padding:18px 18px 92px;position:fixed;inset:0;overflow:auto;align-content:start;
1171
- }
1172
- body.overview .slide-frame{
1173
- position:relative;inset:auto;width:100%;aspect-ratio:16/9;overflow:hidden;
1174
- border:1px solid color-mix(in srgb, var(--muted) 35%, transparent);border-radius:12px;
1175
- cursor:pointer;background:var(--bg);outline:none;
1176
- }
1177
- body.overview .slide-frame:hover{border-color:var(--accent)}
1024
+ const delivery = renderDelivery(manifest, files, lang.id)
1025
+
1026
+ return `<!DOCTYPE html>
1027
+ <html lang="${lang.attr}">
1028
+ <head>
1029
+ <meta charset="UTF-8">
1030
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1031
+ <link rel="icon" href="data:,">
1032
+ <title>${escapeHtml(manifest.title)}</title>
1033
+ <style>
1034
+ :root{
1035
+ --bg:${t.palette.bg};
1036
+ --panel:${t.palette.panel};
1037
+ --fg:${t.palette.fg};
1038
+ --muted:${t.palette.muted};
1039
+ --accent:${t.palette.accent};
1040
+ --accent2:${t.palette.accent2};
1041
+ --font-heading:${t.fonts.heading};
1042
+ --font-body:${t.fonts.body};
1043
+ }
1044
+ *{box-sizing:border-box}
1045
+ html,body{height:100%}
1046
+ body{
1047
+ margin:0;background:var(--bg);color:var(--fg);
1048
+ font-family:var(--font-body);overflow:hidden;
1049
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
1050
+ }
1051
+ #stage{position:fixed;inset:0}
1052
+ .slide-frame{position:fixed;inset:0}
1053
+ .slide{
1054
+ position:absolute;inset:0;display:none;flex-direction:column;justify-content:center;
1055
+ padding:clamp(34px,7vw,110px);overflow:hidden;
1056
+ }
1057
+ .slide::before{
1058
+ content:"";position:absolute;inset:-20%;pointer-events:none;z-index:-1;
1059
+ background:
1060
+ radial-gradient(42% 34% at 82% 18%, ${hexToRgba(t.palette.accent, t.dark ? 0.22 : 0.12)}, transparent 70%),
1061
+ radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
1062
+ radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
1063
+ }
1064
+ .slide.is-active{display:flex}
1065
+ ${motionCss}
1066
+ ${MEDIA_CSS}
1067
+ .kicker{
1068
+ color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
1069
+ font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
1070
+ }
1071
+ h1,h2,.statement-title{font-family:var(--font-heading);line-height:1.06;letter-spacing:-.015em;margin:0}
1072
+ h1{font-size:clamp(44px,7.4vw,118px);max-width:20ch}
1073
+ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
1074
+ .subtitle{
1075
+ color:var(--muted);font-size:clamp(18px,2.3vw,34px);line-height:1.5;
1076
+ max-width:46em;margin-top:28px;
1077
+ }
1078
+ .meta{
1079
+ color:var(--muted);font-size:clamp(12px,1.2vw,16px);margin-top:48px;
1080
+ letter-spacing:.06em;
1081
+ }
1082
+ .bullets ul{margin:30px 0 0;padding:0;list-style:none;display:grid;gap:clamp(12px,1.6vw,24px)}
1083
+ .bullets li{
1084
+ position:relative;padding-left:clamp(28px,2.6vw,44px);
1085
+ font-size:clamp(20px,2.6vw,40px);line-height:1.35;max-width:24em;
1086
+ }
1087
+ .bullets li::before{
1088
+ content:"";position:absolute;left:0;top:.58em;width:.5em;height:.5em;
1089
+ background:var(--accent);border-radius:2px;box-shadow:.28em .28em 0 color-mix(in srgb, var(--accent2) 78%, transparent);
1090
+ }
1091
+ .section .kicker{margin-bottom:10px}
1092
+ .section .accent-line{width:min(180px,18vw);height:6px;background:var(--accent);margin:28px 0}
1093
+ .statement-title{
1094
+ font-size:clamp(34px,5.4vw,88px);max-width:22ch;font-weight:800;
1095
+ border-left:6px solid var(--accent);padding-left:clamp(22px,3vw,48px);
1096
+ }
1097
+ .closing{text-align:center;align-items:center}
1098
+ .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
1099
+ .closing .subtitle{color:var(--accent);font-weight:700}
1100
+ .cover h1{font-weight:900}
1101
+ .quote-mark{
1102
+ font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
1103
+ color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
1104
+ }
1105
+ .quote-text{
1106
+ font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
1107
+ font-weight:700;max-width:22ch;margin:0;
1108
+ }
1109
+ .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
1110
+ table.deck-table{
1111
+ border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:100%;
1112
+ font-size:clamp(14px,1.55vw,23px);
1113
+ }
1114
+ .deck-table th{
1115
+ background:color-mix(in srgb, var(--accent) 26%, var(--bg));color:var(--fg);
1116
+ text-align:left;font-weight:700;padding:.6em .9em;letter-spacing:.02em;
1117
+ border-bottom:2px solid var(--accent);
1118
+ }
1119
+ .deck-table td{
1120
+ padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 55%, transparent);
1121
+ color:var(--fg);
1122
+ }
1123
+ .deck-table .num{text-align:right;font-variant-numeric:tabular-nums}
1124
+ .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
1125
+ #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
1126
+ #hud{
1127
+ position:fixed;left:22px;right:22px;bottom:18px;z-index:50;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px;align-items:center;
1128
+ color:var(--muted);font-size:13px;letter-spacing:.08em;font-variant-numeric:tabular-nums;
1129
+ }
1130
+ #hud button{
1131
+ background:color-mix(in srgb, var(--panel) 88%, transparent);color:var(--fg);
1132
+ border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);border-radius:99px;
1133
+ padding:8px 14px;min-height:34px;font:inherit;cursor:pointer;
1134
+ }
1135
+ #hud button:hover{border-color:var(--accent);color:var(--accent)}
1136
+ #notes-panel{
1137
+ position:fixed;left:0;right:0;bottom:64px;z-index:40;display:none;
1138
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);
1139
+ border-top:2px solid var(--accent);
1140
+ padding:14px clamp(22px,4vw,64px) 18px;max-height:38vh;overflow:auto;
1141
+ }
1142
+ body.notes-open #notes-panel{display:block}
1143
+ #notes-panel .notes-head{display:flex;align-items:center;justify-content:space-between;gap:16px}
1144
+ #notes-close{
1145
+ background:transparent;color:var(--muted);border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);
1146
+ border-radius:99px;padding:4px 12px;font:inherit;cursor:pointer;min-height:30px;
1147
+ }
1148
+ #notes-close:hover{color:var(--accent);border-color:var(--accent)}
1149
+ #notes-panel .notes-label{
1150
+ color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
1151
+ font-size:12px;margin-bottom:8px;
1152
+ }
1153
+ #notes-text{white-space:pre-wrap;line-height:1.65;font-size:clamp(15px,1.5vw,18px);color:var(--fg)}
1154
+ #notes-text.is-empty{color:var(--muted);font-style:italic}
1155
+ #help-panel{
1156
+ position:fixed;right:22px;bottom:64px;z-index:60;max-width:390px;
1157
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);color:var(--fg);
1158
+ border:1px solid color-mix(in srgb, var(--accent) 55%, transparent);border-radius:14px;
1159
+ padding:16px 20px;box-shadow:0 18px 50px rgba(0,0,0,.45);font-size:14px;line-height:1.8;
1160
+ }
1161
+ #help-panel[hidden]{display:none}
1162
+ #help-panel kbd{
1163
+ background:color-mix(in srgb, var(--accent) 22%, transparent);border-radius:5px;
1164
+ padding:1px 6px;font-family:inherit;font-size:12px;
1165
+ }
1166
+ #toast{
1167
+ position:fixed;left:50%;bottom:84px;transform:translateX(-50%);z-index:70;
1168
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);color:var(--fg);
1169
+ border:1px solid color-mix(in srgb, var(--accent) 55%, transparent);border-radius:99px;
1170
+ padding:9px 18px;font-size:13px;
1171
+ }
1172
+ #toast[hidden]{display:none}
1173
+ #blank{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}
1174
+ body.blank-black #blank{display:block;background:#000}
1175
+ body.blank-white #blank{display:block;background:#fff}
1176
+ body.blank-black #hud,body.blank-white #hud{visibility:hidden;pointer-events:none}
1177
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1178
+ body.overview #stage{
1179
+ display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px;
1180
+ grid-auto-rows:var(--deck-thumb-height,180px);
1181
+ padding:18px 18px 92px;position:fixed;inset:0;overflow:auto;align-content:start;
1182
+ }
1183
+ body.overview .slide-frame{
1184
+ position:relative;inset:auto;width:100%;aspect-ratio:16/9;overflow:hidden;
1185
+ border:1px solid color-mix(in srgb, var(--muted) 35%, transparent);border-radius:12px;
1186
+ cursor:pointer;background:var(--bg);outline:none;
1187
+ }
1188
+ body.overview .slide-frame:hover{border-color:var(--accent)}
1178
1189
  body.overview .slide-frame.is-current{outline:2px solid var(--accent);outline-offset:2px}
1179
1190
  body.overview .slide-frame:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
1180
1191
  body.overview .slide-frame::after{
@@ -1182,125 +1193,130 @@ body.overview .slide-frame::after{
1182
1193
  font-size:12px;color:var(--muted);background:color-mix(in srgb, var(--bg) 84%, transparent);
1183
1194
  border:1px solid color-mix(in srgb, var(--muted) 40%, transparent);border-radius:6px;
1184
1195
  padding:1px 7px;font-variant-numeric:tabular-nums;
1185
- }
1186
- body.overview .slide{
1187
- display:flex !important;position:absolute;inset:0;width:100vw;height:100vh;
1188
- transform:scale(var(--deck-thumb-scale,.25));transform-origin:top left;pointer-events:none;
1189
- animation:none !important;
1190
- }
1191
- body.overview .bullets li{opacity:1 !important;animation:none !important}
1192
- body.overview #notes-panel,body.presenting #notes-panel{display:none !important}
1196
+ }
1197
+ body.overview .slide{
1198
+ display:flex !important;position:absolute;inset:0;width:1280px;height:720px;
1199
+ transform:scale(var(--deck-thumb-scale,.25));transform-origin:top left;pointer-events:none;
1200
+ animation:none !important;
1201
+ }
1202
+ body.overview .bullets li{opacity:1 !important;animation:none !important}
1203
+ body.overview .media-canvas{width:1280px;height:720px}
1204
+ body.overview #notes-panel,body.presenting #notes-panel{display:none !important}
1193
1205
  body.presenting #notes-toggle{opacity:.55}
1194
1206
  body.presenting #hud{opacity:0;pointer-events:none;transition:opacity .25s}
1195
- body.presenting #hud:hover{opacity:1;pointer-events:auto}
1196
- @media (max-width:900px){
1197
- .slide{padding:clamp(22px,5vw,44px)}
1198
- h1{font-size:clamp(34px,8.5vw,64px)}
1199
- h2{font-size:clamp(26px,6.5vw,48px)}
1200
- .statement-title{font-size:clamp(26px,7vw,54px)}
1201
- .subtitle{font-size:clamp(16px,3.4vw,24px)}
1202
- .bullets li{font-size:clamp(16px,3.4vw,24px)}
1203
- .kicker{font-size:13px}
1204
- .deck-table{font-size:14px}
1205
- .deck-table th,.deck-table td{padding:.55em .6em}
1206
- #hud{right:10px;bottom:10px;gap:8px;font-size:12px}
1207
- #hud button{padding:10px 16px;min-height:44px}
1208
- #notes-panel{bottom:68px;max-height:45vh}
1209
- #help-panel{right:10px;bottom:68px;left:10px;max-width:none}
1210
- }
1211
- @media (max-width:640px){
1212
- #theme-label{display:none}
1213
- }
1214
- @media (prefers-reduced-motion: reduce){
1215
- *,*::before,*::after{animation:none !important;transition:none !important}
1216
- }
1217
- @media print{
1218
- html,body{height:auto;overflow:visible;background:#fff}
1219
- .slide-frame{position:relative;display:block !important}
1220
- .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
1221
- ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
1222
- #progress,#hud,#notes-panel,#help-panel,#toast{display:none !important}
1223
- }
1207
+ body.presenting #hud:hover{opacity:1;pointer-events:auto}
1208
+ @media (max-width:900px){
1209
+ .slide{padding:clamp(22px,5vw,44px)}
1210
+ h1{font-size:clamp(34px,8.5vw,64px)}
1211
+ h2{font-size:clamp(26px,6.5vw,48px)}
1212
+ .statement-title{font-size:clamp(26px,7vw,54px)}
1213
+ .subtitle{font-size:clamp(16px,3.4vw,24px)}
1214
+ .bullets li{font-size:clamp(16px,3.4vw,24px)}
1215
+ .kicker{font-size:13px}
1216
+ .deck-table{font-size:14px}
1217
+ .deck-table th,.deck-table td{padding:.55em .6em}
1218
+ #hud{left:10px;right:10px;bottom:10px;gap:6px;font-size:12px}
1219
+ #hud button{padding:10px 16px;min-height:44px}
1220
+ #notes-panel{bottom:68px;max-height:45vh}
1221
+ #help-panel{right:10px;bottom:68px;left:10px;max-width:none}
1222
+ }
1223
+ @media (max-width:640px){
1224
+ #theme-label{display:none}
1225
+ }
1226
+ @media (prefers-reduced-motion: reduce){
1227
+ *,*::before,*::after{animation:none !important;transition:none !important}
1228
+ }
1229
+ @media print{
1230
+ html,body{height:auto;overflow:visible;background:#fff}
1231
+ .slide-frame{position:relative;display:block !important}
1232
+ .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
1233
+ ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
1234
+ #progress,#hud,#notes-panel,#help-panel,#toast{display:none !important}
1235
+ }
1236
+ ${delivery.css}
1224
1237
  </style>
1225
1238
  </head>
1226
1239
  <body class="${motion ? 'motion' : 'no-motion'}">
1227
1240
  <div id="progress" aria-hidden="true"></div>
1228
- <div id="stage">
1229
- ${slides}
1230
- </div>
1231
- <div id="hud" aria-live="polite">
1241
+ ${delivery.body}
1242
+ <div id="stage">
1243
+ ${slides}
1244
+ </div>
1245
+ <div id="hud" aria-live="polite">
1232
1246
  <span id="counter">${ui.slide} 1 ${ui.of} ${total}</span>
1233
- <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
1234
- <button id="notes-toggle" type="button" title="S · ${escapeHtml(ui.notesToggle)}" aria-controls="notes-panel" aria-expanded="false">${escapeHtml(ui.notesToggle)}</button>
1235
- <button id="presenter-toggle" type="button" title="V · ${escapeHtml(ui.presenterToggle)}">${escapeHtml(ui.presenterToggle)}</button>
1236
- <button id="overview-toggle" type="button" title="G · ${escapeHtml(ui.overviewToggle)}" aria-pressed="false">${escapeHtml(ui.overviewToggle)}</button>
1237
- <button id="help-toggle" type="button" title="?" aria-label="${escapeHtml(ui.helpTitle)}">?</button>
1238
- <button id="fullscreen" type="button" title="F · Fullscreen" aria-label="Fullscreen">⛶</button>
1239
- </div>
1240
- <div id="notes-panel" role="region" aria-label="${escapeHtml(ui.notesLabel)}">
1241
- <div class="notes-head">
1242
- <span class="notes-label">${escapeHtml(ui.notesLabel)}</span>
1243
- <button id="notes-close" type="button" aria-label="${escapeHtml(ui.notesCloseLabel)}">×</button>
1244
- </div>
1245
- <div id="notes-text"></div>
1246
- </div>
1247
- <div id="help-panel" hidden>
1248
- <div class="notes-label">${escapeHtml(ui.helpTitle)}</div>
1249
- <p><kbd>←</kbd> <kbd>→</kbd> / <kbd>Space</kbd> 翻页 · <kbd>Home</kbd> / <kbd>End</kbd> 首末页 · <kbd>S</kbd> ${escapeHtml(ui.notesToggle)} · <kbd>V</kbd> ${escapeHtml(ui.presenterToggle)} · <kbd>G</kbd> ${escapeHtml(ui.overviewToggle)} · <kbd>F</kbd> 全屏 · <kbd>P</kbd> 打印 · <kbd>B</kbd> 黑屏 · <kbd>W</kbd> 白屏 · <kbd>Esc</kbd> ${escapeHtml(ui.closeLabel)} · <kbd>?</kbd> ${escapeHtml(ui.helpTitle)}</p>
1250
- <p>${escapeHtml(ui.presenterHint)}</p>
1251
- <p>${escapeHtml(ui.overviewHint)}</p>
1252
- </div>
1253
- <div id="toast" role="status" hidden></div>
1254
- <div id="blank" aria-hidden="true"></div>
1255
- <script>
1256
- (() => {
1257
- const frames = Array.from(document.querySelectorAll('.slide-frame'));
1258
- const slides = Array.from(document.querySelectorAll('.slide'));
1259
- const counter = document.getElementById('counter');
1260
- const progress = document.getElementById('progress');
1261
- const fullscreenBtn = document.getElementById('fullscreen');
1262
- const notesPanel = document.getElementById('notes-panel');
1263
- const notesText = document.getElementById('notes-text');
1264
- const notesBtn = document.getElementById('notes-toggle');
1265
- const notesClose = document.getElementById('notes-close');
1266
- const presenterBtn = document.getElementById('presenter-toggle');
1267
- const overviewBtn = document.getElementById('overview-toggle');
1268
- const helpBtn = document.getElementById('help-toggle');
1269
- const helpPanel = document.getElementById('help-panel');
1270
- const toastEl = document.getElementById('toast');
1247
+ ${delivery.navigation}
1248
+ <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
1249
+ <button id="notes-toggle" type="button" title="S · ${escapeHtml(ui.notesToggle)}" aria-controls="notes-panel" aria-expanded="false">${escapeHtml(ui.notesToggle)}</button>
1250
+ <button id="presenter-toggle" type="button" title="V · ${escapeHtml(ui.presenterToggle)}">${escapeHtml(ui.presenterToggle)}</button>
1251
+ <button id="overview-toggle" type="button" title="G · ${escapeHtml(ui.overviewToggle)}" aria-pressed="false">${escapeHtml(ui.overviewToggle)}</button>
1252
+ <button id="help-toggle" type="button" title="?" aria-label="${escapeHtml(ui.helpTitle)}">?</button>
1253
+ <button id="fullscreen" type="button" title="F · Fullscreen" aria-label="Fullscreen">⛶</button>
1254
+ </div>
1255
+ <div id="notes-panel" role="region" aria-label="${escapeHtml(ui.notesLabel)}">
1256
+ <div class="notes-head">
1257
+ <span class="notes-label">${escapeHtml(ui.notesLabel)}</span>
1258
+ <button id="notes-close" type="button" aria-label="${escapeHtml(ui.notesCloseLabel)}">×</button>
1259
+ </div>
1260
+ <div id="notes-text"></div>
1261
+ </div>
1262
+ <div id="help-panel" hidden>
1263
+ <div class="notes-label">${escapeHtml(ui.helpTitle)}</div>
1264
+ <p><kbd>←</kbd> <kbd>→</kbd> / <kbd>Space</kbd> 翻页 · <kbd>Home</kbd> / <kbd>End</kbd> 首末页 · <kbd>S</kbd> ${escapeHtml(ui.notesToggle)} · <kbd>V</kbd> ${escapeHtml(ui.presenterToggle)} · <kbd>G</kbd> ${escapeHtml(ui.overviewToggle)} · <kbd>F</kbd> 全屏 · <kbd>P</kbd> 打印 · <kbd>B</kbd> 黑屏 · <kbd>W</kbd> 白屏 · <kbd>Esc</kbd> ${escapeHtml(ui.closeLabel)} · <kbd>?</kbd> ${escapeHtml(ui.helpTitle)}</p>
1265
+ <p>${escapeHtml(ui.presenterHint)}</p>
1266
+ <p>${escapeHtml(ui.overviewHint)}</p>
1267
+ </div>
1268
+ <div id="toast" role="status" hidden></div>
1269
+ <div id="blank" aria-hidden="true"></div>
1270
+ <script>
1271
+ (() => {
1272
+ const frames = Array.from(document.querySelectorAll('.slide-frame'));
1273
+ const slides = Array.from(document.querySelectorAll('.slide'));
1274
+ const counter = document.getElementById('counter');
1275
+ const progress = document.getElementById('progress');
1276
+ const fullscreenBtn = document.getElementById('fullscreen');
1277
+ const notesPanel = document.getElementById('notes-panel');
1278
+ const notesText = document.getElementById('notes-text');
1279
+ const notesBtn = document.getElementById('notes-toggle');
1280
+ const notesClose = document.getElementById('notes-close');
1281
+ const presenterBtn = document.getElementById('presenter-toggle');
1282
+ const overviewBtn = document.getElementById('overview-toggle');
1283
+ const helpBtn = document.getElementById('help-toggle');
1284
+ const helpPanel = document.getElementById('help-panel');
1285
+ const toastEl = document.getElementById('toast');
1271
1286
  let index = 0;
1272
- let presenterWin = null;
1273
- let presenterInitSent = false;
1274
- let toastTimer = 0;
1275
- const total = slides.length;
1276
- const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
1277
-
1278
- function notesOf(i) { return slides[i] && slides[i].dataset ? (slides[i].dataset.notes || '') : ''; }
1279
- function titleOf(i) {
1280
- const el = slides[i] ? slides[i].querySelector('h1,h2,.statement-title,.quote-text') : null;
1281
- return el ? el.textContent.trim() : '';
1282
- }
1283
- function state() {
1284
- const next = (index + 1) % total;
1285
- return { index: index, total: total, title: titleOf(index), notes: notesOf(index), nextTitle: titleOf(next), nextNotes: notesOf(next) };
1286
- }
1287
- window.__dshPpt = { go: function (delta) { go(index + delta); }, jump: function (i) { go(i); }, state: state };
1288
-
1289
- function toast(message) {
1290
- toastEl.textContent = message;
1291
- toastEl.hidden = false;
1292
- clearTimeout(toastTimer);
1293
- toastTimer = setTimeout(function () { toastEl.hidden = true; }, 2600);
1294
- }
1295
-
1296
- function syncNotes() {
1297
- const notes = notesOf(index);
1298
- notesText.textContent = notes || ui.noNotes;
1299
- notesText.classList.toggle('is-empty', notes === '');
1300
- notesBtn.setAttribute('aria-expanded', document.body.classList.contains('notes-open') ? 'true' : 'false');
1301
- updatePresenter();
1302
- }
1303
-
1287
+ let syncDelivery = () => {};
1288
+ let presenterWin = null;
1289
+ let presenterInitSent = false;
1290
+ let toastTimer = 0;
1291
+ const total = slides.length;
1292
+ const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
1293
+
1294
+ function notesOf(i) { return slides[i] && slides[i].dataset ? (slides[i].dataset.notes || '') : ''; }
1295
+ function titleOf(i) {
1296
+ const el = slides[i] ? slides[i].querySelector('h1,h2,.statement-title,.quote-text') : null;
1297
+ return el ? el.textContent.trim() : '';
1298
+ }
1299
+ function state() {
1300
+ const next = (index + 1) % total;
1301
+ return { index: index, total: total, title: titleOf(index), notes: notesOf(index), nextTitle: titleOf(next), nextNotes: notesOf(next) };
1302
+ }
1303
+ window.__dshPpt = { go: function (delta) { go(index + delta); }, jump: function (i) { go(i); }, state: state };
1304
+
1305
+ function toast(message) {
1306
+ toastEl.textContent = message;
1307
+ toastEl.hidden = false;
1308
+ clearTimeout(toastTimer);
1309
+ toastTimer = setTimeout(function () { toastEl.hidden = true; }, 2600);
1310
+ }
1311
+
1312
+ function syncNotes() {
1313
+ const notes = notesOf(index);
1314
+ notesText.textContent = notes || ui.noNotes;
1315
+ notesText.classList.toggle('is-empty', notes === '');
1316
+ notesBtn.setAttribute('aria-expanded', document.body.classList.contains('notes-open') ? 'true' : 'false');
1317
+ updatePresenter();
1318
+ }
1319
+
1304
1320
  function sendInit() {
1305
1321
  if (!presenterOpen()) return;
1306
1322
  try {
@@ -1333,51 +1349,55 @@ ${slides}
1333
1349
 
1334
1350
  function updatePresenter() { sendState(); }
1335
1351
 
1336
- function go(next) {
1337
- if (document.body.classList.contains('blank-black') || document.body.classList.contains('blank-white')) setBlank(null);
1338
- const wrapped = next < 0 || next >= total;
1339
- index = (next + total) % total;
1340
- slides.forEach(function (slide, i) {
1341
- slide.classList.toggle('is-active', i === index);
1342
- if (frames[i]) frames[i].classList.toggle('is-current', i === index);
1343
- });
1352
+ function go(next) {
1353
+ if (document.body.classList.contains('blank-black') || document.body.classList.contains('blank-white')) setBlank(null);
1354
+ const wrapped = next < 0 || next >= total;
1355
+ index = (next + total) % total;
1356
+ slides.forEach(function (slide, i) {
1357
+ slide.classList.toggle('is-active', i === index);
1358
+ if (frames[i]) frames[i].classList.toggle('is-current', i === index);
1359
+ });
1344
1360
  counter.textContent = ui.slide + ' ' + (index + 1) + ' ' + ui.of + ' ' + total;
1345
- progress.style.width = ((index + 1) / total * 100) + '%';
1346
- document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
1347
- try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch (_) { /* file:// 下个别浏览器可能拒绝 */ }
1348
- if (wrapped) toast(ui.wrapHint + ' ' + (index + 1) + ' / ' + total);
1349
- syncNotes();
1350
- }
1351
-
1352
- function notesOpen() { return document.body.classList.contains('notes-open'); }
1353
- function presenterOpen() { try { return !!(presenterWin && !presenterWin.closed); } catch (_) { return false; } }
1354
-
1355
- function setNotes(on) {
1356
- document.body.classList.toggle('notes-open', on);
1357
- if (on) setOverview(false);
1358
- syncNotes();
1359
- }
1360
-
1361
- function updateThumbScale() {
1362
- if (!document.body.classList.contains('overview')) return;
1363
- requestAnimationFrame(function () {
1364
- const width = frames[0] ? frames[0].getBoundingClientRect().width : 0;
1365
- if (width > 0) document.documentElement.style.setProperty('--deck-thumb-scale', String(width / window.innerWidth));
1366
- });
1367
- }
1368
-
1369
- function setOverview(on) {
1370
- document.body.classList.toggle('overview', on);
1371
- overviewBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1372
- frames.forEach(function (frame) { frame.tabIndex = on ? 0 : -1; });
1373
- if (on) {
1374
- document.body.classList.remove('notes-open');
1375
- helpPanel.hidden = true;
1376
- syncNotes();
1377
- updateThumbScale();
1378
- }
1379
- }
1380
-
1361
+ syncDelivery(index);
1362
+ progress.style.width = ((index + 1) / total * 100) + '%';
1363
+ document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
1364
+ try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch (_) { /* file:// 下个别浏览器可能拒绝 */ }
1365
+ if (wrapped) toast(ui.wrapHint + ' ' + (index + 1) + ' / ' + total);
1366
+ syncNotes();
1367
+ }
1368
+
1369
+ function notesOpen() { return document.body.classList.contains('notes-open'); }
1370
+ function presenterOpen() { try { return !!(presenterWin && !presenterWin.closed); } catch (_) { return false; } }
1371
+
1372
+ function setNotes(on) {
1373
+ document.body.classList.toggle('notes-open', on);
1374
+ if (on) setOverview(false);
1375
+ syncNotes();
1376
+ }
1377
+
1378
+ function updateThumbScale() {
1379
+ if (!document.body.classList.contains('overview')) return;
1380
+ requestAnimationFrame(function () {
1381
+ const width = frames[0] ? frames[0].getBoundingClientRect().width : 0;
1382
+ if (width > 0) {
1383
+ document.documentElement.style.setProperty('--deck-thumb-scale', String(width / 1280));
1384
+ document.documentElement.style.setProperty('--deck-thumb-height', String(width * 9 / 16) + 'px');
1385
+ }
1386
+ });
1387
+ }
1388
+
1389
+ function setOverview(on) {
1390
+ document.body.classList.toggle('overview', on);
1391
+ overviewBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1392
+ frames.forEach(function (frame) { frame.tabIndex = on ? 0 : -1; });
1393
+ if (on) {
1394
+ document.body.classList.remove('notes-open');
1395
+ helpPanel.hidden = true;
1396
+ syncNotes();
1397
+ updateThumbScale();
1398
+ }
1399
+ }
1400
+
1381
1401
  function presenterHtml() {
1382
1402
  const labelCurrent = ${JSON.stringify(ui.currentLabel)};
1383
1403
  const labelNext = ${JSON.stringify(ui.nextLabel)};
@@ -1424,7 +1444,7 @@ ${slides}
1424
1444
  'function fmt(ms){var s=Math.max(0,Math.floor(ms/1000)),m=Math.floor(s/60);s=s%60;var h=Math.floor(m/60);m=m%60;return (h>0?(h<10?"0":"")+h+":":"")+(m<10?"0":"")+m+":"+(s<10?"0":"")+s}',
1425
1445
  'function clock(){var d=new Date();return ("0"+d.getHours()).slice(-2)+":"+("0"+d.getMinutes()).slice(-2)+":"+("0"+d.getSeconds()).slice(-2)}',
1426
1446
  'function tick(){if(!paused)elapsed=Date.now()-started;q("ptimer").textContent=fmt(elapsed);q("pclock").textContent=clock();if(window.requestAnimationFrame)requestAnimationFrame(tick);else setTimeout(tick,250)}',
1427
- 'function fitBox(box){var rw=box.clientWidth||320,rh=box.clientHeight||180;var s=Math.min(rw/window.innerWidth,rh/window.innerHeight);var ox=(rw-window.innerWidth*s)/2,oy=(rh-window.innerHeight*s)/2;box.style.setProperty("--pv-scale",String(s));box.style.setProperty("--pv-x",ox+"px");box.style.setProperty("--pv-y",oy+"px")}',
1447
+ 'function fitBox(box){var rw=box.clientWidth||320,rh=box.clientHeight||180;var s=Math.min(rw/window.innerWidth,rh/window.innerHeight);var ox=(rw-window.innerWidth*s)/2,oy=(rh-window.innerHeight*s)/2;box.style.setProperty("--pv-scale",String(s));box.style.setProperty("--pv-x",ox+"px");box.style.setProperty("--pv-y",oy+"px")}',
1428
1448
  'function fit(){fitBox(q("pv-cur"));fitBox(q("pv-next"));var th=q("thumbs").children;for(var i=0;i<th.length;i++){fitBox(th[i])}}',
1429
1449
  'function setSlide(box,html){box.innerHTML="";var f=document.createElement("div");f.className="pv-slide";f.innerHTML=html;box.appendChild(f)}',
1430
1450
  'function buildThumbs(){var box=q("thumbs");box.innerHTML="";slides.forEach(function(html,i){var t=document.createElement("div");t.className="thumb";var f=document.createElement("div");f.className="pv-slide";f.innerHTML=html;t.appendChild(f);t.addEventListener("click",function(){post({__dshPpt:"cmd",kind:"go",index:i})});box.appendChild(t)});fit()}',
@@ -1458,25 +1478,25 @@ ${slides}
1458
1478
  + '<scr' + 'ipt>' + js + '</scr' + 'ipt></body></html>';
1459
1479
  }
1460
1480
 
1461
- async function toggleFullscreen() {
1462
- try {
1463
- if (document.fullscreenElement) { await document.exitFullscreen?.(); return; }
1464
- if (!document.documentElement.requestFullscreen || document.fullscreenEnabled === false) {
1465
- toast(ui.fullscreenUnavailable);
1466
- return;
1467
- }
1468
- await document.documentElement.requestFullscreen();
1469
- if (!document.fullscreenElement) toast(ui.fullscreenUnavailable);
1470
- } catch (_) { toast(ui.fullscreenUnavailable); }
1471
- }
1472
-
1473
- function openPresenter() {
1481
+ async function toggleFullscreen() {
1482
+ try {
1483
+ if (document.fullscreenElement) { await document.exitFullscreen?.(); return; }
1484
+ if (!document.documentElement.requestFullscreen || document.fullscreenEnabled === false) {
1485
+ toast(ui.fullscreenUnavailable);
1486
+ return;
1487
+ }
1488
+ await document.documentElement.requestFullscreen();
1489
+ if (!document.fullscreenElement) toast(ui.fullscreenUnavailable);
1490
+ } catch (_) { toast(ui.fullscreenUnavailable); }
1491
+ }
1492
+
1493
+ function openPresenter() {
1474
1494
  if (presenterOpen()) { try { presenterWin.focus(); } catch (_) { /* gone */ } return; }
1475
1495
  let url = '';
1476
1496
  try {
1477
1497
  url = URL.createObjectURL(new Blob([presenterHtml()], { type: 'text/html' }));
1478
1498
  } catch (_) { toast(ui.popupBlocked); return; }
1479
- presenterInitSent = false;
1499
+ presenterInitSent = false;
1480
1500
  presenterWin = window.open(url, 'dsh-ppt-presenter', 'width=1180,height=760');
1481
1501
  if (!presenterWin) {
1482
1502
  try { URL.revokeObjectURL(url); } catch (_) { /* ignore */ }
@@ -1518,10 +1538,10 @@ ${slides}
1518
1538
  const data = event.data || {};
1519
1539
  if (data.__dshPpt === 'cmd' && (data.kind === 'end' || data.kind === 'closed')) { closePresenter(); return; }
1520
1540
  if (!presenterOpen()) return;
1521
- if (data.__dshPpt === 'ready') {
1522
- if (!presenterInitSent) { presenterInitSent = true; sendInit(); }
1523
- sendState();
1524
- return;
1541
+ if (data.__dshPpt === 'ready') {
1542
+ if (!presenterInitSent) { presenterInitSent = true; sendInit(); }
1543
+ sendState();
1544
+ return;
1525
1545
  }
1526
1546
  if (data.__dshPpt !== 'cmd') return;
1527
1547
  if (data.kind === 'go') {
@@ -1539,98 +1559,103 @@ ${slides}
1539
1559
  }
1540
1560
  });
1541
1561
 
1542
- document.addEventListener('keydown', (event) => {
1543
- const key = event.key;
1544
- const lower = key.toLowerCase();
1562
+ document.addEventListener('keydown', (event) => {
1563
+ const key = event.key;
1564
+ const lower = key.toLowerCase();
1545
1565
  const targetEl = event.target;
1546
- const interactive = targetEl && (targetEl.tagName === 'BUTTON' || targetEl.tagName === 'INPUT' || targetEl.tagName === 'SELECT' || targetEl.tagName === 'TEXTAREA' || targetEl.isContentEditable === true);
1547
- if (key === ' ' && interactive) return;
1548
- if (key === 'Escape') {
1549
- if (!helpPanel.hidden) { helpPanel.hidden = true; return; }
1550
- if (document.body.classList.contains('overview')) { setOverview(false); return; }
1551
- if (notesOpen()) { setNotes(false); return; }
1552
- if (document.body.classList.contains('presenting') || document.body.classList.contains('blank-black') || document.body.classList.contains('blank-white')) { closePresenter(); return; }
1553
- if (document.fullscreenElement) { document.exitFullscreen?.(); return; }
1554
- return;
1555
- }
1556
- const inOverview = document.body.classList.contains('overview');
1557
- if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || (key === 'ArrowDown' && !inOverview)) {
1558
- event.preventDefault(); go(index + 1);
1559
- } else if (key === 'ArrowLeft' || key === 'PageUp' || (key === 'ArrowUp' && !inOverview)) {
1560
- event.preventDefault(); go(index - 1);
1561
- } else if (/^[1-9]$/.test(key) && !inOverview) {
1562
- const jumped = Number(key);
1563
- if (jumped <= total) { event.preventDefault(); go(jumped - 1); }
1564
- } else if (key === 'Home') {
1565
- event.preventDefault(); go(0);
1566
- } else if (key === 'End') {
1567
- event.preventDefault(); go(total - 1);
1568
- } else if (key === 'Enter' && inOverview) {
1569
- event.preventDefault(); setOverview(false);
1570
- } else if (lower === 'f') {
1571
- void toggleFullscreen();
1572
- } else if (lower === 'g') {
1573
- setOverview(!inOverview);
1574
- } else if (lower === 's') {
1575
- if (document.body.classList.contains('presenting')) { toast(ui.presenterHint); }
1576
- else { document.body.classList.toggle('notes-open'); syncNotes(); }
1577
- } else if (lower === 'v') {
1578
- if (presenterOpen()) closePresenter();
1579
- else openPresenter();
1580
- } else if (lower === 'b') {
1581
- setBlank(document.body.classList.contains('blank-black') ? null : 'black');
1582
- } else if (lower === 'w') {
1583
- setBlank(document.body.classList.contains('blank-white') ? null : 'white');
1584
- } else if (lower === 'p') {
1585
- window.print();
1586
- } else if (key === '?') {
1587
- helpPanel.hidden = !helpPanel.hidden;
1588
- }
1589
- });
1590
-
1591
- let wheelLock = 0;
1566
+ if (document.getElementById('review-panel').open || event.ctrlKey || event.metaKey || event.altKey) return;
1567
+ if (key !== 'Escape' && targetEl && (['INPUT', 'SELECT', 'TEXTAREA'].includes(targetEl.tagName) || targetEl.isContentEditable === true)) return;
1568
+ const interactive = targetEl && (targetEl.tagName === 'BUTTON' || targetEl.tagName === 'INPUT' || targetEl.tagName === 'SELECT' || targetEl.tagName === 'TEXTAREA' || targetEl.isContentEditable === true);
1569
+ if (key === ' ' && interactive) return;
1570
+ if (key === 'Escape') {
1571
+ if (!helpPanel.hidden) { helpPanel.hidden = true; return; }
1572
+ if (document.body.classList.contains('overview')) { setOverview(false); return; }
1573
+ if (notesOpen()) { setNotes(false); return; }
1574
+ if (document.body.classList.contains('presenting') || document.body.classList.contains('blank-black') || document.body.classList.contains('blank-white')) { closePresenter(); return; }
1575
+ if (document.fullscreenElement) { document.exitFullscreen?.(); return; }
1576
+ return;
1577
+ }
1578
+ const inOverview = document.body.classList.contains('overview');
1579
+ if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || (key === 'ArrowDown' && !inOverview)) {
1580
+ event.preventDefault(); go(index + 1);
1581
+ } else if (key === 'ArrowLeft' || key === 'PageUp' || (key === 'ArrowUp' && !inOverview)) {
1582
+ event.preventDefault(); go(index - 1);
1583
+ } else if (/^[1-9]$/.test(key) && !inOverview) {
1584
+ const jumped = Number(key);
1585
+ if (jumped <= total) { event.preventDefault(); go(jumped - 1); }
1586
+ } else if (key === 'Home') {
1587
+ event.preventDefault(); go(0);
1588
+ } else if (key === 'End') {
1589
+ event.preventDefault(); go(total - 1);
1590
+ } else if (key === 'Enter' && inOverview) {
1591
+ event.preventDefault(); setOverview(false);
1592
+ } else if (lower === 'f') {
1593
+ void toggleFullscreen();
1594
+ } else if (lower === 'g') {
1595
+ setOverview(!inOverview);
1596
+ } else if (lower === 's') {
1597
+ if (document.body.classList.contains('presenting')) { toast(ui.presenterHint); }
1598
+ else { document.body.classList.toggle('notes-open'); syncNotes(); }
1599
+ } else if (lower === 'v') {
1600
+ if (presenterOpen()) closePresenter();
1601
+ else openPresenter();
1602
+ } else if (lower === 'b') {
1603
+ setBlank(document.body.classList.contains('blank-black') ? null : 'black');
1604
+ } else if (lower === 'w') {
1605
+ setBlank(document.body.classList.contains('blank-white') ? null : 'white');
1606
+ } else if (lower === 'p') {
1607
+ window.print();
1608
+ } else if (key === '?') {
1609
+ helpPanel.hidden = !helpPanel.hidden;
1610
+ }
1611
+ });
1612
+
1613
+ let wheelLock = 0;
1592
1614
  document.addEventListener('wheel', (event) => {
1593
- const now = Date.now();
1594
- if (now - wheelLock < 550 || document.body.classList.contains('overview') || notesOpen()) return;
1595
- wheelLock = now;
1596
- if (Math.abs(event.deltaY) > 12) go(index + (event.deltaY > 0 ? 1 : -1));
1597
- }, { passive: true });
1598
-
1615
+ if (document.getElementById('review-panel').open || event.target.closest?.('#hud,#delivery-bar,#notes-panel,#help-panel')) return;
1616
+ const now = Date.now();
1617
+ if (now - wheelLock < 550 || document.body.classList.contains('overview') || notesOpen()) return;
1618
+ wheelLock = now;
1619
+ if (Math.abs(event.deltaY) > 12) go(index + (event.deltaY > 0 ? 1 : -1));
1620
+ }, { passive: true });
1621
+
1599
1622
  let touchStartY = 0;
1600
- document.addEventListener('touchstart', (event) => { touchStartY = event.touches[0].clientY; }, { passive: true });
1601
- document.addEventListener('touchend', (event) => {
1602
- const delta = event.changedTouches[0].clientY - touchStartY;
1603
- if (Math.abs(delta) > 48 && !document.body.classList.contains('overview')) go(index + (delta < 0 ? 1 : -1));
1604
- }, { passive: true });
1605
-
1606
- fullscreenBtn.addEventListener('click', () => { void toggleFullscreen(); });
1607
- notesBtn.addEventListener('click', () => {
1608
- if (document.body.classList.contains('presenting')) { toast(ui.presenterHint); return; }
1609
- setNotes(!notesOpen());
1610
- });
1611
- notesClose.addEventListener('click', () => setNotes(false));
1612
- presenterBtn.addEventListener('click', () => { if (presenterOpen()) closePresenter(); else openPresenter(); });
1613
- overviewBtn.addEventListener('click', () => setOverview(!document.body.classList.contains('overview')));
1614
- helpBtn.addEventListener('click', () => { helpPanel.hidden = !helpPanel.hidden; });
1615
- frames.forEach((frame, i) => {
1616
- frame.addEventListener('click', () => {
1617
- if (document.body.classList.contains('overview')) { go(i); setOverview(false); }
1618
- });
1619
- frame.addEventListener('keydown', (event) => {
1620
- if (event.key === 'Enter' && document.body.classList.contains('overview')) { go(i); setOverview(false); }
1621
- });
1622
- });
1623
- window.addEventListener('resize', updateThumbScale);
1624
- window.addEventListener('beforeunload', closePresenter);
1625
-
1623
+ let touchOnStage = false;
1624
+ document.addEventListener('touchstart', (event) => { touchOnStage = !document.getElementById('review-panel').open && !!event.target.closest?.('#stage'); touchStartY = event.touches[0].clientY; }, { passive: true });
1625
+ document.addEventListener('touchend', (event) => {
1626
+ const delta = event.changedTouches[0].clientY - touchStartY;
1627
+ if (touchOnStage && Math.abs(delta) > 48 && !document.body.classList.contains('overview')) go(index + (delta < 0 ? 1 : -1));
1628
+ }, { passive: true });
1629
+
1630
+ fullscreenBtn.addEventListener('click', () => { void toggleFullscreen(); });
1631
+ notesBtn.addEventListener('click', () => {
1632
+ if (document.body.classList.contains('presenting')) { toast(ui.presenterHint); return; }
1633
+ setNotes(!notesOpen());
1634
+ });
1635
+ notesClose.addEventListener('click', () => setNotes(false));
1636
+ presenterBtn.addEventListener('click', () => { if (presenterOpen()) closePresenter(); else openPresenter(); });
1637
+ overviewBtn.addEventListener('click', () => setOverview(!document.body.classList.contains('overview')));
1638
+ helpBtn.addEventListener('click', () => { helpPanel.hidden = !helpPanel.hidden; });
1639
+ frames.forEach((frame, i) => {
1640
+ frame.addEventListener('click', () => {
1641
+ if (document.body.classList.contains('overview')) { go(i); setOverview(false); }
1642
+ });
1643
+ frame.addEventListener('keydown', (event) => {
1644
+ if (event.key === 'Enter' && document.body.classList.contains('overview')) { go(i); setOverview(false); }
1645
+ });
1646
+ });
1647
+ window.addEventListener('resize', updateThumbScale);
1648
+ window.addEventListener('beforeunload', closePresenter);
1649
+
1650
+ ${delivery.script}
1626
1651
  const start = Number.parseInt(location.hash?.replace('#slide-', ''), 10);
1627
- go(Number.isInteger(start) ? start - 1 : 0);
1628
- })();
1629
- </script>
1630
- </body>
1631
- </html>`
1632
- }
1633
- function renderHtmlSlide(slide, index, ui, langId) {
1652
+ go(Number.isInteger(start) ? start - 1 : 0);
1653
+ })();
1654
+ </script>
1655
+ </body>
1656
+ </html>`
1657
+ }
1658
+ function renderHtmlSlide(slide, index, ui, langId, manifest = {}, theme) {
1634
1659
  const kicker = slide.kicker || (slide.layout === 'cover' ? ui.coverKicker : '')
1635
1660
  const title = slide.title || ''
1636
1661
  const subtitle = slide.subtitle || ''
@@ -1638,6 +1663,12 @@ function renderHtmlSlide(slide, index, ui, langId) {
1638
1663
  const number = String(index + 1).padStart(2, '0')
1639
1664
  let inner = ''
1640
1665
  switch (slide.layout) {
1666
+ case 'image':
1667
+ case 'image-left':
1668
+ case 'image-right':
1669
+ case 'chart':
1670
+ inner = renderMediaHtml(slide, manifest, theme)
1671
+ break
1641
1672
  case 'cover':
1642
1673
  inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
1643
1674
  '<h1>' + escapeHtml(title) + '</h1>' +
@@ -1693,7 +1724,9 @@ function renderHtmlSlide(slide, index, ui, langId) {
1693
1724
  ? ' data-notes="' + escapeHtml(slide.notes) + '"'
1694
1725
  : ''
1695
1726
  return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
1696
- '<div class="slide-inner">' + inner + '</div></section>'
1727
+ '<div class="slide-inner">' + inner + '</div>' +
1728
+ (manifest.brand?.logoAssetId ? '<img class="brand-logo" src="' + escapeHtml(manifest.assets?.[manifest.brand.logoAssetId]?.data ?? '') + '" alt="' + escapeHtml(manifest.brand.name || 'Logo') + '"/>' : '') +
1729
+ (manifest.brand?.footer ? '<div class="brand-footer">' + escapeHtml(manifest.brand.footer) + '</div>' : '') + '</section>'
1697
1730
  }
1698
1731
 
1699
1732
  function hexToRgba(hex, alpha) {
@@ -1717,9 +1750,28 @@ function hex(value) {
1717
1750
  return String(value).replace('#', '').toUpperCase()
1718
1751
  }
1719
1752
 
1720
- function pptxFontName(value) {
1721
- const match = /"?([^",]+)"?/.exec(String(value ?? ''))
1722
- return match?.[1]?.trim() || 'Arial'
1753
+ export function pptxFontName(value) {
1754
+ const names = String(value ?? '').split(',').map(name => name.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
1755
+ // A CSS stack may start with an optional web font. Office receives one actual
1756
+ // family, so select a platform fallback instead of copying the first name.
1757
+ if (names.length === 1 && !['serif', 'sans-serif', 'system-ui'].includes(names[0])) return names[0]
1758
+ const available = process.platform === 'win32'
1759
+ ? ['Segoe UI', 'Arial', 'Arial Black', 'Impact', 'Georgia', 'Times New Roman', 'Microsoft YaHei', 'SimSun']
1760
+ : process.platform === 'darwin'
1761
+ ? ['Helvetica Neue', 'Arial', 'Impact', 'Georgia', 'Times New Roman', 'Avenir Next', 'PingFang SC', 'Songti SC']
1762
+ : ['Liberation Sans', 'Liberation Serif', 'Noto Sans', 'Noto Sans CJK SC', 'Noto Serif CJK SC']
1763
+ return names.find(name => available.includes(name)) || (names.includes('serif')
1764
+ ? process.platform === 'linux' ? 'Liberation Serif' : 'Georgia'
1765
+ : process.platform === 'win32' ? 'Segoe UI' : process.platform === 'darwin' ? 'Helvetica Neue' : 'Liberation Sans')
1766
+ }
1767
+
1768
+ export function pptxEastAsianFont(value) {
1769
+ const font = pptxFontName(value)
1770
+ if (/Microsoft YaHei|SimSun|SimHei|PingFang|Songti|Heiti|Noto (Sans|Serif) CJK|[\u3400-\u9fff]/i.test(font)) return font
1771
+ const serif = /Georgia|Times New Roman|Cambria|Liberation Serif/i.test(font)
1772
+ return process.platform === 'win32' ? serif ? 'SimSun' : 'Microsoft YaHei'
1773
+ : process.platform === 'darwin' ? serif ? 'Songti SC' : 'PingFang SC'
1774
+ : serif ? 'Noto Serif CJK SC' : 'Noto Sans CJK SC'
1723
1775
  }
1724
1776
 
1725
1777
  function paragraphXml(text, options = {}) {
@@ -1735,20 +1787,14 @@ function paragraphXml(text, options = {}) {
1735
1787
  spaceAfter = 0,
1736
1788
  } = options
1737
1789
  const runFont = pptxFontName(font)
1738
- let pPr = ''
1739
- if (bullet) {
1740
- pPr = '<a:pPr marL="285750" indent="-285750"><a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/></a:pPr>'
1741
- } else {
1742
- const parts = [] // align 与 spacing 二选一
1743
- if (align !== 'l') parts.push('algn="' + align + '"')
1744
- if (spaceBefore > 0) parts.push('<a:spcBef><a:spcPts val="' + (spaceBefore / 100) + '"/></a:spcBef>')
1745
- if (spaceAfter > 0) parts.push('<a:spcAft><a:spcPts val="' + (spaceAfter / 100) + '"/></a:spcAft>')
1746
- pPr = '<a:pPr' + (parts.length > 0 ? ' ' + parts.join(' ') : '') + '><a:buNone/></a:pPr>'
1747
- }
1790
+ const spacing = (spaceBefore > 0 ? '<a:spcBef><a:spcPts val="' + spaceBefore + '"/></a:spcBef>' : '') +
1791
+ (spaceAfter > 0 ? '<a:spcAft><a:spcPts val="' + spaceAfter + '"/></a:spcAft>' : '')
1792
+ const pPr = '<a:pPr' + (bullet ? ' marL="285750" indent="-285750"' : align !== 'l' ? ' algn="' + align + '"' : '') + '>' + spacing +
1793
+ (bullet ? '<a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/>' : '<a:buNone/>') + '</a:pPr>'
1748
1794
  return '<a:p>' + pPr +
1749
1795
  '<a:r><a:rPr lang="' + lang + '" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1750
1796
  '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1751
- '<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(runFont) + '"/></a:rPr>' +
1797
+ '<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(font)) + '"/></a:rPr>' +
1752
1798
  '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p>'
1753
1799
  }
1754
1800
 
@@ -1779,14 +1825,15 @@ function tableGraphicXml(id, name, box, rows, theme, langId) {
1779
1825
  const font = pptxFontName(theme.fonts.body)
1780
1826
  const safeRows = rows.length > 0 ? rows : [['']]
1781
1827
  const cols = Math.max(1, safeRows[0].length)
1828
+ const fontSize = cols <= 6 ? 1700 : 1400
1782
1829
  const colW = Math.floor(box.w / cols)
1783
1830
  const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
1784
1831
  const rowH = 420000
1785
1832
  const cellXml = (text, fill, color, bold) =>
1786
1833
  '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1787
- '<a:r><a:rPr lang="' + lang + '" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1834
+ '<a:r><a:rPr lang="' + lang + '" sz="' + fontSize + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1788
1835
  '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1789
- '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1836
+ '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/></a:rPr>' +
1790
1837
  '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
1791
1838
  '<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
1792
1839
  (fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
@@ -1824,7 +1871,17 @@ function slideShapeList(slide, index, total, theme, langId) {
1824
1871
  const idBase = (index + 1) * 10
1825
1872
  const footer = String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0')
1826
1873
 
1827
- if (slide.layout === 'cover') {
1874
+ if (MEDIA_LAYOUTS.has(slide.layout)) {
1875
+ const boxes = mediaBoxes(slide.layout)
1876
+ const emu = box => Object.fromEntries(Object.entries(box).map(([key, value]) => [key, Math.round(value * 914400)]))
1877
+ shapes.push(textShapeXml(idBase + 2, 'Title', emu(boxes.title), [para(title, { size: 3400, color: p.fg, bold: true, font })]))
1878
+ if (boxes.body) shapes.push(textShapeXml(idBase + 3, 'Body', emu(boxes.body), [
1879
+ ...(subtitle ? [para(subtitle, { size: 2000, color: p.fg, font: bodyFont })] : []),
1880
+ ...bullets.map(bullet => para(bullet, { size: 2000, color: p.fg, bullet: true, font: bodyFont, spaceAfter: 600 })),
1881
+ ]))
1882
+ const caption = slide.chart?.caption || slide.image?.caption
1883
+ if (caption) shapes.push(textShapeXml(idBase + 4, 'Caption', emu(boxes.caption), [para(caption, { size: 1400, color: p.muted, font: bodyFont })]))
1884
+ } else if (slide.layout === 'cover') {
1828
1885
  shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 2250000, w: 240000, h: 1600000 }, p.accent))
1829
1886
  shapes.push(textShapeXml(idBase + 2, 'Title', { x: 1550000, y: 2160000, w: 9250000, h: 1800000 }, [
1830
1887
  para(title, { size: 4400, color: p.fg, bold: true, font }),
@@ -1877,8 +1934,8 @@ function slideShapeList(slide, index, total, theme, langId) {
1877
1934
  ]))
1878
1935
  }
1879
1936
  } else if (slide.layout === 'table') {
1880
- const hasTitle = title !== '' && title !== kicker
1881
- if (kicker !== '') {
1937
+ const hasTitle = title !== ''
1938
+ if (kicker !== '' && kicker !== title) {
1882
1939
  shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1883
1940
  para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1884
1941
  ]))
@@ -2073,7 +2130,7 @@ function slideMasterXml(theme) {
2073
2130
  const levels = Array.from({ length: 9 }, (_, i) => {
2074
2131
  const sz = Math.max(1100, 2000 - i * 100)
2075
2132
  return '<a:lvl' + (i + 1) + 'pPr marL="' + (342900 + i * 342900) + '" indent="' + (-342900 - i * 0) + '">' +
2076
- '<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>'
2133
+ '<a:defRPr sz="' + sz + '"><a:solidFill><a:srgbClr val="' + hex(p.fg) + '"/></a:solidFill><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/></a:defRPr></a:lvl' + (i + 1) + 'pPr>'
2077
2134
  }).join('')
2078
2135
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
2079
2136
  '<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
@@ -2128,8 +2185,8 @@ function themeXml(theme) {
2128
2185
  color('hlink', p.accent) + color('folHlink', p.accent2) +
2129
2186
  '</a:clrScheme>' +
2130
2187
  '<a:fontScheme name="dsh-ppt">' +
2131
- '<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>' +
2132
- '<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>' +
2188
+ '<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.heading)) + '"/><a:cs typeface=""/></a:majorFont>' +
2189
+ '<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/><a:cs typeface=""/></a:minorFont>' +
2133
2190
  '</a:fontScheme>' +
2134
2191
  '<a:fmtScheme name="dsh-ppt">' +
2135
2192
  '<a:fillStyleLst>' +