dsh-ppt 0.2.1 → 0.4.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - **动效(motion,默认开)**:HTML 放映页间淡入 + 要点按序逐条入场(打印自动兜底为静态);PPTX 每页原生淡入转场,`bullets` 页按段落构建、放映时点击一次出现一条要点(PowerPoint「出现,按段落」标准时序)。
6
+ - **`motion` 开关**:`ppt_create` 新增 `motion: 'on' | 'off'`,CLI 新增 `--motion on|off`;`off` 时产物不含任何动画代码,适合严肃/打印场景。manifest 记录该开关。
7
+ - **技能文件瘦身**:SKILL.md 收敛为触发条件 + 执行纪律 + 六步流水线 + 按需装载索引 + 质量门禁;新增 `references/syntax.md`(Markdown 解析、表格/金句/备注语法、结构化 slides、motion)与 `references/troubleshooting.md`;写作规则去重归口 `references/copywriting.md`,hyperframes 视觉联动并入 `references/themes.md`。
8
+ - 动效相关测试 8 例(转场/时序树/点击节点计数/静态开关/CLI/工具层),全套 42/42 通过。
9
+
10
+ ## 0.3.0
11
+
12
+ - **演讲者备注**:结构化 `slides` 的 `notes` 字段与 Markdown `<!-- 备注: ... -->` 注释都会成为该页备注;HTML 放映按 `S` 键(或右下角「备注」按钮)呼出备注面板;PPTX 生成原生 `notesSlide` + `notesMaster` 部件,PowerPoint 演示者视图直接可用。
13
+ - **新增 `quote` 金句版式**:`>` 引用块自动识别,末行 `—— 出处` 识别为署名;HTML 与 PPTX 双端实现。
14
+ - **新增 `table` 表格版式**:Markdown 表格(`| ... |`)自动识别,PPTX 用原生 `a:tbl` 图形帧(主题色表头 + 隔行着色),限 9 行 8 列。
15
+ - 版式集合扩为 `cover | section | bullets | statement | quote | table | closing`;结构化 `slides` 增加 `rows` 字段。
16
+ - 无表格数据的 `table` 页自动退化为要点页,避免空白版式。
17
+ - 新特性测试 8 例(解析/规范化/HTML/PPTX 部件/端到端)。
18
+
19
+ ## 0.2.1
20
+
21
+ - 修复 PPTX zip 中央目录 local header 偏移恒为 0 导致 PowerPoint 提示修复的问题(issue #1/#3,社区 PR #2 同修法)。
22
+
3
23
  ## 0.2.0
4
24
 
5
25
  - 新增 `defaultTheme` / `defaultLang` 配置,作为 `ppt_create` 未显式传参时的默认值。
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
6
6
 
7
- > **One sentence or one document → a complete presentation**: HTML web slideshow + PPTX export, 5 visual themes, bilingual Chinese/English.
7
+ > **One sentence or one document → a complete presentation**: HTML web slideshow + PPTX export, 5 visual themes, slide transitions + bullet entrance animations, bilingual Chinese/English.
8
8
 
9
9
  DeepSeek Harness (DSH) presentation skill + tool plugin: turns a sentence, a paragraph, or a Markdown document into a ready-to-present **HTML slideshow** and an editable **PPTX**. Pure Node, **zero runtime dependencies**, one codebase for Windows / macOS / Linux.
10
10
 
@@ -14,6 +14,10 @@ DeepSeek Harness (DSH) presentation skill + tool plugin: turns a sentence, a par
14
14
  | --- | --- |
15
15
  | `ppt_create` tool | Markdown / structured slides → `*.html` + `*.pptx` + `*.json` |
16
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
+ | Motion | On by default: HTML slide-in transitions + staggered bullet entrances; native PPTX fade transitions + click-to-reveal bullets. `motion: 'off'` / `--motion off` for a fully static deck |
20
+ | Markdown extras | Tables (`\| ... \|`), blockquotes (`>`), and note comments are auto-detected into matching layouts |
17
21
  | `dsh-ppt` skill | A complete six-step SOP registered into DSH |
18
22
  | Standalone SKILL.md | Copy `skills/dsh-ppt/` into Claude Code / Cursor / Gemini CLI / Codex for cross-harness use |
19
23
  | Visual engine | Reuses the hyperframes visual style library; HTML and PPTX share the same theme source |
@@ -26,7 +30,7 @@ Example:
26
30
 
27
31
  ## Compatibility
28
32
 
29
- Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
33
+ 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.
30
34
 
31
35
  ## Installation
32
36
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-ppt
4
4
 
5
- > **一句话 / 一篇文档 → 完整演示文稿**:HTML 网页放映 + PPTX 导出,5 套视觉主题,中英双语。
5
+ > **一句话 / 一篇文档 → 完整演示文稿**:HTML 网页放映 + PPTX 导出,5 套视觉主题,页间转场 + 要点入场动画,中英双语。
6
6
 
7
7
  ![npm version](https://img.shields.io/npm/v/dsh-ppt?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-ppt) ![license](https://img.shields.io/npm/l/dsh-ppt) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-ppt?style=social)
8
8
 
@@ -16,6 +16,10 @@ 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
+ | 动效(motion) | 默认开:HTML 页间淡入 + 要点逐条入场;PPTX 原生转场 + 要点逐条点击显现;`motion: 'off'` / `--motion off` 产出纯静态 |
22
+ | Markdown 进阶 | 表格(`\| ... \|`)、引用金句(`>`)、备注注释自动识别成对应页型 |
19
23
  | `dsh-ppt` 技能 | 完整 SOP 注册进 DSH:从一句话到成品 deck 的六步流水线 |
20
24
  | 裸 SKILL.md | 把 `skills/dsh-ppt/` 复制到 Claude Code / Cursor / Gemini CLI / Codex 即可跨 harness 使用 |
21
25
  | 视觉引擎 | 复用 hyperframes 视觉风格库,5 套主题 HTML 与 PPTX 同源同色 |
@@ -28,7 +32,7 @@ DSH(DeepSeek Harness)演示文稿技能 + 工具插件:把一句话、一
28
32
 
29
33
  ## 兼容性
30
34
 
31
- 在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
35
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.3` 上验证(2026-09-01 全量冒烟通过)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
32
36
 
33
37
  ## 安装
34
38
 
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-ppt —— 演示文稿技能 + 工具插件(v0.1.0)。
2
+ * dsh-ppt —— 演示文稿技能 + 工具插件。
3
3
  *
4
4
  * 一句话或一篇 Markdown 文档 → 完整演示文稿三件套:
5
5
  * deck.html 独立网页放映(无外链)
@@ -157,10 +157,11 @@ 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
+ motion: { type: 'string', enum: ['on', 'off'], description: 'Slide transitions plus bullet entrance animations: on (default) or off for a fully static deck.' },
164
+ 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
165
  outputDir: { type: 'string', description: 'Directory to write the files into. Default: session working directory (or the plugin outputDir config).' },
165
166
  fileName: { type: 'string', description: 'Base file name for the three artifacts. Default: sanitized deck title.' },
166
167
  }),
@@ -183,6 +184,7 @@ export function apply(ctx, config = {}) {
183
184
  slides: hasSlides ? args.slides : undefined,
184
185
  theme: typeof args.theme === 'string' && args.theme.trim() !== '' ? args.theme.trim() : (resolved.defaultTheme || undefined),
185
186
  lang: typeof args.lang === 'string' && args.lang.trim() !== '' ? args.lang.trim() : (resolved.defaultLang || undefined),
187
+ motion: args.motion,
186
188
  outputDir: typeof args.outputDir === 'string' && args.outputDir.trim() !== '' ? args.outputDir.trim() : (resolved.outputDir || undefined),
187
189
  fileName: typeof args.fileName === 'string' && args.fileName.trim() !== '' ? args.fileName.trim() : undefined,
188
190
  maxSlides: resolved.maxSlides,
package/lib/types.d.ts CHANGED
@@ -28,6 +28,8 @@ export interface PptCreateArgs {
28
28
  slides?: PptSlideSpec[];
29
29
  theme?: string;
30
30
  lang?: string;
31
+ /** 页间转场与要点入场动画:on(默认)/ off。 */
32
+ motion?: 'on' | 'off';
31
33
  outputDir?: string;
32
34
  fileName?: string;
33
35
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ppt",
3
- "version": "0.2.1",
4
- "description": "DSH 演示文稿技能 + 工具插件:一句话或一篇 Markdown 文档 → 完整演示文稿(独立 HTML 网页放映 + 可编辑 PPTX 导出),内置 5 套视觉主题,可配置默认主题/语言,中英双语,SKILL.md 双格式跨 harness。",
3
+ "version": "0.4.0",
4
+ "description": "DSH 演示文稿技能 + 工具插件:一句话或一篇 Markdown 文档 → 完整演示文稿(独立 HTML 网页放映 + 可编辑 PPTX 导出),内置 5 套视觉主题与页间转场/要点入场动画(motion 可关),可配置默认主题/语言,中英双语,SKILL.md 薄路由 + references 按需装载,跨 harness。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -1,93 +1,59 @@
1
1
  ---
2
2
  name: dsh-ppt
3
- description: "Turn one sentence or a Markdown document into a complete, presentation-ready deck: a standalone HTML web slideshow plus an editable PPTX export, with 5 built-in visual themes (Swiss Pulse / Velvet Standard / Data Drift / Soft Signal / Maximalist Type) and bilingual Chinese/English support. Use when the user asks for a PPT, slides, deck, keynote, 演示文稿, 幻灯片, 汇报, 提案, 路演, 培训材料, or wants to turn a document into a presentation. 中文:把一句话或一篇文档变成完整演示文稿(HTML 网页放映 + PPTX 导出),内置 5 套视觉主题,中英双语。"
3
+ description: "Turn one sentence or a Markdown document into a complete, presentation-ready deck: a standalone HTML web slideshow plus an editable PPTX export, with 5 built-in visual themes (Swiss Pulse / Velvet Standard / Data Drift / Soft Signal / Maximalist Type), slide transitions + bullet entrance animations (motion, on by default), and bilingual Chinese/English support. Use when the user asks for a PPT, slides, deck, keynote, 演示文稿, 幻灯片, 汇报, 提案, 路演, 培训材料, or wants to turn a document into a presentation. 中文:把一句话或一篇文档变成完整演示文稿(HTML 网页放映 + PPTX 导出),内置 5 套视觉主题、页间转场与要点入场动画,中英双语。"
4
4
  compatibility: "DSH plugin runs in-process with zero runtime dependencies. The standalone CLI needs Node.js >= 20. Cross-harness: copy this skill directory into any Agent Skills directory."
5
5
  allowed-tools: "Bash, Read, Write, Edit"
6
6
  ---
7
7
 
8
8
  # dsh-ppt
9
9
 
10
- 一句话或一篇文档 → 完整演示文稿:**独立 HTML 网页放映 + 可编辑 PPTX**。这是从内容到成品的完整 SOP;DSH 内安装插件后调用 `ppt_create` / `ppt_themes` 工具,其他 harness 复制本目录并运行 `scripts/build-deck.mjs`。
10
+ 一句话或一篇文档 → 完整演示文稿:**独立 HTML 网页放映 + 可编辑 PPTX**。本文件只做路由与纪律;具体规则按需读取 `references/`。
11
11
 
12
12
  ## 触发条件
13
13
 
14
- - 用户要求做 PPT、幻灯片、演示文稿、deck、keynote、汇报、提案、路演、培训材料。
15
- - 用户给了一句话、一段文字或一篇 Markdown 文档,希望变成可放映/可分享的演示。
16
- - 用户提到导出 PPTX 或网页版幻灯片。
14
+ 用户要做 PPT / 幻灯片 / 演示文稿 / deck / 汇报 / 提案 / 路演 / 培训材料,或给一句话、一段文字、一篇 Markdown 想变成可放映的演示。
17
15
 
18
- ## 核心流水线(每次执行)
16
+ ## 执行纪律(每次都要遵守)
19
17
 
20
- 1. **Probe 需求**:读入用户的句子/文档。确定听众、目标(说服/汇报/教学/发布)、语气与情绪基调。一句话输入不要反问,直接按发布场景推断并产出完整内容。
21
- 2. **Outline 大纲**:产出 5–12 页大纲。每页只讲一个观点;页型组合固定为:封面 → 问题/背景 → 方案/论点(3–6 页)→ 证据/案例 → 行动号召 → 结束页。每页要点不超过 6 条,每条不超过 20 字(中文)或 12 词(英文)。
22
- 3. **Theme 主题**:调用 `ppt_themes`(DSH)或 `node scripts/build-deck.mjs --list-themes`(跨 harness)选择视觉主题。情绪 → 主题映射见 `references/themes.md`;默认 `data`(技术/AI),高管汇报用 `velvet`,数据报表用 `swiss`,温暖叙事用 `soft`,大声量发布用 `bold`。
23
- 4. **Build 生成**:
24
- - DSH 内:调用 `ppt_create`,把大纲写成 Markdown 传 `content`(推荐),或直接传结构化 `slides`。
25
- - 跨 harness:`node <skill-dir>/scripts/build-deck.mjs --title "标题" --content deck.md --theme data --lang zh --out dist`
26
- - 输出三件套:`*.html`(双击即放映)、`*.pptx`(可编辑)、`*.json`(manifest)。
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 打开。
29
-
30
- ## 内容写作规则
31
-
32
- - 一句话输入也要「完整」:封面 = 标题 + 那句话;核心观点页 = 原句放大;结束页 = 谢谢/行动号召。若用户要求更丰富,先自行扩写成 6–10 页大纲再构建。
33
18
  - 一页一个观点;标题是判断句,不是名词短语。
34
- - 正文用短句;先结论后理由;数字和对比比形容词更有说服力。
35
- - 中英双语:`lang` 只决定界面文字(页码/主题标签/结束页),**内容语言由你撰写**。要求双语时,优先每页中文标题 + 英文副标题,或直接生成两份 deck(`--lang zh` 与 `--lang en`)。
36
- - 更多规则见 `references/copywriting.md`。
37
-
38
- ## 主题选择
39
-
40
- 内置 5 套主题(源自 hyperframes 视觉风格库):
41
-
42
- | 主题 ID | 名称 | 情绪 | 适用 |
43
- | --- | --- | --- | --- |
44
- | `data` | 数据漂移 | 未来/沉浸 | AI、技术发布、研究(默认) |
45
- | `swiss` | 瑞士脉冲 | 精准/理性 | 数据、SaaS、开发者工具 |
46
- | `velvet` | 天鹅绒标准 | 高级/克制 | 高管汇报、品牌、融资路演 |
47
- | `soft` | 柔和信号 | 温暖/人本 | 品牌故事、培训、个人分享 |
48
- | `bold` | 极繁大字 | 大声/动能 | 产品发布、活动、大事件 |
49
-
50
- 完整色板与反模式见 `references/themes.md`。
51
-
52
- ## 视觉引擎复用(hyperframes)
19
+ - 内容语言由你撰写;`lang` 只决定界面文字。
20
+ - 先选主题再构建;不确定就用默认 `data`。
21
+ - 交付前逐项过一遍文末「质量门禁」。
53
22
 
54
- 如果 `hyperframes` 技能已安装,生成前先读它的 `visual-styles.md` / `house-style.md` 作为视觉判断依据;本技能的 5 套主题就是把其中 Swiss Pulse / Velvet Standard / Data Drift / Soft Signal / Maximalist Type 五套风格落成确定性 HTML+PPTX 实现。需要高级动画视频版时,才转用 hyperframes;普通演示不要上视频引擎。
23
+ ## 核心流水线
55
24
 
56
- ## 结构化 slides(高级用法)
57
-
58
- `ppt_create` `slides` 与 CLI `--slides` 接受 JSON 数组:
59
-
60
- ```json
61
- [
62
- { "layout": "cover", "title": "标题", "subtitle": "副标题", "kicker": "开场" },
63
- { "layout": "section", "kicker": "01", "title": "背景" },
64
- { "layout": "bullets", "title": "三个论点", "bullets": ["论点一", "论点二", "论点三"] },
65
- { "layout": "statement", "title": "核心观点一句话", "subtitle": "支撑说明" },
66
- { "layout": "closing", "title": "谢谢", "subtitle": "行动号召" }
67
- ]
68
- ```
69
-
70
- `layout` 仅限 `cover | section | bullets | statement | closing`。Markdown 输入不够精确时,用结构化 slides 重写。
25
+ 1. **Probe 需求**:读入用户的句子/文档,确定听众、目标、语气。一句话输入不要反问,直接按发布场景推断。
26
+ 2. **Outline 大纲**:产出 5–12 页大纲(封面 → 问题/背景 → 方案/论点 3–6 页 → 证据 → 行动号召 → 结束页)。
27
+ 写大纲与正文前先读 `references/copywriting.md`。
28
+ 3. **Theme 主题**:调 `ppt_themes`(DSH)或 `node scripts/build-deck.mjs --list-themes`(跨 harness)。
29
+ → 拿不准选哪套时读 `references/themes.md` 的情绪决策树。
30
+ 4. **Build 生成**:
31
+ - DSH 内:调 `ppt_create`,大纲写成 Markdown `content`(推荐),或传结构化 `slides`。
32
+ - harness:`node <skill-dir>/scripts/build-deck.mjs --title "标题" --content deck.md --theme data --lang zh --out dist`。
33
+ - 动效默认开(页间转场 + 要点逐条入场);严肃/打印场景传 `motion: 'off'`。
34
+ 用表格 / 金句 / 备注 / 结构化 slides 前读 `references/syntax.md`。
35
+ 5. **Verify 校验**:确认 HTML 页数与大纲一致、标题无截断、PPTX 文件头为 `PK`。
36
+ 6. **Deliver 交付**:给用户三个绝对路径,说明 HTML 直接放映(← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印),PPTX 用 PowerPoint / WPS / Keynote 打开(备注在演示者视图可见,bullets 页放映时逐条点击出现)。
37
+
38
+ ## 按需装载索引
39
+
40
+ | 何时读 | 文件 | 内容 |
41
+ | --- | --- | --- |
42
+ | 写大纲 / 正文前 | `references/copywriting.md` | 叙事骨架、单页规则、双语规则 |
43
+ | 选主题拿不准 | `references/themes.md` | 5 套主题色板 + 情绪决策树 |
44
+ | 用表格/金句/备注/结构化 slides | `references/syntax.md` | Markdown 解析、进阶语法、slides JSON、motion 开关 |
45
+ | 交付异常 | `references/troubleshooting.md` | 症状 → 处理对照表 |
71
46
 
72
47
  ## 质量门禁(交付前逐项确认)
73
48
 
74
49
  - [ ] 页数 3–60,首屏是封面,末屏是结束页。
75
- - [ ] 每页有明确标题;bullets 页 1–8 条。
50
+ - [ ] 每页有明确标题;bullets 页 1–8 条,中文每条 ≤ 20 字、英文 ≤ 12 词。
76
51
  - [ ] HTML 与 PPTX 使用同一主题,配色一致。
77
52
  - [ ] 主题色对比度足够(深底浅字或浅底深字)。
78
53
  - [ ] 中文内容无错别字;英文标题用 Title Case。
54
+ - [ ] 最后一页有可执行的行动号召,不只是「谢谢」。
79
55
  - [ ] 文件已写到用户工作区,路径为绝对路径。
80
56
 
81
- ## 故障排查
82
-
83
- | 症状 | 处理 |
84
- | --- | --- |
85
- | `ppt_create` 不存在 | 本插件未安装;用 `node <skill-dir>/scripts/build-deck.mjs` 裸 CLI 生成同样三件套 |
86
- | 未知主题 | `ppt_themes` 或 `--list-themes` 看可用 ID;不要猜 |
87
- | 内容超过 60 页 | 合并论点,或拆成多个 deck;`maxSlides` 默认 60 |
88
- | PPTX 打不开 | 确认文件完整(zip 头 `PK`);Office 首次打开空白版式属正常,编辑视图可用 |
89
- | 双语界面 | `lang: bilingual` 只双语化界面;内容双语由 agent 在写作阶段完成 |
90
-
91
57
  ## 跨 harness 安装
92
58
 
93
59
  把整个 `dsh-ppt` 技能目录复制到目标 agent 的技能目录即可(只依赖 Node 20+):
@@ -30,18 +30,9 @@
30
30
  - 英文标题用 Title Case;术语首现给中文+英文,如「检索增强生成(RAG)」。
31
31
  - 双语时不要逐句直译,按目标语言习惯重写。
32
32
 
33
- ## Markdown 输入规范
34
-
35
- `ppt_create` `build-deck.mjs` 按以下规则解析 Markdown:
36
-
37
- - 第一个 `# 标题` 会成为封面标题;其后的段落/列表成为封面副标题来源。
38
- - 每个 `## 小节`(或更深标题)生成一页:
39
- - 有 `- ` / `* ` / `1.` 列表 → `bullets` 页;
40
- - 无列表且无正文 → `section` 过渡页;
41
- - 有正文段落 → 正文拆句后作为 bullets。
42
- - 没有标题的纯文本:第一段第一句为封面副标题,后续段落按 5 句一组拆成要点页。
43
- - 只有一句话:自动生成「封面 → 核心观点 → 结束页」三页。
44
- - 需要更精细控制时使用结构化 `slides` JSON,不要和 Markdown 混用。
33
+ ## Markdown 与结构化输入
34
+
35
+ 解析规则、表格/金句/备注语法与结构化 `slides` JSON `syntax.md`。
45
36
 
46
37
  ## 示例
47
38
 
@@ -0,0 +1,44 @@
1
+ # dsh-ppt 输入语法参考(Markdown / 结构化 slides / 动效)
2
+
3
+ 只在需要查具体语法时读本文件;常规流程在 SKILL.md。
4
+
5
+ ## Markdown 解析规则
6
+
7
+ `ppt_create` 与 `build-deck.mjs` 按以下规则解析 Markdown:
8
+
9
+ - 第一个 `# 标题` 会成为封面标题;其后的段落/列表成为封面副标题来源。
10
+ - 每个 `## 小节`(或更深标题)生成一页:
11
+ - 有 `- ` / `* ` / `1.` 列表 → `bullets` 页;
12
+ - 无列表且无正文 → `section` 过渡页;
13
+ - 有正文段落 → 正文拆句后作为 bullets。
14
+ - 没有标题的纯文本:第一段第一句为封面副标题,后续段落按 5 句一组拆成要点页。
15
+ - 只有一句话:自动生成「封面 → 核心观点 → 结束页」三页。
16
+ - 需要更精细控制时使用结构化 `slides` JSON,不要和 Markdown 混用。
17
+
18
+ ## 进阶语法(自动识别)
19
+
20
+ - **表格**:`| 列 | 列 |` 连续行(第二行 `| --- | --- |` 分隔线)→ 独立 `table` 页(主题色表头,限 9 行 8 列)。数据对比优先用表格,别堆要点。
21
+ - **金句**:`>` 引用块 → 独立 `quote` 页;最后一行写 `—— 出处` 会成为署名。
22
+ - **演讲者备注**:`<!-- 备注: 这页要讲的内容 -->`(或 `<!-- note: ... -->`)附着到该页。备注不进正文:HTML 放映按 `S` 呼出备注面板,PPTX 写入原生备注(演示者视图可见)。为汇报/演讲类 deck 主动写备注(口径、数据出处、应答预案)。
23
+
24
+ ## 结构化 slides(高级用法)
25
+
26
+ `ppt_create` 的 `slides` 与 CLI `--slides` 接受 JSON 数组:
27
+
28
+ ```json
29
+ [
30
+ { "layout": "cover", "title": "标题", "subtitle": "副标题", "kicker": "开场", "notes": "开场先自我介绍 30 秒" },
31
+ { "layout": "section", "kicker": "01", "title": "背景" },
32
+ { "layout": "bullets", "title": "三个论点", "bullets": ["论点一", "论点二", "论点三"] },
33
+ { "layout": "table", "title": "业绩对比", "rows": [["大区", "营收"], ["华东", "1.2 亿"]], "notes": "强调华东增速" },
34
+ { "layout": "quote", "title": "慢就是快。", "subtitle": "创始人" },
35
+ { "layout": "statement", "title": "核心观点一句话", "subtitle": "支撑说明" },
36
+ { "layout": "closing", "title": "谢谢", "subtitle": "行动号召" }
37
+ ]
38
+ ```
39
+
40
+ `layout` 仅限 `cover | section | bullets | statement | quote | table | closing`;每页可选 `notes`(演讲者备注),`table` 页用 `rows`(首行为表头)。Markdown 输入不够精确时,用结构化 slides 重写。
41
+
42
+ ## 动效(motion)
43
+
44
+ 默认 `motion: on`:HTML 放映有页间淡入与要点逐条入场;PPTX 每页原生淡入转场,`bullets` 页的要点按「点击一次出现一条」放映。交付严肃/打印场景或用户明确要静态时传 `motion: 'off'`(CLI `--motion off`),产物不含任何动画代码。
@@ -53,3 +53,7 @@
53
53
  5. 情绪是大音量/发布会 → `bold`
54
54
 
55
55
  无法判断时用 `data`,不要自造主题 ID。
56
+
57
+ ## 视觉引擎复用(hyperframes)
58
+
59
+ 如果 `hyperframes` 技能已安装,生成前先读它的 `visual-styles.md` / `house-style.md` 作为视觉判断依据;本技能的 5 套主题就是把其中 Swiss Pulse / Velvet Standard / Data Drift / Soft Signal / Maximalist Type 五套风格落成确定性 HTML+PPTX 实现。需要高级动画视频版时,才转用 hyperframes;普通演示不要上视频引擎。
@@ -0,0 +1,13 @@
1
+ # dsh-ppt 故障排查
2
+
3
+ 只在交付异常时读本文件。
4
+
5
+ | 症状 | 处理 |
6
+ | --- | --- |
7
+ | `ppt_create` 不存在 | 本插件未安装;用 `node <skill-dir>/scripts/build-deck.mjs` 裸 CLI 生成同样三件套 |
8
+ | 未知主题 | `ppt_themes` 或 `--list-themes` 看可用 ID;不要猜 |
9
+ | 内容超过 60 页 | 合并论点,或拆成多个 deck;`maxSlides` 默认 60 |
10
+ | PPTX 打不开 | 确认文件完整(zip 头 `PK`);Office 首次打开空白版式属正常,编辑视图可用 |
11
+ | 双语界面 | `lang: bilingual` 只双语化界面;内容双语由 agent 在写作阶段完成 |
12
+ | 放映没有动画 | `motion` 被设为 `off`;默认 `on` 时 HTML 有转场、PPTX 要点逐条点击出现 |
13
+ | HTML 打印要点缺失 | 打印样式已强制显示全部要点;若仍缺失检查浏览器是否屏蔽了动画样式 |
@@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'node:path'
16
16
  import { pathToFileURL } from 'node:url'
17
17
  import { buildDeck, listThemes, THEME_IDS, DEFAULT_THEME } from './deck-core.mjs'
18
18
 
19
- const HELP = `dsh-ppt v0.1.0 —— 一句话 / 一篇文档 → HTML 放映 + PPTX 导出
19
+ const HELP = `dsh-ppt —— 一句话 / 一篇文档 → HTML 放映 + PPTX 导出
20
20
 
21
21
  用法:
22
22
  node build-deck.mjs --title <标题> --content <Markdown 或文件路径> [选项]
@@ -27,6 +27,7 @@ const HELP = `dsh-ppt v0.1.0 —— 一句话 / 一篇文档 → HTML 放映 + P
27
27
  --slides <json> 结构化 slides JSON(可选,与 --content 二选一)
28
28
  --theme <id> 视觉主题:${THEME_IDS.join(' / ')}(默认 ${DEFAULT_THEME})
29
29
  --lang <id> 界面语言:zh / en / bilingual(默认 zh)
30
+ --motion <on|off> 页间转场与要点入场动画(默认 on;off 产出纯静态)
30
31
  --out <dir> 输出目录(默认当前目录)
31
32
  --file <name> 文件名前缀(默认取标题)
32
33
  --list-themes 列出内置主题
@@ -39,7 +40,7 @@ const HELP = `dsh-ppt v0.1.0 —— 一句话 / 一篇文档 → HTML 放映 + P
39
40
  `
40
41
 
41
42
  export function parseArgv(argv) {
42
- const args = { content: '', slides: null, lang: 'zh', theme: DEFAULT_THEME, out: '.', file: '', title: '' }
43
+ const args = { content: '', slides: null, lang: 'zh', theme: DEFAULT_THEME, motion: undefined, out: '.', file: '', title: '' }
43
44
  const positional = []
44
45
  for (let i = 0; i < argv.length; i += 1) {
45
46
  const raw = String(argv[i])
@@ -59,6 +60,7 @@ export function parseArgv(argv) {
59
60
  else if (key === '--slides') args.slides = JSON.parse(nextValue())
60
61
  else if (key === '--theme') args.theme = nextValue()
61
62
  else if (key === '--lang') args.lang = nextValue()
63
+ else if (key === '--motion') args.motion = nextValue()
62
64
  else if (key === '--out') args.out = nextValue()
63
65
  else if (key === '--file') args.file = nextValue()
64
66
  else positional.push(raw)
@@ -105,6 +107,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
105
107
  slides,
106
108
  theme: args.theme,
107
109
  lang: args.lang,
110
+ motion: args.motion,
108
111
  outputDir: resolvePath(args.out || '.'),
109
112
  fileName: args.file,
110
113
  }
@@ -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.3.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',
@@ -194,6 +206,13 @@ export function resolveLanguage(input) {
194
206
  return language
195
207
  }
196
208
 
209
+ export function resolveMotion(input) {
210
+ if (input === undefined || input === null || input === '') return true
211
+ if (input === true || input === 'on') return true
212
+ if (input === false || input === 'off') return false
213
+ throw new Error('dsh-ppt:未知 motion 值 "' + String(input) + '",可选:on / off(默认 on)')
214
+ }
215
+
197
216
  export function listThemes(lang = 'zh') {
198
217
  const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
199
218
  return THEME_IDS.map((id) => {
@@ -285,7 +304,49 @@ function chunk(items, size) {
285
304
  // ---------------------------------------------------------------------------
286
305
 
287
306
  function createSection(heading = '', level = 0, coverOnly = false) {
288
- return { heading, level, coverOnly, bullets: [], paragraphs: [] }
307
+ return { heading, level, coverOnly, bullets: [], paragraphs: [], quote: [], table: null, notes: [] }
308
+ }
309
+
310
+ function splitTableRow(line) {
311
+ const trimmed = line.trim()
312
+ const match = /^\|(.+)\|$/.exec(trimmed)
313
+ if (match === null) return null
314
+ return match[1].split('|').map((cell) => cell.trim())
315
+ }
316
+
317
+ function isTableSeparator(cells) {
318
+ return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell) || /^:?-+:?$/.test(cell))
319
+ }
320
+
321
+ const NOTES_COMMENT = /^<!--\s*(?:notes?|备注)\s*[::]\s*([\s\S]*?)\s*-->$/
322
+
323
+ function quoteSlideFrom(section, ui) {
324
+ const lines = [...section.quote]
325
+ let attribution = ''
326
+ const attrMatch = /^(?:——|—|--|-)\s*(.+)$/.exec(lines[lines.length - 1] ?? '')
327
+ if (attrMatch !== null && lines.length > 1) {
328
+ attribution = attrMatch[1]
329
+ lines.pop()
330
+ }
331
+ return { layout: 'quote', kicker: ui.quoteKicker, title: truncate(lines.join(' '), 220), subtitle: attribution }
332
+ }
333
+
334
+ function tableSlideFrom(section, ui) {
335
+ const table = section.table
336
+ const rows = [table.header, ...table.rows]
337
+ .slice(0, 9)
338
+ .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
339
+ const heading = section.heading || ui.tableKicker
340
+ return { layout: 'table', kicker: heading, title: heading, rows }
341
+ }
342
+
343
+ /** 把一个 section 里的金句/表格拆成独立页;备注附着到第一张产出页。 */
344
+ function specialSlidesFor(section, ui) {
345
+ const out = []
346
+ if (section.quote.length > 0) out.push(quoteSlideFrom(section, ui))
347
+ if (section.table !== null) out.push(tableSlideFrom(section, ui))
348
+ if (out.length > 0 && section.notes.length > 0) out[0].notes = section.notes.join('\n')
349
+ return out
289
350
  }
290
351
 
291
352
  export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
@@ -347,6 +408,38 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
347
408
  continue
348
409
  }
349
410
 
411
+ // 演讲者备注:`<!-- 备注: ... -->` / `<!-- note: ... -->` 附到当前页
412
+ const notesMatch = NOTES_COMMENT.exec(line)
413
+ if (notesMatch !== null && notesMatch[1].trim() !== '') {
414
+ if (current === null) current = createSection()
415
+ current.notes.push(notesMatch[1].trim())
416
+ continue
417
+ }
418
+
419
+ // Markdown 表格:连续的 | ... | 行,第二行是分隔线
420
+ const tableCells = splitTableRow(line)
421
+ if (tableCells !== null) {
422
+ if (current === null) current = createSection()
423
+ if (isTableSeparator(tableCells)) continue
424
+ if (current.table === null) {
425
+ current.table = { header: tableCells.map((cell) => stripInlineMarkdown(cell)), rows: [] }
426
+ } else if (current.table.rows.length < 12) {
427
+ current.table.rows.push(tableCells.map((cell) => stripInlineMarkdown(cell)))
428
+ }
429
+ continue
430
+ }
431
+
432
+ // 金句:`>` 引用块(最后一行 `—— 出处` 识别为署名)
433
+ const quoteMatch = /^>\s?(.*)$/.exec(line)
434
+ if (quoteMatch !== null) {
435
+ const quoted = stripInlineMarkdown(quoteMatch[1])
436
+ if (quoted !== '') {
437
+ if (current === null) current = createSection()
438
+ current.quote.push(quoted)
439
+ }
440
+ continue
441
+ }
442
+
350
443
  const paragraph = stripInlineMarkdown(line)
351
444
  if (paragraph === '') continue
352
445
  if (current === null) current = createSection()
@@ -404,24 +497,31 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
404
497
  }))
405
498
  const firstSentence = ordered[0]?.sentences[0] ?? ''
406
499
  if (coverSubtitle === '') coverSubtitle = truncate(firstSentence, 180)
407
- const rest = ordered.flatMap((group, groupIndex) => {
500
+ const bodySlides = []
501
+ let pointIndex = 0
502
+ ordered.forEach((group, groupIndex) => {
503
+ const specials = specialSlidesFor(group.section, ui)
504
+ bodySlides.push(...specials)
408
505
  const sentences = (groupIndex === 0 && !coverSubtitleConsumed) ? group.sentences.slice(1) : group.sentences
409
- return chunk(sentences, 5)
506
+ for (const points of chunk(sentences, 5)) {
507
+ pointIndex += 1
508
+ const slide = {
509
+ layout: 'bullets',
510
+ kicker: ui.pointKicker + ' ' + pointIndex,
511
+ title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
512
+ bullets: points,
513
+ }
514
+ if (specials.length === 0 && group.section.notes.length > 0) slide.notes = group.section.notes.join('\n')
515
+ bodySlides.push(slide)
516
+ }
410
517
  })
411
518
  slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
412
- if (rest.length === 0) {
519
+ if (bodySlides.length === 0) {
413
520
  if (coverSubtitle !== '') {
414
521
  slides.push({ layout: 'statement', kicker: ui.statementKicker, title: coverSubtitle, subtitle: coverTitle || 'Untitled' })
415
522
  }
416
523
  } 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
- })
524
+ slides.push(...bodySlides)
425
525
  }
426
526
  slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
427
527
  return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
@@ -430,18 +530,23 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
430
530
  slides.push({ layout: 'cover', kicker: ui.coverKicker, title: coverTitle || 'Untitled', subtitle: coverSubtitle })
431
531
  let pointIndex = 0
432
532
  for (const section of bodySections) {
533
+ const notes = section.notes.join('\n')
534
+ const specials = specialSlidesFor(section, ui)
535
+ slides.push(...specials)
433
536
  if (section.heading === '') {
434
537
  const points = [...section.bullets, ...section.paragraphs]
435
538
  .flatMap((part) => splitSentences(part))
436
539
  .slice(0, 8)
437
540
  if (points.length > 0) {
438
541
  pointIndex += 1
439
- slides.push({
542
+ const slide = {
440
543
  layout: 'bullets',
441
544
  kicker: ui.pointKicker + ' ' + pointIndex,
442
545
  title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
443
546
  bullets: points,
444
- })
547
+ }
548
+ if (specials.length === 0 && notes !== '') slide.notes = notes
549
+ slides.push(slide)
445
550
  }
446
551
  continue
447
552
  }
@@ -450,16 +555,20 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
450
555
  ...section.paragraphs.flatMap((part) => splitSentences(part)),
451
556
  ].slice(0, 8)
452
557
  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 })
558
+ const slide = { layout: 'bullets', kicker: section.heading, title: section.heading, bullets: points }
559
+ if (specials.length === 0 && notes !== '') slide.notes = notes
560
+ slides.push(slide)
561
+ } else if (specials.length === 0) {
562
+ const slide = { layout: 'section', kicker: section.heading, title: section.heading }
563
+ if (notes !== '') slide.notes = notes
564
+ slides.push(slide)
456
565
  }
457
566
  }
458
567
  slides.push({ layout: 'closing', title: ui.closingTitle, subtitle: coverTitle || 'Untitled' })
459
568
  return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
460
569
  }
461
570
 
462
- const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing'])
571
+ const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
463
572
 
464
573
  function normalizeSlide(raw, index) {
465
574
  const source = (raw !== null && typeof raw === 'object') ? raw : {}
@@ -468,6 +577,8 @@ function normalizeSlide(raw, index) {
468
577
  const subtitle = stripInlineMarkdown(source.subtitle ?? '')
469
578
  const kicker = stripInlineMarkdown(source.kicker ?? '')
470
579
  const text = stripInlineMarkdown(source.text ?? '')
580
+ // 备注保留换行(放映面板与备注页都按原文显示),只做转义,不做 Markdown 清洗
581
+ const notes = String(source.notes ?? '').trim()
471
582
  let bullets = []
472
583
  if (Array.isArray(source.bullets)) {
473
584
  bullets = source.bullets.map((item) => stripInlineMarkdown(String(item))).filter(Boolean)
@@ -475,9 +586,29 @@ function normalizeSlide(raw, index) {
475
586
  bullets = splitSentences(source.bullets)
476
587
  }
477
588
  if (layout === 'statement' && title === '' && text !== '') {
478
- return { layout, kicker, title: text, subtitle: subtitle || '', bullets }
589
+ return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
479
590
  }
480
- return { layout, kicker, title, subtitle, text, bullets }
591
+ if (layout === 'quote') {
592
+ const quoteText = title !== '' ? title : text
593
+ if (quoteText === '') return { layout: 'statement', kicker, title: subtitle, subtitle: '', bullets, ...(notes !== '' ? { notes } : {}) }
594
+ return { layout, kicker, title: truncate(quoteText, 220), subtitle, ...(notes !== '' ? { notes } : {}) }
595
+ }
596
+ if (layout === 'table') {
597
+ let rows = []
598
+ if (Array.isArray(source.rows)) {
599
+ rows = source.rows
600
+ .map((row) => (Array.isArray(row) ? row : [row]).map((cell) => stripInlineMarkdown(String(cell))))
601
+ .filter((row) => row.some((cell) => cell !== ''))
602
+ .slice(0, 9)
603
+ .map((row) => row.slice(0, 8).map((cell) => truncate(cell, 60)))
604
+ }
605
+ if (rows.length === 0) {
606
+ // 没有表格数据的 table 页退化成要点页,避免产出空白版式
607
+ return { layout: 'bullets', kicker, title, subtitle, bullets, ...(notes !== '' ? { notes } : {}) }
608
+ }
609
+ return { layout, kicker: kicker || title, title, rows, ...(notes !== '' ? { notes } : {}) }
610
+ }
611
+ return { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }
481
612
  }
482
613
 
483
614
  export function normalizeSlides(rawSlides, maxSlides = 60) {
@@ -502,6 +633,7 @@ export function normalizeBuildOptions(options = {}) {
502
633
  if (title === '') throw new Error('dsh-ppt:title 不能为空')
503
634
  const theme = resolveTheme(options.theme)
504
635
  const language = resolveLanguage(options.lang)
636
+ const motion = resolveMotion(options.motion)
505
637
  const maxSlides = clampInt(options.maxSlides, 60, 3, 120)
506
638
  let deck
507
639
  if (Array.isArray(options.slides) && options.slides.length > 0) {
@@ -522,12 +654,12 @@ export function normalizeBuildOptions(options = {}) {
522
654
  if (deck.slides.length < 1) throw new Error('dsh-ppt:没有可生成的幻灯片')
523
655
  const outputDir = resolvePath(String(options.outputDir ?? '.').trim() || '.')
524
656
  const fileName = sanitizeFileName(options.fileName ?? title)
525
- return { title, theme, language, deck, outputDir, fileName }
657
+ return { title, theme, language, motion, deck, outputDir, fileName }
526
658
  }
527
659
 
528
660
  export function buildDeck(options = {}) {
529
661
  const normalized = normalizeBuildOptions(options)
530
- const { title, theme, language, deck, outputDir, fileName } = normalized
662
+ const { title, theme, language, motion, deck, outputDir, fileName } = normalized
531
663
  mkdirSync(outputDir, { recursive: true })
532
664
 
533
665
  const manifest = {
@@ -535,6 +667,7 @@ export function buildDeck(options = {}) {
535
667
  title,
536
668
  theme: theme.id,
537
669
  language: language.id,
670
+ motion,
538
671
  slideCount: deck.slides.length,
539
672
  slides: deck.slides,
540
673
  }
@@ -572,6 +705,16 @@ export function renderHtml(manifest, theme, language) {
572
705
  const t = theme
573
706
  const lang = language
574
707
  const ui = lang.ui
708
+ const motion = manifest.motion !== false
709
+ const motionCss = motion
710
+ ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
711
+ @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
712
+ body.motion .bullets li{
713
+ opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
714
+ animation-delay:calc(var(--i, 0)*90ms + .12s);
715
+ }
716
+ @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
717
+ : ''
575
718
  const slides = manifest.slides.map((slide, index) => renderHtmlSlide(slide, index, ui, lang.id)).join('\n')
576
719
  const themeLabel = t.name[lang.id] ?? t.name.en
577
720
  const total = manifest.slides.length
@@ -611,8 +754,8 @@ body{
611
754
  radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
612
755
  radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
613
756
  }
614
- .slide.is-active{display:flex;animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
615
- @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
757
+ .slide.is-active{display:flex}
758
+ ${motionCss}
616
759
  .kicker{
617
760
  color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
618
761
  font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
@@ -647,6 +790,28 @@ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
647
790
  .closing h1,.closing .statement-title{font-size:clamp(52px,9vw,148px)}
648
791
  .closing .subtitle{color:var(--accent);font-weight:700}
649
792
  .cover h1{font-weight:900}
793
+ .quote-mark{
794
+ font-family:var(--font-heading);font-size:clamp(90px,13vw,210px);line-height:.62;
795
+ color:var(--accent);margin-bottom:clamp(14px,2vw,30px);
796
+ }
797
+ .quote-text{
798
+ font-family:var(--font-heading);font-size:clamp(26px,4.2vw,62px);line-height:1.28;
799
+ font-weight:700;max-width:22ch;margin:0;
800
+ }
801
+ .quote-attr{color:var(--muted);margin-top:clamp(18px,2.6vw,34px);font-size:clamp(15px,1.8vw,26px);letter-spacing:.04em}
802
+ table.deck-table{
803
+ border-collapse:collapse;margin-top:clamp(20px,3vw,42px);width:100%;max-width:1120px;
804
+ font-size:clamp(13px,1.55vw,23px);
805
+ }
806
+ .deck-table th{
807
+ background:var(--accent);color:var(--bg);text-align:left;font-weight:700;
808
+ padding:.6em .9em;letter-spacing:.02em;
809
+ }
810
+ .deck-table td{
811
+ padding:.55em .9em;border-bottom:1px solid color-mix(in srgb, var(--muted) 34%, transparent);
812
+ color:var(--fg);
813
+ }
814
+ .deck-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--panel) 62%, transparent)}
650
815
  #progress{position:fixed;top:0;left:0;height:3px;width:0;background:var(--accent);z-index:30;transition:width .25s}
651
816
  #hud{
652
817
  position:fixed;right:22px;bottom:18px;z-index:30;display:flex;gap:14px;align-items:center;
@@ -658,42 +823,71 @@ h2{font-size:clamp(34px,5vw,82px);max-width:20ch}
658
823
  padding:7px 13px;font:inherit;cursor:pointer;
659
824
  }
660
825
  #hud button:hover{border-color:var(--accent);color:var(--accent)}
826
+ #notes-panel{
827
+ position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;
828
+ background:color-mix(in srgb, var(--panel) 96%, black 4%);
829
+ border-top:2px solid var(--accent);
830
+ padding:16px clamp(22px,4vw,64px) 20px;max-height:38vh;overflow:auto;
831
+ }
832
+ body.notes-open #notes-panel.has-notes{display:block}
833
+ #notes-panel .notes-label{
834
+ color:var(--accent);font-weight:700;letter-spacing:.14em;text-transform:uppercase;
835
+ font-size:12px;margin-bottom:8px;
836
+ }
837
+ #notes-text{white-space:pre-wrap;line-height:1.65;font-size:15px;color:var(--fg)}
661
838
  body.overview .slide{display:flex !important;position:relative;inset:auto;width:100%;height:100vh}
662
839
  body.overview{overflow:auto}
663
840
  body.overview #progress,body.overview #hud{position:fixed}
841
+ body.overview #notes-panel{display:none !important}
664
842
  @media (max-width:640px){
665
843
  #hud{right:12px;bottom:10px;gap:8px;font-size:11px}
666
844
  }
667
845
  @media print{
668
846
  html,body{height:auto;overflow:visible;background:#fff}
669
847
  .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
670
- #progress,#hud{display:none !important}
848
+ ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
849
+ #progress,#hud,#notes-panel{display:none !important}
671
850
  }
672
851
  </style>
673
852
  </head>
674
- <body>
853
+ <body class="${motion ? 'motion' : 'no-motion'}">
675
854
  <div id="progress" aria-hidden="true"></div>
676
855
  ${slides}
677
856
  <div id="hud" aria-live="polite">
678
857
  <span id="counter">${ui.slide} 1 ${ui.of} ${total}</span>
679
858
  <span id="theme-label">${ui.theme} · ${escapeHtml(themeLabel)}</span>
859
+ <button id="notes-toggle" title="S">${escapeHtml(ui.notesToggle)}</button>
680
860
  <button id="fullscreen" title="F">⛶</button>
681
861
  </div>
862
+ <div id="notes-panel" aria-live="polite">
863
+ <div class="notes-label">${escapeHtml(ui.notesLabel)}</div>
864
+ <div id="notes-text"></div>
865
+ </div>
682
866
  <script>
683
867
  (() => {
684
868
  const slides = Array.from(document.querySelectorAll('.slide'));
685
869
  const counter = document.getElementById('counter');
686
870
  const progress = document.getElementById('progress');
687
871
  const fullscreenBtn = document.getElementById('fullscreen');
872
+ const notesPanel = document.getElementById('notes-panel');
873
+ const notesText = document.getElementById('notes-text');
874
+ const notesBtn = document.getElementById('notes-toggle');
688
875
  let index = 0;
689
876
  const total = slides.length;
690
877
  const ui = ${JSON.stringify(ui).replace(/</g, '\\u003c')};
691
878
 
879
+ function refreshNotes() {
880
+ const notes = slides[index]?.dataset.notes || '';
881
+ notesPanel.classList.toggle('has-notes', notes !== '');
882
+ notesText.textContent = notes;
883
+ }
884
+
692
885
  function go(next) {
693
886
  index = (next + total) % total;
694
887
  slides.forEach((slide, i) => slide.classList.toggle('is-active', i === index));
695
888
  counter.textContent = ui.slide + ' ' + (index + 1) + ' ' + ui.of + ' ' + total;
696
889
  progress.style.width = ((index + 1) / total * 100) + '%';
890
+ refreshNotes();
697
891
  document.title = (index + 1) + ' / ' + total + ' · ' + ${JSON.stringify(manifest.title).replace(/</g, '\\u003c')};
698
892
  try { history.replaceState?.(null, '', '#slide-' + (index + 1)); } catch { /* file:// 下个别浏览器可能拒绝 */ }
699
893
  }
@@ -713,6 +907,8 @@ ${slides}
713
907
  } else if (event.key.toLowerCase() === 'g') {
714
908
  document.body.classList.toggle('overview');
715
909
  go(index);
910
+ } else if (event.key.toLowerCase() === 's') {
911
+ document.body.classList.toggle('notes-open');
716
912
  } else if (event.key.toLowerCase() === 'p') {
717
913
  window.print();
718
914
  }
@@ -738,6 +934,10 @@ ${slides}
738
934
  else document.exitFullscreen?.();
739
935
  });
740
936
 
937
+ notesBtn.addEventListener('click', () => {
938
+ document.body.classList.toggle('notes-open');
939
+ });
940
+
741
941
  const start = Number.parseInt(location.hash?.replace('#slide-', ''), 10);
742
942
  go(Number.isInteger(start) ? start - 1 : 0);
743
943
  })();
@@ -771,6 +971,25 @@ function renderHtmlSlide(slide, index, ui, langId) {
771
971
  '<div class="statement-title">' + escapeHtml(title || slide.text || '') + '</div>' +
772
972
  (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '')
773
973
  break
974
+ case 'quote':
975
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.quoteKicker) + '</div>' +
976
+ '<div class="quote-mark" aria-hidden="true">\u201C</div>' +
977
+ '<blockquote class="quote-text">' + escapeHtml(title || slide.text || '') + '</blockquote>' +
978
+ (subtitle !== '' ? '<div class="quote-attr">\u2014\u2014 ' + escapeHtml(subtitle) + '</div>' : '')
979
+ break
980
+ case 'table': {
981
+ const rows = Array.isArray(slide.rows) ? slide.rows : []
982
+ const head = rows[0] ?? []
983
+ const body = rows.slice(1)
984
+ inner = '<div class="kicker">' + escapeHtml(kicker || ui.tableKicker) + '</div>' +
985
+ (title !== '' && title !== kicker ? '<h2>' + escapeHtml(title) + '</h2>' : '') +
986
+ '<table class="deck-table"><thead><tr>' +
987
+ head.map((cell) => '<th>' + escapeHtml(cell) + '</th>').join('') +
988
+ '</tr></thead><tbody>' +
989
+ body.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(cell) + '</td>').join('') + '</tr>').join('') +
990
+ '</tbody></table>'
991
+ break
992
+ }
774
993
  case 'closing':
775
994
  inner = '<h1>' + escapeHtml(title || ui.closingTitle) + '</h1>' +
776
995
  (subtitle !== '' ? '<div class="subtitle">' + escapeHtml(subtitle) + '</div>' : '') +
@@ -780,10 +999,13 @@ function renderHtmlSlide(slide, index, ui, langId) {
780
999
  default:
781
1000
  inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
782
1001
  '<h2>' + escapeHtml(title) + '</h2>' +
783
- '<ul>' + bullets.map((bullet) => '<li>' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
1002
+ '<ul>' + bullets.map((bullet, bulletIndex) => '<li style="--i:' + bulletIndex + '">' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
784
1003
  break
785
1004
  }
786
- return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '">' +
1005
+ const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
1006
+ ? ' data-notes="' + escapeHtml(slide.notes) + '"'
1007
+ : ''
1008
+ return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
787
1009
  '<div class="slide-inner">' + inner + '</div></section>'
788
1010
  }
789
1011
 
@@ -862,6 +1084,42 @@ function accentBarXml(id, name, box, color) {
862
1084
  '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>'
863
1085
  }
864
1086
 
1087
+ /** 原生 OOXML 表格(graphicFrame + a:tbl):首行主题色表头,隔行面板色。 */
1088
+ function tableGraphicXml(id, name, box, rows, theme) {
1089
+ const p = theme.palette
1090
+ const font = pptxFontName(theme.fonts.body)
1091
+ const safeRows = rows.length > 0 ? rows : [['']]
1092
+ const cols = Math.max(1, safeRows[0].length)
1093
+ const colW = Math.floor(box.w / cols)
1094
+ const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
1095
+ const rowH = 420000
1096
+ const cellXml = (text, fill, color, bold) =>
1097
+ '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1098
+ '<a:r><a:rPr lang="zh-CN" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1099
+ '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1100
+ '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1101
+ '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
1102
+ '<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
1103
+ (fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
1104
+ '</a:tcPr></a:tc>'
1105
+ const trs = safeRows.map((row, rowIndex) => {
1106
+ const padded = Array.from({ length: cols }, (_, i) => row[i] ?? '')
1107
+ const cells = rowIndex === 0
1108
+ ? padded.map((c) => cellXml(c, p.accent, p.bg, true)).join('')
1109
+ : padded.map((c) => cellXml(c, rowIndex % 2 === 1 ? p.panel : '', p.fg, false)).join('')
1110
+ return '<a:tr h="' + rowH + '">' + cells + '</a:tr>'
1111
+ }).join('')
1112
+ const height = Math.max(1, safeRows.length) * rowH
1113
+ return '<p:graphicFrame>' +
1114
+ '<p:nvGraphicFramePr><p:cNvPr id="' + id + '" name="' + escapeXml(name) + '"/>' +
1115
+ '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>' +
1116
+ '<p:xfrm><a:off x="' + box.x + '" y="' + box.y + '"/><a:ext cx="' + box.w + '" cy="' + height + '"/></p:xfrm>' +
1117
+ '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">' +
1118
+ '<a:tbl><a:tblPr firstRow="1" bandRow="1"/>' +
1119
+ '<a:tblGrid>' + grid + '</a:tblGrid>' + trs + '</a:tbl>' +
1120
+ '</a:graphicData></a:graphic></p:graphicFrame>'
1121
+ }
1122
+
865
1123
  function slideShapeList(slide, index, total, theme, langId) {
866
1124
  const p = theme.palette
867
1125
  const font = pptxFontName(theme.fonts.heading)
@@ -914,6 +1172,31 @@ function slideShapeList(slide, index, total, theme, langId) {
914
1172
  paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
915
1173
  ]))
916
1174
  }
1175
+ } else if (slide.layout === 'quote') {
1176
+ shapes.push(textShapeXml(idBase + 1, 'Quote mark', { x: 900000, y: 1050000, w: 1700000, h: 1900000 }, [
1177
+ paragraphXml('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1178
+ ]))
1179
+ shapes.push(textShapeXml(idBase + 2, 'Quote', { x: 1050000, y: 2550000, w: 10100000, h: 2600000 }, [
1180
+ paragraphXml(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1181
+ ]))
1182
+ if (subtitle !== '') {
1183
+ shapes.push(textShapeXml(idBase + 3, 'Attribution', { x: 1070000, y: 5300000, w: 9000000, h: 600000 }, [
1184
+ paragraphXml('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1185
+ ]))
1186
+ }
1187
+ } else if (slide.layout === 'table') {
1188
+ const hasTitle = title !== '' && title !== kicker
1189
+ if (kicker !== '') {
1190
+ shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1191
+ paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1192
+ ]))
1193
+ }
1194
+ if (hasTitle) {
1195
+ shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1196
+ paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1197
+ ]))
1198
+ }
1199
+ shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme))
917
1200
  } else if (slide.layout === 'closing') {
918
1201
  shapes.push(textShapeXml(idBase + 1, 'Title', { x: 1050000, y: 2300000, w: 10200000, h: 1700000 }, [
919
1202
  paragraphXml(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
@@ -946,8 +1229,48 @@ function slideShapeList(slide, index, total, theme, langId) {
946
1229
  return shapes.join('')
947
1230
  }
948
1231
 
949
- function slideXml(slide, index, total, theme, langId) {
1232
+ /**
1233
+ * 原生逐条点击显现动画(PowerPoint「出现,按段落」的标准时序树):
1234
+ * bldP 把正文文本框标记为按段落构建,主序列里每条要点一个点击节点。
1235
+ */
1236
+ function bulletTimingXml(spid, clicks) {
1237
+ let id = 2
1238
+ const nextId = () => { id += 1; return String(id) }
1239
+ const clickPars = []
1240
+ for (let i = 0; i < clicks; i += 1) {
1241
+ clickPars.push(
1242
+ '<p:par><p:cTn id="' + nextId() + '" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>' +
1243
+ '<p:par><p:cTn id="' + nextId() + '" presetID="1" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="clickEffect">' +
1244
+ '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>' +
1245
+ '<p:set><p:cBhvr><p:cTn id="' + nextId() + '" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>' +
1246
+ '<p:tgtEl><p:spTgt spid="' + spid + '"/></p:tgtEl>' +
1247
+ '<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr>' +
1248
+ '<p:to><p:strVal val="visible"/></p:to></p:set>' +
1249
+ '</p:childTnLst></p:cTn></p:par>' +
1250
+ '</p:childTnLst></p:cTn></p:par>',
1251
+ )
1252
+ }
1253
+ return '<p:timing><p:tnLst><p:par>' +
1254
+ '<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>' +
1255
+ '<p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>' +
1256
+ clickPars.join('') +
1257
+ '</p:childTnLst></p:cTn>' +
1258
+ '<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>' +
1259
+ '<p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>' +
1260
+ '</p:seq></p:childTnLst></p:cTn></p:par></p:tnLst>' +
1261
+ '<p:bldLst><p:bldP spid="' + spid + '" grpId="0"/></p:bldLst></p:timing>'
1262
+ }
1263
+
1264
+ function slideXml(slide, index, total, theme, langId, motion) {
950
1265
  const p = theme.palette
1266
+ let motionXml = ''
1267
+ if (motion) {
1268
+ motionXml = '<p:transition spd="med"><p:fade/></p:transition>'
1269
+ const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1270
+ if (slide.layout === 'bullets' && bullets.length >= 2) {
1271
+ motionXml += bulletTimingXml((index + 1) * 10 + 3, bullets.length)
1272
+ }
1273
+ }
951
1274
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
952
1275
  '<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
953
1276
  'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
@@ -958,37 +1281,97 @@ function slideXml(slide, index, total, theme, langId) {
958
1281
  slideShapeList(slide, index, total, theme, langId) +
959
1282
  '</p:spTree></p:cSld>' +
960
1283
  '<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>' +
1284
+ motionXml +
961
1285
  '</p:sld>'
962
1286
  }
963
1287
 
964
- function slideRelXml() {
1288
+ function slideRelXml(hasNotes, slideIndex) {
1289
+ const notesRel = hasNotes
1290
+ ? '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide' + slideIndex + '.xml"/>'
1291
+ : ''
965
1292
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
966
1293
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
967
1294
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>' +
1295
+ notesRel +
968
1296
  '</Relationships>'
969
1297
  }
970
1298
 
971
- function presentationXml(slideCount) {
1299
+ function presentationXml(slideCount, hasNotesMaster) {
972
1300
  const slideIds = Array.from({ length: slideCount }, (_, i) =>
973
1301
  '<p:sldId id="' + (256 + i) + '" r:id="rId' + (i + 2) + '"/>').join('')
1302
+ const notesMasterId = hasNotesMaster
1303
+ ? '<p:notesMasterIdLst><p:notesMasterId id="2147483649" r:id="rId' + (slideCount + 2) + '"/></p:notesMasterIdLst>'
1304
+ : ''
974
1305
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
975
1306
  '<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
976
1307
  'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
977
1308
  'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
978
1309
  '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>' +
979
1310
  '<p:sldIdLst>' + slideIds + '</p:sldIdLst>' +
1311
+ notesMasterId +
980
1312
  '<p:sldSz cx="' + EMU_W + '" cy="' + EMU_H + '" type="screen16x9"/>' +
981
1313
  '<p:notesSz cx="6858000" cy="9144000"/>' +
982
1314
  '</p:presentation>'
983
1315
  }
984
1316
 
985
- function presentationRelXml(slideCount) {
1317
+ function presentationRelXml(slideCount, hasNotesMaster) {
986
1318
  const slideRels = Array.from({ length: slideCount }, (_, i) =>
987
1319
  '<Relationship Id="rId' + (i + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide' + (i + 1) + '.xml"/>').join('')
1320
+ const notesMasterRel = hasNotesMaster
1321
+ ? '<Relationship Id="rId' + (slideCount + 2) + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>'
1322
+ : ''
988
1323
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
989
1324
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
990
1325
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>' +
991
1326
  slideRels +
1327
+ notesMasterRel +
1328
+ '</Relationships>'
1329
+ }
1330
+
1331
+ function notesMasterXml() {
1332
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1333
+ '<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1334
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1335
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1336
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1337
+ '<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>' +
1338
+ '</p:spTree></p:cSld>' +
1339
+ '<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"/>' +
1340
+ '<p:notesStyle><a:lvl1pPr><a:defRPr sz="1200"/></a:lvl1pPr></p:notesStyle>' +
1341
+ '</p:notesMaster>'
1342
+ }
1343
+
1344
+ function notesMasterRelXml() {
1345
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1346
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1347
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>' +
1348
+ '</Relationships>'
1349
+ }
1350
+
1351
+ function notesSlideXml(notes) {
1352
+ const paragraphs = String(notes ?? '').split('\n')
1353
+ .filter((line) => line.trim() !== '')
1354
+ .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>')
1355
+ .join('')
1356
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1357
+ '<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1358
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
1359
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">' +
1360
+ '<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' +
1361
+ '<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>' +
1362
+ '<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>' +
1363
+ '<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/>' +
1364
+ '<p:txBody><a:bodyPr/><a:lstStyle/>' + paragraphs + '</p:txBody></p:sp>' +
1365
+ '</p:spTree></p:cSld>' +
1366
+ '<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>' +
1367
+ '</p:notes>'
1368
+ }
1369
+
1370
+ function notesSlideRelXml(slideIndex) {
1371
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1372
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
1373
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide' + slideIndex + '.xml"/>' +
1374
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>' +
992
1375
  '</Relationships>'
993
1376
  }
994
1377
 
@@ -1070,9 +1453,14 @@ function themeXml(theme) {
1070
1453
  '</a:theme>'
1071
1454
  }
1072
1455
 
1073
- function contentTypesXml(slideCount) {
1456
+ function contentTypesXml(slideCount, noteIndexes) {
1074
1457
  const overrides = Array.from({ length: slideCount }, (_, i) =>
1075
1458
  '<Override PartName="/ppt/slides/slide' + (i + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>').join('')
1459
+ const notesOverrides = noteIndexes.map((index) =>
1460
+ '<Override PartName="/ppt/notesSlides/notesSlide' + (index + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>').join('')
1461
+ const notesMasterOverride = noteIndexes.length > 0
1462
+ ? '<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
1463
+ : ''
1076
1464
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1077
1465
  '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
1078
1466
  '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
@@ -1083,7 +1471,9 @@ function contentTypesXml(slideCount) {
1083
1471
  '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>' +
1084
1472
  '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
1085
1473
  '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
1474
+ notesMasterOverride +
1086
1475
  overrides +
1476
+ notesOverrides +
1087
1477
  '</Types>'
1088
1478
  }
1089
1479
 
@@ -1106,12 +1496,12 @@ function coreXml(title) {
1106
1496
  '</cp:coreProperties>'
1107
1497
  }
1108
1498
 
1109
- function appXml(slideCount) {
1499
+ function appXml(slideCount, notesCount) {
1110
1500
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1111
1501
  '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' +
1112
1502
  'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
1113
1503
  '<Application>dsh-ppt</Application><PresentationFormat>Widescreen</PresentationFormat>' +
1114
- '<Slides>' + slideCount + '</Slides><Notes>0</Notes><HiddenSlides>0</HiddenSlides>' +
1504
+ '<Slides>' + slideCount + '</Slides><Notes>' + notesCount + '</Notes><HiddenSlides>0</HiddenSlides>' +
1115
1505
  '</Properties>'
1116
1506
  }
1117
1507
 
@@ -1201,23 +1591,38 @@ function crc32(buffer) {
1201
1591
  export function buildPptx(manifest, themeInput, languageInput) {
1202
1592
  const theme = themeInput?.id ? themeInput : resolveTheme(themeInput)
1203
1593
  const language = languageInput?.id ? languageInput : resolveLanguage(languageInput)
1594
+ const motion = manifest.motion !== false
1204
1595
  const slides = manifest.slides
1596
+ const noteIndexes = []
1597
+ slides.forEach((slide, index) => {
1598
+ if (typeof slide.notes === 'string' && slide.notes.trim() !== '') noteIndexes.push(index)
1599
+ })
1600
+ const hasNotesMaster = noteIndexes.length > 0
1205
1601
  const entries = [
1206
- { name: '[Content_Types].xml', data: contentTypesXml(slides.length) },
1602
+ { name: '[Content_Types].xml', data: contentTypesXml(slides.length, noteIndexes) },
1207
1603
  { name: '_rels/.rels', data: rootRelXml() },
1208
- { name: 'docProps/app.xml', data: appXml(slides.length) },
1604
+ { name: 'docProps/app.xml', data: appXml(slides.length, noteIndexes.length) },
1209
1605
  { name: 'docProps/core.xml', data: coreXml(manifest.title) },
1210
- { name: 'ppt/presentation.xml', data: presentationXml(slides.length) },
1211
- { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length) },
1606
+ { name: 'ppt/presentation.xml', data: presentationXml(slides.length, hasNotesMaster) },
1607
+ { name: 'ppt/_rels/presentation.xml.rels', data: presentationRelXml(slides.length, hasNotesMaster) },
1212
1608
  { name: 'ppt/slideMasters/slideMaster1.xml', data: slideMasterXml(theme) },
1213
1609
  { name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: slideMasterRelXml() },
1214
1610
  { name: 'ppt/slideLayouts/slideLayout1.xml', data: slideLayoutXml(theme) },
1215
1611
  { name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: slideLayoutRelXml() },
1216
1612
  { name: 'ppt/theme/theme1.xml', data: themeXml(theme) },
1217
1613
  ]
1614
+ if (hasNotesMaster) {
1615
+ entries.push({ name: 'ppt/notesMasters/notesMaster1.xml', data: notesMasterXml() })
1616
+ entries.push({ name: 'ppt/notesMasters/_rels/notesMaster1.xml.rels', data: notesMasterRelXml() })
1617
+ }
1218
1618
  slides.forEach((slide, index) => {
1219
- entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id) })
1220
- entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml() })
1619
+ const hasNotes = noteIndexes.includes(index)
1620
+ entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id, motion) })
1621
+ entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1622
+ if (hasNotes) {
1623
+ entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes) })
1624
+ entries.push({ name: 'ppt/notesSlides/_rels/notesSlide' + (index + 1) + '.xml.rels', data: notesSlideRelXml(index + 1) })
1625
+ }
1221
1626
  })
1222
1627
  return buildZip(entries)
1223
1628
  }