dsh-ppt 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - **演讲者备注**:结构化 `slides` 的 `notes` 字段与 Markdown `<!-- 备注: ... -->` 注释都会成为该页备注;HTML 放映按 `S` 键(或右下角「备注」按钮)呼出备注面板;PPTX 生成原生 `notesSlide` + `notesMaster` 部件,PowerPoint 演示者视图直接可用。
6
+ - **新增 `quote` 金句版式**:`>` 引用块自动识别,末行 `—— 出处` 识别为署名;HTML 与 PPTX 双端实现。
7
+ - **新增 `table` 表格版式**:Markdown 表格(`| ... |`)自动识别,PPTX 用原生 `a:tbl` 图形帧(主题色表头 + 隔行着色),限 9 行 8 列。
8
+ - 版式集合扩为 `cover | section | bullets | statement | quote | table | closing`;结构化 `slides` 增加 `rows` 字段。
9
+ - 无表格数据的 `table` 页自动退化为要点页,避免空白版式。
10
+ - 新特性测试 8 例(解析/规范化/HTML/PPTX 部件/端到端)。
11
+
12
+ ## 0.2.1
13
+
14
+ - 修复 PPTX zip 中央目录 local header 偏移恒为 0 导致 PowerPoint 提示修复的问题(issue #1/#3,社区 PR #2 同修法)。
15
+
3
16
  ## 0.2.0
4
17
 
5
18
  - 新增 `defaultTheme` / `defaultLang` 配置,作为 `ppt_create` 未显式传参时的默认值。
package/README.en.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-ppt
2
2
 
3
+ ![npm](https://img.shields.io/npm/v/dsh-ppt) ![downloads](https://img.shields.io/npm/dm/dsh-ppt) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-ppt) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-ppt?style=social)
4
+
3
5
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
6
 
5
7
  > **One sentence or one document → a complete presentation**: HTML web slideshow + PPTX export, 5 visual themes, bilingual Chinese/English.
@@ -12,6 +14,9 @@ DeepSeek Harness (DSH) presentation skill + tool plugin: turns a sentence, a par
12
14
  | --- | --- |
13
15
  | `ppt_create` tool | Markdown / structured slides → `*.html` + `*.pptx` + `*.json` |
14
16
  | `ppt_themes` tool | Lists the 5 built-in themes and their best use cases |
17
+ | 7 layouts | cover / section / bullets / statement / **quote** / **table** / closing |
18
+ | Speaker notes | `<!-- note: ... -->` comments or the `notes` field: press `S` in the HTML player; native PPTX notes slides (presenter view) |
19
+ | Markdown extras | Tables (`\| ... \|`), blockquotes (`>`), and note comments are auto-detected into matching layouts |
15
20
  | `dsh-ppt` skill | A complete six-step SOP registered into DSH |
16
21
  | Standalone SKILL.md | Copy `skills/dsh-ppt/` into Claude Code / Cursor / Gemini CLI / Codex for cross-harness use |
17
22
  | Visual engine | Reuses the hyperframes visual style library; HTML and PPTX share the same theme source |
@@ -22,6 +27,10 @@ Example:
22
27
  >
23
28
  > Turn `docs/quarterly-review.md` into a bilingual presentation and export PPTX.
24
29
 
30
+ ## Compatibility
31
+
32
+ Verified against `@deepseek-ai/dsh@0.1.2-alpha.3` on 2026-09-01. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
33
+
25
34
  ## Installation
26
35
 
27
36
  ```bash
@@ -30,6 +39,15 @@ dsh plugin --profile web add dsh-ppt
30
39
 
31
40
  After restart, the `ppt_create` / `ppt_themes` tools and the `dsh-ppt` skill are available. The plugin ships with an empty config and **won't crash startup**.
32
41
 
42
+ ## Uninstall
43
+
44
+ ```bash
45
+ dsh plugin --profile web remove dsh-ppt
46
+ ```
47
+
48
+ Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
49
+
50
+
33
51
  ## Quick start
34
52
 
35
53
  ### Inside DSH (recommended)
package/README.md CHANGED
@@ -16,6 +16,9 @@ DSH(DeepSeek Harness)演示文稿技能 + 工具插件:把一句话、一
16
16
  | --- | --- |
17
17
  | `ppt_create` 工具 | Markdown / 结构化 slides → `*.html` + `*.pptx` + `*.json` 三件套 |
18
18
  | `ppt_themes` 工具 | 列出 5 套内置主题与适用场景 |
19
+ | 7 种页型 | 封面 / 章节 / 要点 / 核心观点 / **金句** / **表格** / 结束页 |
20
+ | 演讲者备注 | `<!-- 备注: ... -->` 或 `notes` 字段:HTML 按 `S` 呼出,PPTX 原生备注页(演示者视图) |
21
+ | Markdown 进阶 | 表格(`\| ... \|`)、引用金句(`>`)、备注注释自动识别成对应页型 |
19
22
  | `dsh-ppt` 技能 | 完整 SOP 注册进 DSH:从一句话到成品 deck 的六步流水线 |
20
23
  | 裸 SKILL.md | 把 `skills/dsh-ppt/` 复制到 Claude Code / Cursor / Gemini CLI / Codex 即可跨 harness 使用 |
21
24
  | 视觉引擎 | 复用 hyperframes 视觉风格库,5 套主题 HTML 与 PPTX 同源同色 |
@@ -26,6 +29,10 @@ DSH(DeepSeek Harness)演示文稿技能 + 工具插件:把一句话、一
26
29
  >
27
30
  > 把 `docs/季度汇报.md` 做成双语演示文稿,导出 PPTX。
28
31
 
32
+ ## 兼容性
33
+
34
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.3` 上验证(2026-09-01 全量冒烟通过)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
35
+
29
36
  ## 安装
30
37
 
31
38
  ```bash
@@ -105,6 +112,15 @@ node <skill-dir>/scripts/build-deck.mjs \
105
112
 
106
113
  也可用环境变量 `DSH_PPT_OUTPUT_DIR` 指定默认输出目录;`ppt_create` 的 `outputDir`/`theme`/`lang` 参数优先级最高。
107
114
 
115
+
116
+ ## 卸载
117
+
118
+ ```bash
119
+ dsh plugin --profile web remove dsh-ppt
120
+ ```
121
+
122
+ 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中的对应插件行。
123
+
108
124
  ## 中英双语
109
125
 
110
126
  - `lang` 参数:`zh`(默认)/ `en` / `bilingual`,控制播放器界面、页码与结束页默认文案。
@@ -142,4 +158,4 @@ MIT。社区插件,与 DeepSeek 官方无关;`@deepseek-ai/*` 为官方保
142
158
 
143
159
  - [dsh-hyperframes](https://github.com/STARDUSTLC666/dsh-hyperframes) — HTML 视频创作技能(本插件视觉风格来源)
144
160
  - [dsh-remotion](https://github.com/STARDUSTLC666/dsh-remotion) — React 编程式视频技能
145
- - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件六件套
161
+ - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件六件套
package/lib/index.js CHANGED
@@ -157,10 +157,10 @@ export function apply(ctx, config = {}) {
157
157
  description: 'Build a complete presentation deck from one sentence or a Markdown document and write three artifacts to outputDir: a standalone HTML web slideshow, an editable 16:9 PPTX, and a deck.json manifest. Five built-in visual themes are available (see ppt_themes). content is Markdown text (recommended); advanced callers may pass structured slides instead. 中文:把一句话或一篇 Markdown 文档生成完整演示文稿,写入 outputDir 三个文件:独立 HTML 网页放映、可编辑 16:9 PPTX、deck.json manifest;内置 5 套视觉主题。',
158
158
  parameters: compileParameters({
159
159
  title: { type: 'string', required: true, description: 'Deck title (used for the cover and file names).' },
160
- content: { type: 'string', required: true, description: 'Markdown content: one sentence, a paragraph, or a full document. First # heading becomes the cover title; ## headings become slides; -/* lists become bullets. Required unless slides is provided.' },
160
+ content: { type: 'string', required: true, description: 'Markdown content: one sentence, a paragraph, or a full document. First # heading becomes the cover title; ## headings become slides; -/* lists become bullets; | ... | tables become table slides; > blockquotes become quote slides; <!-- 备注: ... --> comments become speaker notes. Required unless slides is provided.' },
161
161
  theme: { type: 'string', description: 'Visual theme id: swiss / velvet / data / soft / bold. Default data. See ppt_themes.' },
162
162
  lang: { type: 'string', description: 'UI language of the generated player: zh (default), en, or bilingual. Content language is whatever you write.' },
163
- slides: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Optional structured slides: [{ layout: cover|section|bullets|statement|closing, title, subtitle, kicker, bullets: [] }]. Use this for precise control instead of content.' },
163
+ slides: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Optional structured slides: [{ layout: cover|section|bullets|statement|quote|table|closing, title, subtitle, kicker, bullets: [], rows: [][] (for table), notes: "speaker notes" }]. Use this for precise control instead of content.' },
164
164
  outputDir: { type: 'string', description: 'Directory to write the files into. Default: session working directory (or the plugin outputDir config).' },
165
165
  fileName: { type: 'string', description: 'Base file name for the three artifacts. Default: sanitized deck title.' },
166
166
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ppt",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DSH 演示文稿技能 + 工具插件:一句话或一篇 Markdown 文档 → 完整演示文稿(独立 HTML 网页放映 + 可编辑 PPTX 导出),内置 5 套视觉主题,可配置默认主题/语言,中英双语,SKILL.md 双格式跨 harness。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -27,7 +27,6 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "tsc -p tsconfig.json",
30
- "prepare": "tsc -p tsconfig.json",
31
30
  "typecheck": "tsc -p tsconfig.json --noEmit",
32
31
  "test": "pnpm run build && node --test \"test/*.test.mjs\"",
33
32
  "test:core": "node --test \"test/*.test.mjs\"",
@@ -52,7 +51,7 @@
52
51
  ],
53
52
  "license": "MIT",
54
53
  "engines": {
55
- "node": ">=20"
54
+ "node": ">=22"
56
55
  },
57
56
  "devDependencies": {
58
57
  "@types/node": "^24.0.0",
@@ -25,7 +25,7 @@ allowed-tools: "Bash, Read, Write, Edit"
25
25
  - 跨 harness:`node <skill-dir>/scripts/build-deck.mjs --title "标题" --content deck.md --theme data --lang zh --out dist`
26
26
  - 输出三件套:`*.html`(双击即放映)、`*.pptx`(可编辑)、`*.json`(manifest)。
27
27
  5. **Verify 校验**:确认 HTML 页数与大纲一致、标题/要点无截断;PPTX 文件头为 `PK`(zip)。DSH 内用文件工具读取确认;跨 harness 用 `unzip -l deck.pptx | grep presentation.xml` 或 PowerShell `Expand-Archive` 抽查。
28
- 6. **Deliver 交付**:给用户三个绝对路径,并说明:HTML 浏览器直接打开(方向键翻页、F 全屏、G 总览、P 打印/另存 PDF);PPTX 用 PowerPoint / WPS / Keynote 打开。
28
+ 6. **Deliver 交付**:给用户三个绝对路径,并说明:HTML 浏览器直接打开(方向键翻页、F 全屏、G 总览、S 演讲者备注、P 打印/另存 PDF);PPTX 用 PowerPoint / WPS / Keynote 打开(备注在演示者视图可见)。
29
29
 
30
30
  ## 内容写作规则
31
31
 
@@ -35,6 +35,12 @@ allowed-tools: "Bash, Read, Write, Edit"
35
35
  - 中英双语:`lang` 只决定界面文字(页码/主题标签/结束页),**内容语言由你撰写**。要求双语时,优先每页中文标题 + 英文副标题,或直接生成两份 deck(`--lang zh` 与 `--lang en`)。
36
36
  - 更多规则见 `references/copywriting.md`。
37
37
 
38
+ ## Markdown 进阶语法(自动识别)
39
+
40
+ - **表格**:`| 列 | 列 |` 连续行(第二行 `| --- | --- |` 分隔线)→ 独立 `table` 页(主题色表头,限 9 行 8 列)。数据对比优先用表格,别堆要点。
41
+ - **金句**:`>` 引用块 → 独立 `quote` 页;最后一行写 `—— 出处` 会成为署名。
42
+ - **演讲者备注**:`<!-- 备注: 这页要讲的内容 -->`(或 `<!-- note: ... -->`)附着到该页。备注不进正文:HTML 放映按 `S` 呼出备注面板,PPTX 写入原生备注(演示者视图可见)。为汇报/演讲类 deck 主动写备注(口径、数据出处、应答预案)。
43
+
38
44
  ## 主题选择
39
45
 
40
46
  内置 5 套主题(源自 hyperframes 视觉风格库):
@@ -59,15 +65,17 @@ allowed-tools: "Bash, Read, Write, Edit"
59
65
 
60
66
  ```json
61
67
  [
62
- { "layout": "cover", "title": "标题", "subtitle": "副标题", "kicker": "开场" },
68
+ { "layout": "cover", "title": "标题", "subtitle": "副标题", "kicker": "开场", "notes": "开场先自我介绍 30 秒" },
63
69
  { "layout": "section", "kicker": "01", "title": "背景" },
64
70
  { "layout": "bullets", "title": "三个论点", "bullets": ["论点一", "论点二", "论点三"] },
71
+ { "layout": "table", "title": "业绩对比", "rows": [["大区", "营收"], ["华东", "1.2 亿"]], "notes": "强调华东增速" },
72
+ { "layout": "quote", "title": "慢就是快。", "subtitle": "创始人" },
65
73
  { "layout": "statement", "title": "核心观点一句话", "subtitle": "支撑说明" },
66
74
  { "layout": "closing", "title": "谢谢", "subtitle": "行动号召" }
67
75
  ]
68
76
  ```
69
77
 
70
- `layout` 仅限 `cover | section | bullets | statement | closing`。Markdown 输入不够精确时,用结构化 slides 重写。
78
+ `layout` 仅限 `cover | section | bullets | statement | quote | table | closing`;每页可选 `notes`(演讲者备注),`table` 页用 `rows`(首行为表头)。Markdown 输入不够精确时,用结构化 slides 重写。
71
79
 
72
80
  ## 质量门禁(交付前逐项确认)
73
81
 
@@ -18,7 +18,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'
18
18
  import { resolve as resolvePath } from 'node:path'
19
19
  import { deflateRawSync } from 'node:zlib'
20
20
 
21
- export const DECK_VERSION = '0.1.0'
21
+ export const DECK_VERSION = '0.2.0'
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // 主题
@@ -133,10 +133,14 @@ export const LANGUAGES = {
133
133
  slide: '第',
134
134
  of: '/ 共',
135
135
  theme: '主题',
136
- help: '← → 翻页 · F 全屏 · G 总览 · P 打印',
136
+ help: '← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
137
137
  coverKicker: '开场',
138
138
  pointKicker: '要点',
139
139
  statementKicker: '核心观点',
140
+ quoteKicker: '金句',
141
+ tableKicker: '数据',
142
+ notesLabel: '演讲者备注',
143
+ notesToggle: '备注',
140
144
  closingTitle: '谢谢',
141
145
  closingSubtitle: '讨论与问答',
142
146
  generatedBy: '由 dsh-ppt 生成',
@@ -149,10 +153,14 @@ export const LANGUAGES = {
149
153
  slide: 'Slide',
150
154
  of: '/',
151
155
  theme: 'Theme',
152
- help: '← → navigate · F fullscreen · G overview · P print',
156
+ help: '← → navigate · F fullscreen · G overview · S notes · P print',
153
157
  coverKicker: 'Opening',
154
158
  pointKicker: 'Key point',
155
159
  statementKicker: 'Core idea',
160
+ quoteKicker: 'Quote',
161
+ tableKicker: 'Data',
162
+ notesLabel: 'Speaker notes',
163
+ notesToggle: 'Notes',
156
164
  closingTitle: 'Thank You',
157
165
  closingSubtitle: 'Discussion & Q&A',
158
166
  generatedBy: 'Generated with dsh-ppt',
@@ -165,10 +173,14 @@ export const LANGUAGES = {
165
173
  slide: '第',
166
174
  of: '/ 共',
167
175
  theme: '主题 · Theme',
168
- help: '← → 翻页 · F 全屏 · G 总览 · P 打印',
176
+ help: '← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印',
169
177
  coverKicker: '开场 · Opening',
170
178
  pointKicker: '要点 · Key point',
171
179
  statementKicker: '核心观点 · Core Idea',
180
+ quoteKicker: '金句 · Quote',
181
+ tableKicker: '数据 · Data',
182
+ notesLabel: '演讲者备注 · Speaker notes',
183
+ notesToggle: '备注 · Notes',
172
184
  closingTitle: '谢谢 · Thank You',
173
185
  closingSubtitle: '讨论与问答 · Q&A',
174
186
  generatedBy: '由 dsh-ppt 生成 · Generated with dsh-ppt',
@@ -285,7 +297,49 @@ function chunk(items, size) {
285
297
  // ---------------------------------------------------------------------------
286
298
 
287
299
  function createSection(heading = '', level = 0, coverOnly = false) {
288
- return { heading, level, coverOnly, bullets: [], paragraphs: [] }
300
+ return { heading, level, coverOnly, bullets: [], paragraphs: [], quote: [], table: null, notes: [] }
301
+ }
302
+
303
+ function splitTableRow(line) {
304
+ const trimmed = line.trim()
305
+ const match = /^\|(.+)\|$/.exec(trimmed)
306
+ if (match === null) return null
307
+ return match[1].split('|').map((cell) => cell.trim())
308
+ }
309
+
310
+ function isTableSeparator(cells) {
311
+ return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell) || /^:?-+:?$/.test(cell))
312
+ }
313
+
314
+ const NOTES_COMMENT = /^<!--\s*(?:notes?|备注)\s*[::]\s*([\s\S]*?)\s*-->$/
315
+
316
+ function quoteSlideFrom(section, ui) {
317
+ const lines = [...section.quote]
318
+ let attribution = ''
319
+ const attrMatch = /^(?:——|—|--|-)\s*(.+)$/.exec(lines[lines.length - 1] ?? '')
320
+ if (attrMatch !== null && lines.length > 1) {
321
+ attribution = attrMatch[1]
322
+ lines.pop()
323
+ }
324
+ return { layout: 'quote', kicker: ui.quoteKicker, title: truncate(lines.join(' '), 220), subtitle: attribution }
325
+ }
326
+
327
+ function tableSlideFrom(section, ui) {
328
+ const table = section.table
329
+ const rows = [table.header, ...table.rows]
330
+ .slice(0, 9)
331
+ .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
332
+ const heading = section.heading || ui.tableKicker
333
+ return { layout: 'table', kicker: heading, title: heading, rows }
334
+ }
335
+
336
+ /** 把一个 section 里的金句/表格拆成独立页;备注附着到第一张产出页。 */
337
+ function specialSlidesFor(section, ui) {
338
+ const out = []
339
+ if (section.quote.length > 0) out.push(quoteSlideFrom(section, ui))
340
+ if (section.table !== null) out.push(tableSlideFrom(section, ui))
341
+ if (out.length > 0 && section.notes.length > 0) out[0].notes = section.notes.join('\n')
342
+ return out
289
343
  }
290
344
 
291
345
  export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
@@ -347,6 +401,38 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
347
401
  continue
348
402
  }
349
403
 
404
+ // 演讲者备注:`<!-- 备注: ... -->` / `<!-- note: ... -->` 附到当前页
405
+ const notesMatch = NOTES_COMMENT.exec(line)
406
+ if (notesMatch !== null && notesMatch[1].trim() !== '') {
407
+ if (current === null) current = createSection()
408
+ current.notes.push(notesMatch[1].trim())
409
+ continue
410
+ }
411
+
412
+ // Markdown 表格:连续的 | ... | 行,第二行是分隔线
413
+ const tableCells = splitTableRow(line)
414
+ if (tableCells !== null) {
415
+ if (current === null) current = createSection()
416
+ if (isTableSeparator(tableCells)) continue
417
+ if (current.table === null) {
418
+ current.table = { header: tableCells.map((cell) => stripInlineMarkdown(cell)), rows: [] }
419
+ } else if (current.table.rows.length < 12) {
420
+ current.table.rows.push(tableCells.map((cell) => stripInlineMarkdown(cell)))
421
+ }
422
+ continue
423
+ }
424
+
425
+ // 金句:`>` 引用块(最后一行 `—— 出处` 识别为署名)
426
+ const quoteMatch = /^>\s?(.*)$/.exec(line)
427
+ if (quoteMatch !== null) {
428
+ const quoted = stripInlineMarkdown(quoteMatch[1])
429
+ if (quoted !== '') {
430
+ if (current === null) current = createSection()
431
+ current.quote.push(quoted)
432
+ }
433
+ continue
434
+ }
435
+
350
436
  const paragraph = stripInlineMarkdown(line)
351
437
  if (paragraph === '') continue
352
438
  if (current === null) current = createSection()
@@ -404,24 +490,31 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
404
490
  }))
405
491
  const firstSentence = ordered[0]?.sentences[0] ?? ''
406
492
  if (coverSubtitle === '') coverSubtitle = truncate(firstSentence, 180)
407
- const rest = ordered.flatMap((group, groupIndex) => {
493
+ const bodySlides = []
494
+ let pointIndex = 0
495
+ ordered.forEach((group, groupIndex) => {
496
+ const specials = specialSlidesFor(group.section, ui)
497
+ bodySlides.push(...specials)
408
498
  const sentences = (groupIndex === 0 && !coverSubtitleConsumed) ? group.sentences.slice(1) : group.sentences
409
- return chunk(sentences, 5)
499
+ for (const points of chunk(sentences, 5)) {
500
+ pointIndex += 1
501
+ const slide = {
502
+ layout: 'bullets',
503
+ kicker: ui.pointKicker + ' ' + pointIndex,
504
+ title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
505
+ bullets: points,
506
+ }
507
+ if (specials.length === 0 && group.section.notes.length > 0) slide.notes = group.section.notes.join('\n')
508
+ bodySlides.push(slide)
509
+ }
410
510
  })
411
511
  slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
412
- if (rest.length === 0) {
512
+ if (bodySlides.length === 0) {
413
513
  if (coverSubtitle !== '') {
414
514
  slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle || 'Untitled' })
415
515
  }
416
516
  } else {
417
- rest.forEach((points, index) => {
418
- slides.push({
419
- layout: 'bullets',
420
- kicker: ui.pointKicker + ' ' + (index + 1),
421
- title: truncate(points[0], 40) || ui.pointKicker + ' ' + (index + 1),
422
- bullets: points,
423
- })
424
- })
517
+ slides.push(...bodySlides)
425
518
  }
426
519
  slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
427
520
  return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
@@ -430,18 +523,23 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
430
523
  slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
431
524
  let pointIndex = 0
432
525
  for (const section of bodySections) {
526
+ const notes = section.notes.join('\n')
527
+ const specials = specialSlidesFor(section, ui)
528
+ slides.push(...specials)
433
529
  if (section.heading === '') {
434
530
  const points = [...section.bullets, ...section.paragraphs]
435
531
  .flatMap((part) => splitSentences(part))
436
532
  .slice(0, 8)
437
533
  if (points.length > 0) {
438
534
  pointIndex += 1
439
- slides.push({
535
+ const slide = {
440
536
  layout: 'bullets',
441
537
  kicker: ui.pointKicker + ' ' + pointIndex,
442
538
  title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
443
539
  bullets: points,
444
- })
540
+ }
541
+ if (specials.length === 0 && notes !== '') slide.notes = notes
542
+ slides.push(slide)
445
543
  }
446
544
  continue
447
545
  }
@@ -450,16 +548,20 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
450
548
  ...section.paragraphs.flatMap((part) => splitSentences(part)),
451
549
  ].slice(0, 8)
452
550
  if (points.length > 0) {
453
- slides.push({ layout: 'bullets', kicker: section.heading, title: section.heading, bullets: points })
454
- } else {
455
- slides.push({ layout: 'section', kicker: section.heading, title: section.heading })
551
+ const slide = { layout: 'bullets', kicker: section.heading, title: section.heading, bullets: points }
552
+ if (specials.length === 0 && notes !== '') slide.notes = notes
553
+ slides.push(slide)
554
+ } else if (specials.length === 0) {
555
+ const slide = { layout: 'section', kicker: section.heading, title: section.heading }
556
+ if (notes !== '') slide.notes = notes
557
+ slides.push(slide)
456
558
  }
457
559
  }
458
560
  slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
459
561
  return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
460
562
  }
461
563
 
462
- const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing'])
564
+ const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
463
565
 
464
566
  function normalizeSlide(raw, index) {
465
567
  const source = (raw !== null && typeof raw === 'object') ? raw : {}
@@ -468,6 +570,8 @@ function normalizeSlide(raw, index) {
468
570
  const subtitle = stripInlineMarkdown(source.subtitle ?? '')
469
571
  const kicker = stripInlineMarkdown(source.kicker ?? '')
470
572
  const text = stripInlineMarkdown(source.text ?? '')
573
+ // 备注保留换行(放映面板与备注页都按原文显示),只做转义,不做 Markdown 清洗
574
+ const notes = String(source.notes ?? '').trim()
471
575
  let bullets = []
472
576
  if (Array.isArray(source.bullets)) {
473
577
  bullets = source.bullets.map((item) => stripInlineMarkdown(String(item))).filter(Boolean)
@@ -475,9 +579,29 @@ function normalizeSlide(raw, index) {
475
579
  bullets = splitSentences(source.bullets)
476
580
  }
477
581
  if (layout === 'statement' && title === '' && text !== '') {
478
- return { layout, kicker, title: text, subtitle: subtitle || '', bullets }
582
+ return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
583
+ }
584
+ if (layout === 'quote') {
585
+ const quoteText = title !== '' ? title : text
586
+ if (quoteText === '') return { layout: 'statement', kicker, title: subtitle, subtitle: '', bullets, ...(notes !== '' ? { notes } : {}) }
587
+ return { layout, kicker, title: truncate(quoteText, 220), subtitle, ...(notes !== '' ? { notes } : {}) }
588
+ }
589
+ if (layout === 'table') {
590
+ let rows = []
591
+ if (Array.isArray(source.rows)) {
592
+ rows = source.rows
593
+ .map((row) => (Array.isArray(row) ? row : [row]).map((cell) => stripInlineMarkdown(String(cell))))
594
+ .filter((row) => row.some((cell) => cell !== ''))
595
+ .slice(0, 9)
596
+ .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
597
+ }
598
+ if (rows.length === 0) {
599
+ // 没有表格数据的 table 页退化成要点页,避免产出空白版式
600
+ return { layout: 'bullets', kicker, title, subtitle, bullets, ...(notes !== '' ? { notes } : {}) }
601
+ }
602
+ return { layout, kicker: kicker || title, title, rows, ...(notes !== '' ? { notes } : {}) }
479
603
  }
480
- return { layout, kicker, title, subtitle, text, bullets }
604
+ return { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }
481
605
  }
482
606
 
483
607
  export function normalizeSlides(rawSlides, maxSlides = 60) {
@@ -647,6 +771,28 @@ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
647
771
  .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
648
772
  .closing .subtitle{color:var(--accent);font-weight:700}
649
773
  .cover h1{font-weight:900}
774
+ .quote-mark{
775
+ font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
776
+ color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
777
+ }
778
+ .quote-text{
779
+ font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
780
+ font-weight:700;max-width:22ch;margin:0;
781
+ }
782
+ .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
783
+ table.deck-table{
784
+ border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:1120px;
785
+ font-size:clamp(13px,1.55vw,23px);
786
+ }
787
+ .deck-table th{
788
+ background:var(--accent);color:var(--bg);text-align:left;font-weight:700;
789
+ padding:.6em .9em;letter-spacing:.02em;
790
+ }
791
+ .deck-table td{
792
+ padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 34%, transparent);
793
+ color:var(--fg);
794
+ }
795
+ .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
650
796
  #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
651
797
  #hud{
652
798
  position:fixed;right:22px;bottom:18px;z-index:30;display:flex;gap:14px;align-items:center;
@@ -658,16 +804,29 @@ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
658
804
  padding:7px 13px;font:inherit;cursor:pointer;
659
805
  }
660
806
  #hud button:hover{border-color:var(--accent);color:var(--accent)}
807
+ #notes-panel{
808
+ position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;
809
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);
810
+ border-top:2px solid var(--accent);
811
+ padding:16px clamp(22px,4vw,64px) 20px;max-height:38vh;overflow:auto;
812
+ }
813
+ body.notes-open #notes-panel.has-notes{display:block}
814
+ #notes-panel .notes-label{
815
+ color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
816
+ font-size:12px;margin-bottom:8px;
817
+ }
818
+ #notes-text{white-space:pre-wrap;line-height:1.65;font-size:15px;color:var(--fg)}
661
819
  body.overview .slide{display:flex !important;position:relative;inset:auto;width:100%;height:100vh}
662
820
  body.overview{overflow:auto}
663
821
  body.overview #progress,body.overview #hud{position:fixed}
822
+ body.overview #notes-panel{display:none !important}
664
823
  @media (max-width:640px){
665
824
  #hud{right:12px;bottom:10px;gap:8px;font-size:11px}
666
825
  }
667
826
  @media print{
668
827
  html,body{height:auto;overflow:visible;background:#fff}
669
828
  .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
670
- #progress,#hud{display:none !important}
829
+ #progress,#hud,#notes-panel{display:none !important}
671
830
  }
672
831
  </style>
673
832
  </head>
@@ -677,23 +836,38 @@ ${slides}
677
836
  <div id="hud" aria-live="polite">
678
837
  <span id="counter">${ui.slide} 1 ${ui.of} ${total}</span>
679
838
  <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
839
+ <button id="notes-toggle" title="S">${escapeHtml(ui.notesToggle)}</button>
680
840
  <button id="fullscreen" title="F">⛶</button>
681
841
  </div>
842
+ <div id="notes-panel" aria-live="polite">
843
+ <div class="notes-label">${escapeHtml(ui.notesLabel)}</div>
844
+ <div id="notes-text"></div>
845
+ </div>
682
846
  <script>
683
847
  (() => {
684
848
  const slides = Array.from(document.querySelectorAll('.slide'));
685
849
  const counter = document.getElementById('counter');
686
850
  const progress = document.getElementById('progress');
687
851
  const fullscreenBtn = document.getElementById('fullscreen');
852
+ const notesPanel = document.getElementById('notes-panel');
853
+ const notesText = document.getElementById('notes-text');
854
+ const notesBtn = document.getElementById('notes-toggle');
688
855
  let index = 0;
689
856
  const total = slides.length;
690
857
  const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
691
858
 
859
+ function refreshNotes() {
860
+ const notes = slides[index]?.dataset.notes || '';
861
+ notesPanel.classList.toggle('has-notes', notes !== '');
862
+ notesText.textContent = notes;
863
+ }
864
+
692
865
  function go(next) {
693
866
  index = (next + total) % total;
694
867
  slides.forEach((slide, i) => slide.classList.toggle('is-active', i === index));
695
868
  counter.textContent = ui.slide + ' ' + (index + 1) + ' ' + ui.of + ' ' + total;
696
869
  progress.style.width = ((index + 1) / total * 100) + '%';
870
+ refreshNotes();
697
871
  document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
698
872
  try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch { /* file:// 下个别浏览器可能拒绝 */ }
699
873
  }
@@ -713,6 +887,8 @@ ${slides}
713
887
  } else if (event.key.toLowerCase() === 'g') {
714
888
  document.body.classList.toggle('overview');
715
889
  go(index);
890
+ } else if (event.key.toLowerCase() === 's') {
891
+ document.body.classList.toggle('notes-open');
716
892
  } else if (event.key.toLowerCase() === 'p') {
717
893
  window.print();
718
894
  }
@@ -738,6 +914,10 @@ ${slides}
738
914
  else document.exitFullscreen?.();
739
915
  });
740
916
 
917
+ notesBtn.addEventListener('click', () => {
918
+ document.body.classList.toggle('notes-open');
919
+ });
920
+
741
921
  const start = Number.parseInt(location.hash?.replace('#slide-', ''), 10);
742
922
  go(Number.isInteger(start) ? start - 1 : 0);
743
923
  })();
@@ -771,6 +951,25 @@ function renderHtmlSlide(slide, index, ui, langId) {
771
951
  '<div class="statement-title">' + escapeHtml(title || slide.text || '') + '</div>' +
772
952
  (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
773
953
  break
954
+ case 'quote':
955
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.quoteKicker) + '</div>' +
956
+ '<div class="quote-mark" aria-hidden="true">\u201C</div>' +
957
+ '<blockquote class="quote-text">' + escapeHtml(title || slide.text || '') + '</blockquote>' +
958
+ (subtitle !== '' ? '<div class="quote-attr">\u2014\u2014 ' + escapeHtml(subtitle) + '</div>' : '')
959
+ break
960
+ case 'table': {
961
+ const rows = Array.isArray(slide.rows) ? slide.rows : []
962
+ const head = rows[0] ?? []
963
+ const body = rows.slice(1)
964
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.tableKicker) + '</div>' +
965
+ (title !== '' && title !== kicker ? '<h2>' + escapeHtml(title) + '</h2>' : '') +
966
+ '<table class="deck-table"><thead><tr>' +
967
+ head.map((cell) => '<th>' + escapeHtml(cell) + '</th>').join('') +
968
+ '</tr></thead><tbody>' +
969
+ body.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
970
+ '</tbody></table>'
971
+ break
972
+ }
774
973
  case 'closing':
775
974
  inner = '<h1>' + escapeHtml(title || ui.closingTitle) + '</h1>' +
776
975
  (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
@@ -783,7 +982,10 @@ function renderHtmlSlide(slide, index, ui, langId) {
783
982
  '<ul>' + bullets.map((bullet) => '<li>' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
784
983
  break
785
984
  }
786
- return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '">' +
985
+ const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
986
+ ? ' data-notes="' + escapeHtml(slide.notes) + '"'
987
+ : ''
988
+ return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
787
989
  '<div class="slide-inner">' + inner + '</div></section>'
788
990
  }
789
991
 
@@ -862,6 +1064,42 @@ function accentBarXml(id, name, box, color) {
862
1064
  '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>'
863
1065
  }
864
1066
 
1067
+ /** 原生 OOXML 表格(graphicFrame + a:tbl):首行主题色表头,隔行面板色。 */
1068
+ function tableGraphicXml(id, name, box, rows, theme) {
1069
+ const p = theme.palette
1070
+ const font = pptxFontName(theme.fonts.body)
1071
+ const safeRows = rows.length > 0 ? rows : [['']]
1072
+ const cols = Math.max(1, safeRows[0].length)
1073
+ const colW = Math.floor(box.w / cols)
1074
+ const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
1075
+ const rowH = 420000
1076
+ const cellXml = (text, fill, color, bold) =>
1077
+ '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1078
+ '<a:r><a:rPr lang="zh-CN" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1079
+ '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1080
+ '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1081
+ '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
1082
+ '<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
1083
+ (fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
1084
+ '</a:tcPr></a:tc>'
1085
+ const trs = safeRows.map((row, rowIndex) => {
1086
+ const padded = Array.from({ length: cols }, (_, i) => row[i] ?? '')
1087
+ const cells = rowIndex === 0
1088
+ ? padded.map((c) => cellXml(c, p.accent, p.bg, true)).join('')
1089
+ : padded.map((c) => cellXml(c, rowIndex % 2 === 1 ? p.panel : '', p.fg, false)).join('')
1090
+ return '<a:tr h="' + rowH + '">' + cells + '</a:tr>'
1091
+ }).join('')
1092
+ const height = Math.max(1, safeRows.length) * rowH
1093
+ return '<p:graphicFrame>' +
1094
+ '<p:nvGraphicFramePr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/>' +
1095
+ '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>' +
1096
+ '<p:xfrm><a:off x="' + box.x + '" y="' + box.y + '"/><a:ext cx="' + box.w + '" cy="' + height + '"/></p:xfrm>' +
1097
+ '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">' +
1098
+ '<a:tbl><a:tblPr firstRow="1" bandRow="1"/>' +
1099
+ '<a:tblGrid>' + grid + '</a:tblGrid>' + trs + '</a:tbl>' +
1100
+ '</a:graphicData></a:graphic></p:graphicFrame>'
1101
+ }
1102
+
865
1103
  function slideShapeList(slide, index, total, theme, langId) {
866
1104
  const p = theme.palette
867
1105
  const font = pptxFontName(theme.fonts.heading)
@@ -914,6 +1152,31 @@ function slideShapeList(slide, index, total, theme, langId) {
914
1152
  paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
915
1153
  ]))
916
1154
  }
1155
+ } else if (slide.layout === 'quote') {
1156
+ shapes.push(textShapeXml(idBase + 1, 'Quote mark', { x: 900000, y: 1050000, w: 1700000, h: 1900000 }, [
1157
+ paragraphXml('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1158
+ ]))
1159
+ shapes.push(textShapeXml(idBase + 2, 'Quote', { x: 1050000, y: 2550000, w: 10100000, h: 2600000 }, [
1160
+ paragraphXml(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1161
+ ]))
1162
+ if (subtitle !== '') {
1163
+ shapes.push(textShapeXml(idBase + 3, 'Attribution', { x: 1070000, y: 5300000, w: 9000000, h: 600000 }, [
1164
+ paragraphXml('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1165
+ ]))
1166
+ }
1167
+ } else if (slide.layout === 'table') {
1168
+ const hasTitle = title !== '' && title !== kicker
1169
+ if (kicker !== '') {
1170
+ shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1171
+ paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1172
+ ]))
1173
+ }
1174
+ if (hasTitle) {
1175
+ shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1176
+ paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1177
+ ]))
1178
+ }
1179
+ shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme))
917
1180
  } else if (slide.layout === 'closing') {
918
1181
  shapes.push(textShapeXml(idBase + 1, 'Title', { x: 1050000, y: 2300000, w: 10200000, h: 1700000 }, [
919
1182
  paragraphXml(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
@@ -961,34 +1224,93 @@ function slideXml(slide, index, total, theme, langId) {
961
1224
  '</p:sld>'
962
1225
  }
963
1226
 
964
- function slideRelXml() {
1227
+ function slideRelXml(hasNotes, slideIndex) {
1228
+ const notesRel = hasNotes
1229
+ ? '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide' + slideIndex + '.xml"/>'
1230
+ : ''
965
1231
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
966
1232
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
967
1233
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1234
+ notesRel +
968
1235
  '</Relationships>'
969
1236
  }
970
1237
 
971
- function presentationXml(slideCount) {
1238
+ function presentationXml(slideCount, hasNotesMaster) {
972
1239
  const slideIds = Array.from({ length: slideCount }, (_, i) =>
973
1240
  '<p:sldId id="' + (256 + i) + '" r:id="rId' + (i + 2) + '"/>').join('')
1241
+ const notesMasterId = hasNotesMaster
1242
+ ? '<p:notesMasterIdLst><p:notesMasterId id="2147483649" r:id="rId' + (slideCount + 2) + '"/></p:notesMasterIdLst>'
1243
+ : ''
974
1244
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
975
1245
  '<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
976
1246
  'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
977
1247
  'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
978
1248
  '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>' +
979
1249
  '<p:sldIdLst>' + slideIds + '</p:sldIdLst>' +
1250
+ notesMasterId +
980
1251
  '<p:sldSz cx="' + EMU_W + '" cy="' + EMU_H + '" type="screen16x9"/>' +
981
1252
  '<p:notesSz cx="6858000" cy="9144000"/>' +
982
1253
  '</p:presentation>'
983
1254
  }
984
1255
 
985
- function presentationRelXml(slideCount) {
1256
+ function presentationRelXml(slideCount, hasNotesMaster) {
986
1257
  const slideRels = Array.from({ length: slideCount }, (_, i) =>
987
1258
  '<Relationship Id="rId' + (i + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide' + (i + 1) + '.xml"/>').join('')
1259
+ const notesMasterRel = hasNotesMaster
1260
+ ? '<Relationship Id="rId' + (slideCount + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>'
1261
+ : ''
988
1262
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
989
1263
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
990
1264
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>' +
991
1265
  slideRels +
1266
+ notesMasterRel +
1267
+ '</Relationships>'
1268
+ }
1269
+
1270
+ function notesMasterXml() {
1271
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1272
+ '<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1273
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1274
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1275
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1276
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1277
+ '</p:spTree></p:cSld>' +
1278
+ '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' +
1279
+ '<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle>' +
1280
+ '</p:notesMaster>'
1281
+ }
1282
+
1283
+ function notesMasterRelXml() {
1284
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1285
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1286
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1287
+ '</Relationships>'
1288
+ }
1289
+
1290
+ function notesSlideXml(notes) {
1291
+ const paragraphs = String(notes ?? '').split('\n')
1292
+ .filter((line) => line.trim() !== '')
1293
+ .map((line) => '<a:p><a:r><a:rPr lang="zh-CN" sz="1200" dirty="0"/><a:t>' + escapeXml(line) + '</a:t></a:r></a:p>')
1294
+ .join('')
1295
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1296
+ '<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1297
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1298
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1299
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1300
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>' +
1301
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr/><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp>' +
1302
+ '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/>' +
1303
+ '<p:txBody><a:bodyPr/><a:lstStyle/>' + paragraphs + '</p:txBody></p:sp>' +
1304
+ '</p:spTree></p:cSld>' +
1305
+ '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1306
+ '</p:notes>'
1307
+ }
1308
+
1309
+ function notesSlideRelXml(slideIndex) {
1310
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1311
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1312
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide' + slideIndex + '.xml"/>' +
1313
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>' +
992
1314
  '</Relationships>'
993
1315
  }
994
1316
 
@@ -1070,9 +1392,14 @@ function themeXml(theme) {
1070
1392
  '</a:theme>'
1071
1393
  }
1072
1394
 
1073
- function contentTypesXml(slideCount) {
1395
+ function contentTypesXml(slideCount, noteIndexes) {
1074
1396
  const overrides = Array.from({ length: slideCount }, (_, i) =>
1075
1397
  '<Override PartName="/ppt/slides/slide' + (i + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>').join('')
1398
+ const notesOverrides = noteIndexes.map((index) =>
1399
+ '<Override PartName="/ppt/notesSlides/notesSlide' + (index + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>').join('')
1400
+ const notesMasterOverride = noteIndexes.length > 0
1401
+ ? '<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
1402
+ : ''
1076
1403
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1077
1404
  '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
1078
1405
  '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
@@ -1083,7 +1410,9 @@ function contentTypesXml(slideCount) {
1083
1410
  '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>' +
1084
1411
  '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
1085
1412
  '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
1413
+ notesMasterOverride +
1086
1414
  overrides +
1415
+ notesOverrides +
1087
1416
  '</Types>'
1088
1417
  }
1089
1418
 
@@ -1106,12 +1435,12 @@ function coreXml(title) {
1106
1435
  '</cp:coreProperties>'
1107
1436
  }
1108
1437
 
1109
- function appXml(slideCount) {
1438
+ function appXml(slideCount, notesCount) {
1110
1439
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1111
1440
  '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' +
1112
1441
  'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
1113
1442
  '<Application>dsh-ppt</Application><PresentationFormat>Widescreen</PresentationFormat>' +
1114
- '<Slides>' + slideCount + '</Slides><Notes>0</Notes><HiddenSlides>0</HiddenSlides>' +
1443
+ '<Slides>' + slideCount + '</Slides><Notes>' + notesCount + '</Notes><HiddenSlides>0</HiddenSlides>' +
1115
1444
  '</Properties>'
1116
1445
  }
1117
1446
 
@@ -1121,6 +1450,7 @@ function buildZip(entries) {
1121
1450
  let offset = 0
1122
1451
 
1123
1452
  for (const entry of entries) {
1453
+ const entryOffset = offset
1124
1454
  const name = Buffer.from(entry.name, 'utf8')
1125
1455
  const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), 'utf8')
1126
1456
  const crc = crc32(data)
@@ -1160,7 +1490,7 @@ function buildZip(entries) {
1160
1490
  centralHeader.writeUInt16LE(0, 34)
1161
1491
  centralHeader.writeUInt16LE(0, 36)
1162
1492
  centralHeader.writeUInt32LE(0, 38)
1163
- centralHeader.writeUInt32LE(0, 42)
1493
+ centralHeader.writeUInt32LE(entryOffset, 42)
1164
1494
  central.push(centralHeader, name)
1165
1495
  }
1166
1496
 
@@ -1201,22 +1531,36 @@ export function buildPptx(manifest, themeInput, languageInput) {
1201
1531
  const theme = themeInput?.id ? themeInput : resolveTheme(themeInput)
1202
1532
  const language = languageInput?.id ? languageInput : resolveLanguage(languageInput)
1203
1533
  const slides = manifest.slides
1534
+ const noteIndexes = []
1535
+ slides.forEach((slide, index) => {
1536
+ if (typeof slide.notes === 'string' && slide.notes.trim() !== '') noteIndexes.push(index)
1537
+ })
1538
+ const hasNotesMaster = noteIndexes.length > 0
1204
1539
  const entries = [
1205
- { name: '[Content_Types].xml', data: contentTypesXml(slides.length) },
1540
+ { name: '[Content_Types].xml', data: contentTypesXml(slides.length, noteIndexes) },
1206
1541
  { name: '_rels/.rels', data: rootRelXml() },
1207
- { name: 'docProps/app.xml', data: appXml(slides.length) },
1542
+ { name: 'docProps/app.xml', data: appXml(slides.length, noteIndexes.length) },
1208
1543
  { name: 'docProps/core.xml', data: coreXml(manifest.title) },
1209
- { name: 'ppt/presentation.xml', data: presentationXml(slides.length) },
1210
- { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length) },
1544
+ { name: 'ppt/presentation.xml', data: presentationXml(slides.length, hasNotesMaster) },
1545
+ { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length, hasNotesMaster) },
1211
1546
  { name: 'ppt/slideMasters/slideMaster1.xml', data: slideMasterXml(theme) },
1212
1547
  { name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: slideMasterRelXml() },
1213
1548
  { name: 'ppt/slideLayouts/slideLayout1.xml', data: slideLayoutXml(theme) },
1214
1549
  { name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: slideLayoutRelXml() },
1215
1550
  { name: 'ppt/theme/theme1.xml', data: themeXml(theme) },
1216
1551
  ]
1552
+ if (hasNotesMaster) {
1553
+ entries.push({ name: 'ppt/notesMasters/notesMaster1.xml', data: notesMasterXml() })
1554
+ entries.push({ name: 'ppt/notesMasters/_rels/notesMaster1.xml.rels', data: notesMasterRelXml() })
1555
+ }
1217
1556
  slides.forEach((slide, index) => {
1557
+ const hasNotes = noteIndexes.includes(index)
1218
1558
  entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id) })
1219
- entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml() })
1559
+ entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1560
+ if (hasNotes) {
1561
+ entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes) })
1562
+ entries.push({ name: 'ppt/notesSlides/_rels/notesSlide' + (index + 1) + '.xml.rels', data: notesSlideRelXml(index + 1) })
1563
+ }
1220
1564
  })
1221
1565
  return buildZip(entries)
1222
1566
  }