dsh-ppt 0.4.4 → 0.5.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.
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
18
- import { resolve as resolvePath } from 'node:path'
18
+ import { resolve as resolvePath, join as joinPath } from 'node:path'
19
19
  import { deflateRawSync } from 'node:zlib'
20
20
 
21
21
  /**
@@ -152,13 +152,28 @@ export const LANGUAGES = {
152
152
  pointKicker: '要点',
153
153
  statementKicker: '核心观点',
154
154
  quoteKicker: '金句',
155
- tableKicker: '数据',
156
- continuation: '(续)',
155
+ tableKicker: '数据',
156
+ continuation: '(续)',
157
157
  notesLabel: '演讲者备注',
158
158
  notesToggle: '备注',
159
159
  closingTitle: '谢谢',
160
160
  closingSubtitle: '讨论与问答',
161
- generatedBy: '由 dsh-ppt 生成',
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',
171
+ presenterHint: '备注:未开演讲者窗口时按 S 显示在顶部;打开后只在演讲者窗口显示,观众看不到',
172
+ wrapHint: '已环绕到',
173
+ popupBlocked: '浏览器拦截了演讲者窗口,请允许弹出窗口后按 V 重试',
174
+ fullscreenUnavailable: '当前窗口无法进入全屏,请在浏览器中独立打开演示文件后重试。',
175
+ notesCloseLabel: '关闭备注',
176
+ overviewHint: '点击缩略图跳页,方向键切换,Enter 进入,Esc 退出'
162
177
  },
163
178
  },
164
179
  en: {
@@ -173,13 +188,28 @@ export const LANGUAGES = {
173
188
  pointKicker: 'Key point',
174
189
  statementKicker: 'Core idea',
175
190
  quoteKicker: 'Quote',
176
- tableKicker: 'Data',
177
- continuation: ' (continued)',
191
+ tableKicker: 'Data',
192
+ continuation: ' (continued)',
178
193
  notesLabel: 'Speaker notes',
179
194
  notesToggle: 'Notes',
180
195
  closingTitle: 'Thank You',
181
196
  closingSubtitle: 'Discussion & Q&A',
182
- generatedBy: 'Generated with dsh-ppt',
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',
207
+ 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
+ overviewHint: 'Click a thumbnail to jump; arrows move, Enter opens, Esc exits'
183
213
  },
184
214
  },
185
215
  bilingual: {
@@ -194,17 +224,141 @@ export const LANGUAGES = {
194
224
  pointKicker: '要点 · Key point',
195
225
  statementKicker: '核心观点 · Core Idea',
196
226
  quoteKicker: '金句 · Quote',
197
- tableKicker: '数据 · Data',
198
- continuation: '(续 · continued)',
227
+ tableKicker: '数据 · Data',
228
+ continuation: '(续 · continued)',
199
229
  notesLabel: '演讲者备注 · Speaker notes',
200
230
  notesToggle: '备注 · Notes',
201
231
  closingTitle: '谢谢 · Thank You',
202
232
  closingSubtitle: '讨论与问答 · Q&A',
203
- generatedBy: '由 dsh-ppt 生成 · Generated with dsh-ppt',
204
- },
205
- },
206
- }
207
-
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',
243
+ 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
+
208
362
  export function resolveTheme(input) {
209
363
  const id = String(input ?? DEFAULT_THEME).trim().toLowerCase()
210
364
  const theme = THEMES[id]
@@ -371,37 +525,37 @@ function quoteSlideFrom(section, ui) {
371
525
  return { layout: 'quote', kicker: ui.quoteKicker, title: truncate(lines.join(' '), 220), subtitle: attribution }
372
526
  }
373
527
 
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')
380
- }
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 } : {}) }
390
- })
391
- }
392
-
393
- function tableSlidesFrom(section, ui) {
394
- const table = section.table
395
- const rows = [table.header, ...table.rows]
396
- const heading = section.heading || ui.tableKicker
397
- return paginateTable({ layout: 'table', kicker: heading, title: heading, rows }, ui.continuation)
528
+ function paginateTable(slide, continuation = '(续)') {
529
+ const rows = slide.rows
530
+ const columns = Math.max(...rows.map(row => row.length))
531
+ if (columns > 8) throw new Error('dsh-ppt:表格最多 8 列,请拆成多个表格后重试 / tables support up to 8 columns')
532
+ if (rows.some(row => row.some(cell => [...cell].length > 60))) {
533
+ throw new Error('dsh-ppt:表格单元格最多 60 个字符,请精简或拆分内容后重试 / cells support up to 60 characters')
534
+ }
535
+ const padded = rows.map(row => Array.from({ length: columns }, (_, i) => row[i] ?? ''))
536
+ const pages = chunk(padded.slice(1), 8)
537
+ if (pages.length === 0) pages.push([])
538
+ return pages.map((body, index) => {
539
+ const { notes, ...rest } = slide
540
+ const title = slide.title + (index === 0 ? '' : continuation)
541
+ // 同名 kicker/title 共用紧凑标题;续页不能凭空多出一行大标题挤压表格。
542
+ return { ...rest, title, kicker: slide.kicker === slide.title ? title : slide.kicker, rows: [padded[0], ...body],
543
+ ...(index === 0 && notes ? { notes } : {}) }
544
+ })
545
+ }
546
+
547
+ function tableSlidesFrom(section, ui) {
548
+ const table = section.table
549
+ const rows = [table.header, ...table.rows]
550
+ const heading = section.heading || ui.tableKicker
551
+ return paginateTable({ layout: 'table', kicker: heading, title: heading, rows }, ui.continuation)
398
552
  }
399
553
 
400
554
  /** 把一个 section 里的金句/表格拆成独立页;备注附着到第一张产出页。 */
401
555
  function specialSlidesFor(section, ui) {
402
556
  const out = []
403
557
  if (section.quote.length > 0) out.push(quoteSlideFrom(section, ui))
404
- if (section.table !== null) out.push(...tableSlidesFrom(section, ui))
558
+ if (section.table !== null) out.push(...tableSlidesFrom(section, ui))
405
559
  if (out.length > 0 && section.notes.length > 0) out[0].notes = section.notes.join('\n')
406
560
  return out
407
561
  }
@@ -425,8 +579,8 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
425
579
  let firstH1Seen = false
426
580
 
427
581
  const flush = () => {
428
- if (current !== null && (current.heading !== '' || current.bullets.length > 0 || current.paragraphs.length > 0
429
- || current.table !== null || current.quote.length > 0)) {
582
+ if (current !== null && (current.heading !== '' || current.bullets.length > 0 || current.paragraphs.length > 0
583
+ || current.table !== null || current.quote.length > 0)) {
430
584
  sections.push(current)
431
585
  }
432
586
  current = null
@@ -481,7 +635,7 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
481
635
  if (isTableSeparator(tableCells)) continue
482
636
  if (current.table === null) {
483
637
  current.table = { header: tableCells.map((cell) => stripInlineMarkdown(cell)), rows: [] }
484
- } else {
638
+ } else {
485
639
  current.table.rows.push(tableCells.map((cell) => stripInlineMarkdown(cell)))
486
640
  }
487
641
  continue
@@ -515,7 +669,7 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
515
669
  }
516
670
  }
517
671
 
518
- let bodySections = sections.filter((section) => section.coverOnly !== true)
672
+ let bodySections = sections.filter((section) => section.coverOnly !== true)
519
673
  // 首个 H1 既是封面又把所有要点收在封面节里时,把封面副标题之外的剩余要点
520
674
  // 提升为一个无标题节,走下面的要点页生成逻辑,避免丢掉内容。
521
675
  if (bodySections.length === 0 && coverSource !== null && coverSource !== undefined) {
@@ -527,18 +681,18 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
527
681
  if (extras.length > 0) {
528
682
  const synthetic = createSection()
529
683
  synthetic.bullets = extras
530
- bodySections = [synthetic]
531
- }
532
- }
533
-
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)
541
- }
684
+ bodySections = [synthetic]
685
+ }
686
+ }
687
+
688
+ // 表格和引用不能只挂在封面节上,否则首个 H1 后没有 H2 时会丢失。
689
+ if (coverSource && (coverSource.table !== null || coverSource.quote.length > 0)) {
690
+ const special = createSection()
691
+ special.table = coverSource.table
692
+ special.quote = coverSource.quote
693
+ special.notes = coverSource.notes
694
+ bodySections.unshift(special)
695
+ }
542
696
 
543
697
  if (bodySections.length === 0) {
544
698
  // 一句话 / 无结构输入:封面 + 核心观点 + 结束页(仍是完整的三页演示文稿)
@@ -625,7 +779,7 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
625
779
  if (pages.length > 0) {
626
780
  // 超过 8 条自动续页,不再静默丢弃;备注只挂在第一页
627
781
  pages.forEach((pagePoints, pageIndex) => {
628
- const label = pageIndex === 0 ? section.heading : section.heading + ui.continuation
782
+ const label = pageIndex === 0 ? section.heading : section.heading + ui.continuation
629
783
  const slide = { layout: 'bullets', kicker: label, title: label, bullets: pagePoints }
630
784
  if (pageIndex === 0 && specials.length === 0 && notes !== '') slide.notes = notes
631
785
  slides.push(slide)
@@ -669,8 +823,8 @@ function normalizeSlide(raw, index) {
669
823
  let rows = []
670
824
  if (Array.isArray(source.rows)) {
671
825
  rows = source.rows
672
- .map((row) => (Array.isArray(row) ? row : [row]).map((cell) => stripInlineMarkdown(String(cell))))
673
- .filter((row) => row.some((cell) => cell !== ''))
826
+ .map((row) => (Array.isArray(row) ? row : [row]).map((cell) => stripInlineMarkdown(String(cell))))
827
+ .filter((row) => row.some((cell) => cell !== ''))
674
828
  }
675
829
  if (rows.length === 0) {
676
830
  // 没有表格数据的 table 页退化成要点页,避免产出空白版式
@@ -681,22 +835,22 @@ function normalizeSlide(raw, index) {
681
835
  return { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }
682
836
  }
683
837
 
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')
688
- }
689
- }
690
-
691
- export function normalizeSlides(rawSlides, maxSlides = 60, lang = 'zh') {
838
+ function checkSlideLimit(slides, limit) {
839
+ if (slides.length > limit) {
840
+ throw new Error('dsh-ppt:生成后共 ' + slides.length + ' 页,超过 maxSlides=' + limit
841
+ + ',请提高 maxSlides(最多 120)或拆分内容 / generated ' + slides.length + ' slides; raise maxSlides or split the deck')
842
+ }
843
+ }
844
+
845
+ export function normalizeSlides(rawSlides, maxSlides = 60, lang = 'zh') {
692
846
  if (!Array.isArray(rawSlides) || rawSlides.length === 0) {
693
847
  throw new Error('dsh-ppt:slides 必须是非空数组(每个元素是 { layout, title, subtitle, kicker, bullets } 对象)')
694
848
  }
695
849
  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)
850
+ const ui = resolveLanguage(lang).ui
851
+ const slides = rawSlides.map(normalizeSlide).flatMap(slide => slide.layout === 'table'
852
+ ? paginateTable(slide, ui.continuation) : [slide])
853
+ checkSlideLimit(slides, limit)
700
854
  if (slides.length === 0) throw new Error('dsh-ppt:slides 规范化后为空')
701
855
  return slides
702
856
  }
@@ -717,13 +871,13 @@ export function normalizeBuildOptions(options = {}) {
717
871
  deck = {
718
872
  title,
719
873
  subtitle: stripInlineMarkdown(options.subtitle ?? ''),
720
- slides: normalizeSlides(options.slides, maxSlides, language.id),
874
+ slides: normalizeSlides(options.slides, maxSlides, language.id),
721
875
  }
722
876
  } else {
723
877
  const content = String(options.content ?? '').trim()
724
878
  if (content === '') throw new Error('dsh-ppt:content 不能为空(或用 slides 传结构化幻灯片)')
725
879
  deck = parseMarkdownDeck(title, content, language.id)
726
- checkSlideLimit(deck.slides, maxSlides)
880
+ checkSlideLimit(deck.slides, maxSlides)
727
881
  }
728
882
  if (deck.slides.length < 1) throw new Error('dsh-ppt:没有可生成的幻灯片')
729
883
  const outputDir = resolvePath(String(options.outputDir ?? '.').trim() || '.')
@@ -838,251 +992,644 @@ export function buildDeck(options = {}) {
838
992
  // HTML 网页放映
839
993
  // ---------------------------------------------------------------------------
840
994
 
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;
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
1015
+ 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)}
1178
+ body.overview .slide-frame.is-current{outline:2px solid var(--accent);outline-offset:2px}
1179
+ body.overview .slide-frame:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
1180
+ body.overview .slide-frame::after{
1181
+ content:attr(data-frame);position:absolute;right:8px;bottom:6px;z-index:2;
1182
+ font-size:12px;color:var(--muted);background:color-mix(in srgb, var(--bg) 84%, transparent);
1183
+ border:1px solid color-mix(in srgb, var(--muted) 40%, transparent);border-radius:6px;
1184
+ 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}
1193
+ body.presenting #notes-toggle{opacity:.55}
1194
+ 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
+ }
1224
+ </style>
1225
+ </head>
1226
+ <body class="${motion ? 'motion' : 'no-motion'}">
1227
+ <div id="progress" aria-hidden="true"></div>
1228
+ <div id="stage">
1229
+ ${slides}
1230
+ </div>
1231
+ <div id="hud" aria-live="polite">
1232
+ <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');
1271
+ 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
+
1304
+ function sendInit() {
1305
+ if (!presenterOpen()) return;
1306
+ try {
1307
+ presenterWin.postMessage({
1308
+ __dshPpt: 'init',
1309
+ title: (document.title.split(' · ')[1] || document.title),
1310
+ css: document.querySelector('style') ? document.querySelector('style').textContent : '',
1311
+ total: total,
1312
+ slides: slides.map(function (slide) { return slide.outerHTML; }),
1313
+ }, '*');
1314
+ } catch (_) { /* popup closing */ }
1020
1315
  }
1021
1316
 
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:// 下个别浏览器可能拒绝 */ }
1317
+ function sendState() {
1318
+ if (!presenterOpen()) return;
1319
+ const next = (index + 1) % total;
1320
+ try {
1321
+ presenterWin.postMessage({
1322
+ __dshPpt: 'state',
1323
+ index: index,
1324
+ total: total,
1325
+ title: titleOf(index),
1326
+ nextTitle: titleOf(next),
1327
+ notes: notesOf(index),
1328
+ nextNotes: notesOf(next),
1329
+ blank: document.body.classList.contains('blank-black') ? 'black' : (document.body.classList.contains('blank-white') ? 'white' : null),
1330
+ }, '*');
1331
+ } catch (_) { /* popup closing */ }
1332
+ }
1333
+
1334
+ function updatePresenter() { sendState(); }
1335
+
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
+ });
1344
+ 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
+
1381
+ function presenterHtml() {
1382
+ const labelCurrent = ${JSON.stringify(ui.currentLabel)};
1383
+ const labelNext = ${JSON.stringify(ui.nextLabel)};
1384
+ const labelNotes = ${JSON.stringify(ui.notesLabel)};
1385
+ const noNotes = ${JSON.stringify(ui.noNotes)};
1386
+ const hintText = ${JSON.stringify('← → 翻页 · P 暂停 · T 重置 · +/- 字号 · B/W 黑/白屏 · Esc 结束')};
1387
+ const deckTitle = ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
1388
+ const langAttr = ${JSON.stringify(lang.attr)};
1389
+ const cs = getComputedStyle(document.documentElement);
1390
+ const val = function (name, fallback) { const v = cs.getPropertyValue(name).trim(); return v || fallback; };
1391
+ const css = [
1392
+ ':root{--bg:' + val('--bg', '#070B14') + ';--panel:' + val('--panel', '#0D1424') + ';--fg:' + val('--fg', '#E8F1FF') + ';--muted:' + val('--muted', '#7E8BA8') + ';--accent:' + val('--accent', '#7C3AED') + ';--accent2:' + val('--accent2', '#06B6D4') + ';--font-heading:' + val('--font-heading', 'sans-serif') + ';--font-body:' + val('--font-body', 'sans-serif') + '}',
1393
+ '*{box-sizing:border-box}',
1394
+ 'body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--font-body);height:100vh;display:flex;flex-direction:column;overflow:hidden}',
1395
+ 'header{display:flex;align-items:center;gap:14px;padding:9px 16px;border-bottom:1px solid color-mix(in srgb, var(--muted) 40%, transparent);font-size:13px;color:var(--muted)}',
1396
+ 'header #ptitle{color:var(--fg);font-weight:700;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
1397
+ 'header #pclock{font-variant-numeric:tabular-nums}',
1398
+ 'main{flex:1;display:grid;grid-template-columns:1.12fr .88fr;gap:12px;padding:12px 16px;min-height:0}',
1399
+ '.col{display:flex;flex-direction:column;gap:10px;min-height:0}',
1400
+ '.card{background:var(--panel);border:1px solid color-mix(in srgb, var(--muted) 30%, transparent);border-radius:12px;padding:10px 12px;min-height:0;display:flex;flex-direction:column;gap:6px}',
1401
+ '.card-notes{flex:1}',
1402
+ '.lbl{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}',
1403
+ '.pv{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;background:var(--bg);border:1px solid color-mix(in srgb, var(--muted) 25%, transparent);--pv-scale:.3}',
1404
+ '.pv-slide{position:absolute;left:0;top:0;transform:translate(var(--pv-x,0px),var(--pv-y,0px)) scale(var(--pv-scale,.25));transform-origin:top left}',
1405
+ '.pv-slide .slide{display:flex !important;position:absolute;left:0;top:0;width:100vw;height:100vh;animation:none !important}',
1406
+ '.pv-slide .bullets li{opacity:1 !important;animation:none !important}',
1407
+ '#thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:2px}',
1408
+ '#thumbs .thumb{position:relative;flex:0 0 128px;aspect-ratio:16/9;border-radius:8px;overflow:hidden;border:1px solid color-mix(in srgb, var(--muted) 35%, transparent);cursor:pointer;background:var(--bg);--pv-scale:.09}',
1409
+ '#thumbs .thumb.is-current{outline:2px solid var(--accent);outline-offset:1px}',
1410
+ '#pnotes{font-size:var(--notes-size,19px);line-height:1.6;white-space:pre-wrap;overflow:auto;flex:1}',
1411
+ 'footer{display:flex;align-items:center;gap:8px;padding:9px 16px;border-top:1px solid color-mix(in srgb, var(--muted) 40%, transparent);flex-wrap:wrap}',
1412
+ 'button{background:color-mix(in srgb, var(--panel) 90%, transparent);color:var(--fg);border:1px solid color-mix(in srgb, var(--muted) 45%, transparent);border-radius:99px;padding:6px 12px;min-height:34px;font:inherit;font-size:13px;cursor:pointer}',
1413
+ 'button:hover{border-color:var(--accent);color:var(--accent)}',
1414
+ 'button[aria-pressed="true"]{background:var(--accent);color:var(--bg);border-color:var(--accent)}',
1415
+ '#ptimer{font-variant-numeric:tabular-nums;font-size:20px;font-weight:700;color:var(--accent);margin-left:6px}',
1416
+ '.bar{flex:1;min-width:100px;height:8px;background:color-mix(in srgb, var(--muted) 28%, transparent);border-radius:99px;overflow:hidden}',
1417
+ '#pprog{height:100%;width:0;background:var(--accent);transition:width .2s}',
1418
+ '.hint{color:var(--muted);font-size:11px;width:100%}',
1419
+ ].join('');
1420
+ const js = [
1421
+ 'var total=0,index=0,elapsed=0,started=Date.now(),paused=false,notesSize=19,slides=[],cssText="",curBlank=null,initialized=false,readyTries=0;',
1422
+ 'function q(id){return document.getElementById(id)}',
1423
+ 'function post(m){try{if(window.opener)window.opener.postMessage(m,"*")}catch(e){}}',
1424
+ '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
+ 'function clock(){var d=new Date();return ("0"+d.getHours()).slice(-2)+":"+("0"+d.getMinutes()).slice(-2)+":"+("0"+d.getSeconds()).slice(-2)}',
1426
+ '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")}',
1428
+ '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
+ 'function setSlide(box,html){box.innerHTML="";var f=document.createElement("div");f.className="pv-slide";f.innerHTML=html;box.appendChild(f)}',
1430
+ '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()}',
1431
+ 'function renderState(st){index=st.index;total=st.total||total;q("pcounter").textContent=(index+1)+" / "+total;setSlide(q("pv-cur"),slides[index]||"");setSlide(q("pv-next"),slides[(index+1)%total]||"");q("pnotes").textContent=st.notes||' + JSON.stringify(ui.noNotes) + ';q("pprog").style.width=((index+1)/total*100)+"%";curBlank=st.blank||null;q("pv-black").setAttribute("aria-pressed",curBlank==="black"?"true":"false");q("pv-white").setAttribute("aria-pressed",curBlank==="white"?"true":"false");q("pv-black").textContent=curBlank==="black"?"取消黑屏":"黑屏";q("pv-white").textContent=curBlank==="white"?"取消白屏":"白屏";var th=q("thumbs").children;for(var i=0;i<th.length;i++){th[i].classList.toggle("is-current",i===index)}fit()}',
1432
+ 'function togglePause(){paused=!paused;if(!paused)started=Date.now()-elapsed;var b=q("pause");b.setAttribute("aria-pressed",paused?"true":"false");b.textContent=paused?"继续":"暂停"}',
1433
+ 'function resetTimer(){started=Date.now();elapsed=0}',
1434
+ 'function applyNotesSize(){document.documentElement.style.setProperty("--notes-size",notesSize+"px")}',
1435
+ 'window.addEventListener("message",function(e){var d=e.data||{};if(d.__dshPpt==="init"&&!initialized){initialized=true;slides=d.slides||[];total=d.total||slides.length;cssText=d.css||"";var st=document.createElement("style");st.textContent=cssText;document.head.appendChild(st);q("ptitle").textContent=d.title||"";buildThumbs()}else if(d.__dshPpt==="state"){renderState(d)}else if(d.__dshPpt==="close"){window.close()}});',
1436
+ 'window.addEventListener("resize",fit);',
1437
+ 'window.addEventListener("beforeunload",function(){post({__dshPpt:"cmd",kind:"closed"})});',
1438
+ 'document.addEventListener("keydown",function(e){var k=e.key;if(k==="ArrowRight"||k===" "){e.preventDefault();post({__dshPpt:"cmd",kind:"go",delta:1})}else if(k==="ArrowLeft"){e.preventDefault();post({__dshPpt:"cmd",kind:"go",delta:-1})}else if(k==="Escape"){post({__dshPpt:"cmd",kind:"end"});window.close()}else if(k.toLowerCase()==="p"){togglePause()}else if(k.toLowerCase()==="t"){resetTimer()}else if(k==="+"||k==="="){notesSize=Math.min(34,notesSize+2);applyNotesSize()}else if(k==="-"){notesSize=Math.max(12,notesSize-2);applyNotesSize()}else if(k.toLowerCase()==="b"){post({__dshPpt:"cmd",kind:"blank",color:curBlank==="black"?null:"black"})}else if(k.toLowerCase()==="w"){post({__dshPpt:"cmd",kind:"blank",color:curBlank==="white"?null:"white"})}});',
1439
+ 'q("prev").addEventListener("click",function(){post({__dshPpt:"cmd",kind:"go",delta:-1})});',
1440
+ 'q("next").addEventListener("click",function(){post({__dshPpt:"cmd",kind:"go",delta:1})});',
1441
+ 'q("pause").addEventListener("click",togglePause);',
1442
+ 'q("reset").addEventListener("click",resetTimer);',
1443
+ 'q("font-plus").addEventListener("click",function(){notesSize=Math.min(34,notesSize+2);applyNotesSize()});',
1444
+ 'q("font-minus").addEventListener("click",function(){notesSize=Math.max(12,notesSize-2);applyNotesSize()});',
1445
+ 'q("pv-black").addEventListener("click",function(){post({__dshPpt:"cmd",kind:"blank",color:curBlank==="black"?null:"black"})});',
1446
+ 'q("pv-white").addEventListener("click",function(){post({__dshPpt:"cmd",kind:"blank",color:curBlank==="white"?null:"white"})});',
1447
+ 'q("end").addEventListener("click",function(){post({__dshPpt:"cmd",kind:"end"});window.close()});',
1448
+ 'applyNotesSize();tick();function ready(){if(initialized)return;post({__dshPpt:"ready"});readyTries++;if(readyTries<5)setTimeout(ready,700)}setTimeout(ready,250);',
1449
+ ].join('');
1450
+ return '<!DOCTYPE html><html lang="' + langAttr + '"><head><meta charset="UTF-8"><title>' + deckTitle + ' · 演讲者视图</title><style>' + css + '</style></head><body>'
1451
+ + '<header><div id="ptitle"></div><div id="pcounter"></div><div id="pclock" title="本地时间"></div></header>'
1452
+ + '<main><div class="col">'
1453
+ + '<div class="card"><div class="lbl">' + labelCurrent + '</div><div class="pv" id="pv-cur"></div></div>'
1454
+ + '<div class="card"><div class="lbl">' + labelNext + '</div><div class="pv" id="pv-next"></div></div>'
1455
+ + '<div class="card"><div class="lbl">SLIDES</div><div id="thumbs"></div></div>'
1456
+ + '</div><div class="col"><div class="card card-notes"><div class="lbl">' + labelNotes + '</div><div id="pnotes"></div></div></div></main>'
1457
+ + '<footer><button id="prev" title="上一页 (←)">◀</button><button id="next" title="下一页 (→)">▶</button><button id="pause" title="暂停 (P)" aria-pressed="false">暂停</button><button id="reset" title="重置计时 (T)">重置计时</button><button id="font-minus" title="备注字号减小 (-)">A−</button><button id="font-plus" title="备注字号增大 (+)">A+</button><button id="pv-black" title="黑屏 (B)">黑屏</button><button id="pv-white" title="白屏 (W)">白屏</button><button id="end" title="结束放映 (Esc)">结束放映</button><div id="ptimer">00:00</div><div class="bar"><div id="pprog"></div></div><div class="hint">' + hintText + '</div></footer>'
1458
+ + '<scr' + 'ipt>' + js + '</scr' + 'ipt></body></html>';
1030
1459
  }
1031
1460
 
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();
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() {
1474
+ if (presenterOpen()) { try { presenterWin.focus(); } catch (_) { /* gone */ } return; }
1475
+ let url = '';
1476
+ try {
1477
+ url = URL.createObjectURL(new Blob([presenterHtml()], { type: 'text/html' }));
1478
+ } catch (_) { toast(ui.popupBlocked); return; }
1479
+ presenterInitSent = false;
1480
+ presenterWin = window.open(url, 'dsh-ppt-presenter', 'width=1180,height=760');
1481
+ if (!presenterWin) {
1482
+ try { URL.revokeObjectURL(url); } catch (_) { /* ignore */ }
1483
+ toast(ui.popupBlocked);
1484
+ return;
1051
1485
  }
1052
- });
1486
+ document.body.classList.add('presenting');
1487
+ if (document.activeElement && typeof document.activeElement.blur === 'function') document.activeElement.blur();
1488
+ setTimeout(function () { try { URL.revokeObjectURL(url); } catch (_) { /* ignore */ } }, 5000);
1489
+ }
1053
1490
 
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
- });
1491
+ function closePresenter() {
1492
+ if (presenterOpen()) {
1493
+ try { presenterWin.postMessage({ __dshPpt: 'close' }, '*'); presenterWin.close(); } catch (_) { /* gone */ }
1494
+ }
1495
+ document.body.classList.remove('presenting');
1496
+ presenterInitSent = false;
1497
+ if (document.fullscreenElement) { try { document.exitFullscreen?.(); } catch (_) { /* ignore */ } }
1498
+ setBlank(null);
1499
+ }
1073
1500
 
1074
- notesBtn.addEventListener('click', () => {
1075
- document.body.classList.toggle('notes-open');
1076
- });
1501
+ function setBlank(color) {
1502
+ document.body.classList.remove('blank-black', 'blank-white');
1503
+ if (color === 'black') document.body.classList.add('blank-black');
1504
+ else if (color === 'white') document.body.classList.add('blank-white');
1505
+ sendState();
1506
+ }
1077
1507
 
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
- }
1508
+ setInterval(function () {
1509
+ if (!presenterOpen() && document.body.classList.contains('presenting')) {
1510
+ document.body.classList.remove('presenting');
1511
+ if (document.fullscreenElement) { try { document.exitFullscreen?.(); } catch (_) { /* ignore */ } }
1512
+ setBlank(null);
1513
+ }
1514
+ }, 800);
1515
+
1516
+ window.addEventListener('message', function (event) {
1517
+ if (event.source !== presenterWin) return;
1518
+ const data = event.data || {};
1519
+ if (data.__dshPpt === 'cmd' && (data.kind === 'end' || data.kind === 'closed')) { closePresenter(); return; }
1520
+ if (!presenterOpen()) return;
1521
+ if (data.__dshPpt === 'ready') {
1522
+ if (!presenterInitSent) { presenterInitSent = true; sendInit(); }
1523
+ sendState();
1524
+ return;
1525
+ }
1526
+ if (data.__dshPpt !== 'cmd') return;
1527
+ if (data.kind === 'go') {
1528
+ setBlank(null);
1529
+ if (typeof data.index === 'number') go(data.index);
1530
+ else go(index + (data.delta || 0));
1531
+ sendState();
1532
+ } else if (data.kind === 'blank') {
1533
+ setBlank(data.color || null);
1534
+ } else if (data.kind === 'end') {
1535
+ closePresenter();
1536
+ } else if (data.kind === 'closed') {
1537
+ document.body.classList.remove('presenting');
1538
+ setBlank(null);
1539
+ }
1540
+ });
1085
1541
 
1542
+ document.addEventListener('keydown', (event) => {
1543
+ const key = event.key;
1544
+ const lower = key.toLowerCase();
1545
+ 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;
1592
+ 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
+
1599
+ 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
+
1626
+ 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
+ }
1086
1633
  function renderHtmlSlide(slide, index, ui, langId) {
1087
1634
  const kicker = slide.kicker || (slide.layout === 'cover' ? ui.coverKicker : '')
1088
1635
  const title = slide.title || ''
@@ -1118,12 +1665,15 @@ function renderHtmlSlide(slide, index, ui, langId) {
1118
1665
  const rows = Array.isArray(slide.rows) ? slide.rows : []
1119
1666
  const head = rows[0] ?? []
1120
1667
  const body = rows.slice(1)
1121
- inner = '<div class="kicker">' + escapeHtml(kicker || ui.tableKicker) + '</div>' +
1122
- (title !== '' && title !== kicker ? '<h2>' + escapeHtml(title) + '</h2>' : '') +
1668
+ const isNum = (cell) => /^\s*[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*(?:%|%|次|页|个|人|元|天|小时|分钟)?\s*$/.test(String(cell ?? ''))
1669
+ const headingText = title !== '' ? title : (kicker || ui.tableKicker)
1670
+ const showKicker = kicker !== '' && kicker !== headingText
1671
+ inner = (showKicker ? '<div class="kicker">' + escapeHtml(kicker) + '</div>' : '') +
1672
+ '<h2>' + escapeHtml(headingText) + '</h2>' +
1123
1673
  '<table class="deck-table"><thead><tr>' +
1124
- head.map((cell) => '<th>' + escapeHtml(cell) + '</th>').join('') +
1674
+ head.map((cell) => '<th' + (isNum(cell) ? ' class="num"' : '') + '>' + escapeHtml(cell) + '</th>').join('') +
1125
1675
  '</tr></thead><tbody>' +
1126
- body.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
1676
+ body.map((row) => '<tr>' + row.map((cell) => '<td' + (isNum(cell) ? ' class="num"' : '') + '>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
1127
1677
  '</tbody></table>'
1128
1678
  break
1129
1679
  }