dsh-ppt 0.8.1 → 0.10.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
@@ -4,6 +4,18 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.10.0 (2026-10-15)
8
+
9
+ 实际操作、最终 PPTX 渲染与未测范围见 [验收记录](docs/validation-0.10.0.md)。
10
+
11
+ - 设置页可复制、删除当前页,复制时保留图片、原生图表与备注;副本拥有独立页面 ID。
12
+ - `ppt_edit` 增加 `duplicate` / `remove`,支持与文字修改、排序合并成一次原子修改,整步可撤销。
13
+ - 保留至少一页并限制 120 页;未保存输入或修订冲突时阻止结构修改,避免覆盖正在编辑的内容。
14
+
15
+ ## 0.9.0 (2026-10-05)
16
+
17
+ - 增加成品 ZIP 下载:一次交付 HTML、可编辑 PPTX、工程 JSON 和使用说明;只打包本项目登记的当前修订,未保存或产物缺失时阻止交付。
18
+
7
19
  ## 0.8.1 (2026-10-03)
8
20
 
9
21
  - 演示文稿工作台、质量提醒和后台失败引导跟随宿主中文 / English 切换;正文、成品语言与工程内容保持原样。
package/README.en.md CHANGED
@@ -2,14 +2,18 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
+ ![dsh-ppt whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ppt/master/assets/cover-whale-girl.png)
6
+
5
7
  Turn source material into an HTML slideshow, editable PPTX and a reusable project.
6
8
 
7
- [![npm](https://img.shields.io/npm/v/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt) [![downloads](https://img.shields.io/npm/dm/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt)
9
+ [![npm](https://img.shields.io/npm/v/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ppt-downloads.svg)](https://www.npmjs.com/package/dsh-ppt)
10
+
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-ppt/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-ppt/pulls).
8
12
 
9
13
  ## What it does
10
14
 
11
15
  - Create branded decks with text, images, tables and native charts.
12
- - Edit text and images in Settings, reorder pages and undo changes.
16
+ - Edit text and images in Settings, reorder, duplicate or delete slides, and undo changes.
13
17
  - Check quality and optionally render the final PPTX as images or PDF.
14
18
 
15
19
  ## Install
package/README.md CHANGED
@@ -2,14 +2,18 @@
2
2
 
3
3
  [English](README.en.md)
4
4
 
5
+ ![dsh-ppt 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ppt/master/assets/cover-whale-girl.png)
6
+
5
7
  把材料制作成 HTML 放映、可编辑 PPTX 和可继续修改的工程文件。
6
8
 
7
- [![npm](https://img.shields.io/npm/v/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt) [![downloads](https://img.shields.io/npm/dm/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt)
9
+ [![npm](https://img.shields.io/npm/v/dsh-ppt)](https://www.npmjs.com/package/dsh-ppt) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ppt-downloads.svg)](https://www.npmjs.com/package/dsh-ppt)
10
+
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-ppt/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-ppt/pulls)。
8
12
 
9
13
  ## 功能
10
14
 
11
15
  - 生成图文、表格、原生图表和品牌化演示文稿。
12
- - 在设置页编辑文字和图片、调整页面顺序,并支持撤销。
16
+ - 在设置页编辑文字和图片、调整顺序、复制或删除页面,并支持撤销。
13
17
  - 提供质量检查与可选的最终 PPTX 图片 / PDF 渲染。
14
18
 
15
19
  ## 安装
@@ -4,6 +4,10 @@
4
4
 
5
5
  These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
6
6
 
7
+ ## 0.9.0 (2026-10-05)
8
+
9
+ - Add a delivery ZIP containing HTML, editable PPTX, project JSON and usage notes. Only indexed files at the saved revision are included; missing or stale artifacts block delivery.
10
+
7
11
  ## 0.7.0 (2026-10-02)
8
12
 
9
13
  - Generated HTML decks now have previous/next buttons and page-number navigation, with keyboard, touch and a usable 390px layout.
package/docs/USAGE.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
4
 
5
+ ## Current improvements
6
+
7
+ Save workbench edits, then choose Download delivery ZIP. Individual downloads remain available. The ZIP excludes configuration, secrets and other workspace files; each file is limited to 64 MiB and the total to 128 MiB.
8
+
5
9
  ## Capabilities
6
10
 
7
11
  | Capability | Description |
@@ -80,7 +84,7 @@ Existing artifacts are not overwritten by default: if any member of the trio alr
80
84
 
81
85
  ### Lightweight Settings editor (0.8.0)
82
86
 
83
- Open Settings → Presentations and choose a project registered by `ppt_create`, `ppt_edit` or `ppt_check`. Select a page to edit text, bullets and speaker notes; clicking text in the HTML preview focuses its field. Upload PNG/JPEG images, reorder pages and undo changes. Saving regenerates HTML, PPTX and JSON together.
87
+ Open Settings → Presentations and choose a project registered by `ppt_create`, `ppt_edit` or `ppt_check`. Select a page to edit text, bullets and speaker notes; clicking text in the HTML preview focuses its field. Upload PNG/JPEG images, reorder, duplicate or delete slides and undo changes. Copies preserve images, charts and notes. The last slide cannot be deleted; the limit is 120 slides. Save or cancel pending edits before changing slide structure. Saving regenerates HTML, PPTX and JSON together.
84
88
 
85
89
  Save or cancel pending changes before switching pages, reordering, downloading or rendering. A stale revision cannot overwrite another writer's changes. Use “Load latest, keep my input”, review overlapping fields, then explicitly save. The HTML preview and actual PPTX rendering are separate; edits invalidate previous renders. Review every rendered page and font diagnostics before downloading the latest files. The editor works with this plugin's own JSON projects; it does not import arbitrary external PPTX files, and ordinary generation/editing does not need the optional renderer.
86
90
 
@@ -94,6 +98,8 @@ A chart accepts `chart: {kind:"column"|"bar"|"line"|"pie", categories, series:[{
94
98
 
95
99
  Edit example: `{deckPath:"report.json",expectedRevision:0,edits:[{slide:3,patch:{title:"A clearer conclusion",bullets:["Evidence","Next action"]}}]}`. Use `ppt_check` to obtain stable IDs and the revision; `ppt_undo` restores the latest edit including assets/brand. Keeps up to 20 edits and rejects stale revisions. The artifact trio is regenerated atomically with rollback on commit failure.
96
100
 
101
+ Structure example: `{deckPath:"report.json",expectedRevision:0,duplicate:[2],remove:[3]}`. References use the original 1-based slide numbers or stable IDs. Copies follow the source slide with new IDs; existing slides keep theirs. A slide cannot be copied and removed in the same request. Keep 1–120 slides. Text edits, reordering and structural changes can share one undo step.
102
+
97
103
  `deliveryStatus: draft` means unfilled slots; `ready-for-review` means review can start. Static estimates, rendering and visual inspection are recorded separately. Generation/rendering never automatically certifies visual quality. Inspect HTML and every page image before delivery.
98
104
 
99
105
  ## Optional PNG/PDF renderer
package/docs/USAGE.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 本次改进
6
+
7
+ 在演示文稿设置页保存修改后点击“下载成品包 ZIP”。单文件下载仍可使用。ZIP 不包含配置、密钥或工作区其他文件;单文件 ≤64 MiB、合计 ≤128 MiB。
8
+
5
9
  ## 能力一览
6
10
 
7
11
  | 能力 | 说明 |
@@ -77,7 +81,7 @@ node <skill-dir>/scripts/build-deck.mjs \
77
81
 
78
82
  ### 设置页轻量编辑(0.8.0)
79
83
 
80
- 打开「设置 → 演示文稿」,选择由 `ppt_create`、`ppt_edit` 或 `ppt_check` 登记的本插件工程。点击页码,修改标题、正文、要点和演讲备注;也可点击 HTML 预览中的文字定位字段。上传 PNG/JPEG 会嵌入工程并同步生成 PPTX。页面上移/下移与文字修改均可撤销。
84
+ 打开「设置 → 演示文稿」,选择由 `ppt_create`、`ppt_edit` 或 `ppt_check` 登记的本插件工程。点击页码,修改标题、正文、要点和演讲备注;也可点击 HTML 预览中的文字定位字段。上传 PNG/JPEG 会嵌入工程并同步生成 PPTX。页面可上移/下移、复制、删除,均支持撤销。复制会保留图片、图表和备注;最后一页不能删除,最多 120 页。有未保存输入时,先保存或取消再操作页面结构。
81
85
 
82
86
  保存会同时更新 HTML、PPTX 和 JSON。未保存时先保存或取消,再切换页面、排序、下载或渲染。若助手同时修改了工程,旧修订不会覆盖新文件;点击「载入最新版本,保留我的输入」后检查重叠字段,再明确保存。
83
87
 
@@ -103,6 +107,8 @@ node <skill-dir>/scripts/build-deck.mjs \
103
107
 
104
108
  编辑示例:`ppt_edit { deckPath: "report.json", expectedRevision: 0, edits: [{ slide: 3, patch: { title: "新的结论", bullets: ["依据一", "下一步"] } }] }`。先用 `ppt_check` 获取页码/ID/修订号,再修改;`ppt_undo` 撤销最近一次修改,恢复图片、图表和品牌。编辑和撤销更新整套文件,失败会回滚。
105
109
 
110
+ 结构示例:`ppt_edit { deckPath: "report.json", expectedRevision: 0, duplicate: [2], remove: [3] }`。引用按修改前的页码或稳定 ID 解析;副本紧跟原页并获得新 ID,其他页面的 ID 保持不变。同一页不能同时复制和删除,最终须保留 1–120 页;可与文字修改、排序合并为一次可撤销操作。
111
+
106
112
  `deliveryStatus: draft` 表示模板未填完;`ready-for-review` 表示可开始检查。静态估算、PPTX 渲染和视觉检查分开记录。生成或渲染成功不会自动代表视觉验收通过,应查看 HTML 和每页图片,修正问题后交付。
107
113
 
108
114
  ## 可选 PNG / PDF 渲染
@@ -0,0 +1,10 @@
1
+ # 0.10.0 验收记录
2
+
3
+ 2026-10-15,Windows / Node 24.16.0,官方 DSH 0.2.1-alpha.3,隔离配置与合成演示文稿。
4
+
5
+ - 本地测试:119 通过,0 跳过;覆盖复制后的独立 ID、图片/图表/备注保留、组合编辑/排序/复制/删除、非法请求不改变成品、修订冲突和撤销。
6
+ - 真实宿主网页:通过「设置 → 演示文稿」点击图片页,复制、删除、连续撤销;删除至最后一页时按钮不可用;未保存文字时禁止结构操作,取消后恢复。
7
+ - 语言与布局:中文和英文控件均可操作,用户的中文标题与备注保持原文;390px 窄屏完成复制/删除/撤销,工作台无横向溢出;未出现页面异常或浏览器控制台错误。
8
+ - 最终 PPTX:官方 LibreOffice Kit 0.1.5 原生引擎导出三页 PNG 与 PDF;完整性收据通过;人工逐页查看封面、原生柱状图和图片页,无缺字、重叠或裁切,缺失字体列表为空。
9
+
10
+ 网页通过不等于桌面原生能力通过。本次未操作用户桌面窗口,未验证 PowerPoint/WPS 编辑和演示者视图,也未让真实模型自主选择工具;不以这些结果代替相应验收。
package/lib/client.js CHANGED
@@ -25,6 +25,10 @@ const EN = Object.freeze({
25
25
  "内容没有变化。": "No changes to save.",
26
26
  "已保存,HTML、PPTX 和工程文件已同步更新。": "Saved. HTML, PPTX and project files were updated together.",
27
27
  "页面顺序已更新,可撤销。": "Slides reordered. You can undo this change.",
28
+ "复制本页": "Duplicate slide",
29
+ "删除本页": "Delete slide",
30
+ "页面已复制,图片、图表和备注一并保留,可撤销。": "Slide duplicated with its images, charts and notes. You can undo this change.",
31
+ "页面已删除,可撤销。": "Slide deleted. You can undo this change.",
28
32
  "已撤销上次修改。": "Last change undone.",
29
33
  "当前 PPTX 已生成逐页图片,请查看画面。": "Slide images were rendered from the current PPTX. Review each slide.",
30
34
  "正在渲染最终 PPTX,较大文件可能需要几分钟…": "Rendering the final PPTX. Large files may take a few minutes…",
@@ -77,6 +81,8 @@ const EN = Object.freeze({
77
81
  "第 ": "Slide ",
78
82
  " 页:": ": ",
79
83
  "下载最新 PPTX": "Download latest PPTX",
84
+ "下载成品包 ZIP": "Download delivery ZIP",
85
+ "成品包包含 PPTX、HTML 和工程文件。请先保存修改,再下载最新版本。": "The bundle includes the PPTX, HTML and project file. Save your edits before downloading the latest version.",
80
86
  "下载 HTML": "Download HTML",
81
87
  "备份工程": "Back up project",
82
88
  "演示文稿": "Presentations"
@@ -237,6 +243,12 @@ function useLanguage(locale) {
237
243
  const order = project.slides.map(s => s.id); [order[index], order[index + delta]] = [order[index + delta], order[index]];
238
244
  return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, order }, signal), value => { adopt(value.project, slide.id); setMessage(t("页面顺序已更新,可撤销。")); });
239
245
  }
246
+ function structure(operation) {
247
+ return perform(signal => action({ operation, id: project.id, expectedRevision: project.revision, slide: slide.id }, signal), value => {
248
+ adopt(value.project, value.selected);
249
+ setMessage(operation === 'duplicate' ? t("页面已复制,图片、图表和备注一并保留,可撤销。") : t("页面已删除,可撤销。"));
250
+ });
251
+ }
240
252
  function undo() { return perform(signal => action({ operation: 'undo', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMessage(t("已撤销上次修改。")); }); }
241
253
  function render() {
242
254
  return perform(signal => action({ operation: 'render', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMode('pptx'); setMessage(t("当前 PPTX 已生成逐页图片,请查看画面。")); }, t("正在渲染最终 PPTX,较大文件可能需要几分钟…"));
@@ -272,7 +284,9 @@ function useLanguage(locale) {
272
284
  h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => { setProject(null); refreshList(); } }, t("返回项目")), h('strong', null, project.title), h('span', { className: 'muted' }, project.slides.length + t(" 页 · 修订 ") + project.revision)),
273
285
  h('div', { className: 'layout' },
274
286
  h('div', null, h('h3', null, t("页面")), h('div', { className: 'pages' }, project.slides.map((row, i) => h('button', { key: row.id, 'aria-current': row.id === selected ? 'true' : undefined, disabled: busy || dirty, onClick: () => choose(row) }, (i + 1) + '. ' + (row.title || t("未命名页"))))),
275
- h('div', { className: 'tools' }, h('button', { disabled: busy || dirty || index === 0, onClick: () => move(-1) }, t("上移")), h('button', { disabled: busy || dirty || index === project.slides.length - 1, onClick: () => move(1) }, t("下移")))),
287
+ h('div', { className: 'tools' }, h('button', { disabled: busy || dirty || index === 0, onClick: () => move(-1) }, t("上移")), h('button', { disabled: busy || dirty || index === project.slides.length - 1, onClick: () => move(1) }, t("下移")),
288
+ h('button', { disabled: busy || dirty || project.slides.length >= 120, onClick: () => structure('duplicate') }, t("复制本页")),
289
+ h('button', { disabled: busy || dirty || project.slides.length <= 1, onClick: () => structure('remove') }, t("删除本页")))),
276
290
  h('div', null,
277
291
  h('div', { className: 'tools' }, h('button', { disabled: busy, 'aria-pressed': mode === 'html', onClick: () => setMode('html') }, t("HTML 预览")),
278
292
  h('button', { disabled: busy || !project.render, 'aria-pressed': mode === 'pptx', onClick: () => setMode('pptx') }, t("最终 PPTX 预览")),
@@ -290,7 +304,8 @@ function useLanguage(locale) {
290
304
  image && h(React.Fragment, null, h('p', { className: 'muted' }, t("已选择:") + image.name), h('label', { htmlFor: 'pptw-alt' }, t("图片说明")), h('input', { id: 'pptw-alt', value: image.alt, disabled: busy, onChange: e => setImage({ ...image, alt: e.target.value }) }), h('label', { htmlFor: 'pptw-fit' }, t("图片适应方式")), h('select', { id: 'pptw-fit', value: image.fit, disabled: busy, onChange: e => setImage({ ...image, fit: e.target.value }) }, h('option', { value: 'contain' }, t("完整显示")), h('option', { value: 'cover' }, t("填满并裁切")))),
291
305
  h('div', { className: 'tools' }, h('button', { className: 'primary', disabled: busy || !dirty, onClick: save }, t("保存此页")), h('button', { disabled: busy || !dirty, onClick: () => { setDraft(draftFor(slide)); setImage(null); setDirty(false); setError(''); setConflict(false); setOverlaps([]); } }, t("取消修改")), h('button', { disabled: busy || dirty || !project.undoAvailable, onClick: undo }, t("撤销上次修改")))),
292
306
  h('details', { className: 'card' }, h('summary', null, t("质量提醒(") + project.quality.warningCount + (languageOf() === 'en' ? ')' : ')')), (project.quality.issues || []).map((issue, i) => h('p', { key: i }, t("第 ") + issue.slide + t(" 页:") + (languageOf() === 'en' ? issue.messageEn : issue.message) + (languageOf() === 'en' ? ' ' : ';') + (languageOf() === 'en' ? issue.suggestionEn : issue.suggestion)))),
293
- h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => download('pptx') }, t("下载最新 PPTX")), h('button', { disabled: busy || dirty, onClick: () => download('html') }, t("下载 HTML")), h('button', { disabled: busy || dirty, onClick: () => download('json') }, t("备份工程")))
307
+ h('p', { className: 'muted' }, t("成品包包含 PPTX、HTML 和工程文件。请先保存修改,再下载最新版本。")),
308
+ h('div', { className: 'tools' }, h('button', { className: 'primary', disabled: busy || dirty, onClick: () => download('bundle') }, t("下载成品包 ZIP")), h('button', { disabled: busy || dirty, onClick: () => download('pptx') }, t("下载最新 PPTX")), h('button', { disabled: busy || dirty, onClick: () => download('html') }, t("下载 HTML")), h('button', { disabled: busy || dirty, onClick: () => download('json') }, t("备份工程")))
294
309
  )
295
310
  )
296
311
  )
package/lib/tools.js CHANGED
@@ -216,6 +216,8 @@ export function buildPptTools(config) {
216
216
  parameters: compileParameters({ ...projectParameters,
217
217
  edits: { type: 'array', items: { type: 'object', properties: { slide: { type: ['integer', 'string'], description: '1-based page number or stable slide ID' }, patch: { type: 'object', properties: slideProperties, additionalProperties: false } }, required: ['slide', 'patch'], additionalProperties: false } },
218
218
  order: { type: 'array', items: { type: 'string' }, description: 'Reorder pages using every stable slide ID exactly once. May be used alone or together with edits; one undo restores the previous content and order.' },
219
+ duplicate: { type: 'array', items: { type: ['integer', 'string'] }, description: 'Copy selected existing slides immediately after their originals, including images/charts/notes. References are 1-based page numbers or stable IDs from before this edit. Copies get new IDs; undo restores the previous deck.' },
220
+ remove: { type: 'array', items: { type: ['integer', 'string'] }, description: 'Remove selected slides by their original 1-based page number or stable ID. At least one page must remain. A slide cannot be copied and removed in the same edit.' },
219
221
  brand: { type: ['object', 'null'], properties: brandProperties, additionalProperties: false, description: 'Merge shared branding. null clears all branding. Empty footer clears footer; logo:null clears logo.' },
220
222
  }),
221
223
  output: { schema: createResultSchema, render: (_args, value) => renderCreate(value) },
@@ -1,5 +1,7 @@
1
1
  /** UI messages are independent of the deck's language; slide content is never translated. */
2
2
  const messages = {
3
+ '成品文件不完整,请先保存一次再下载成品包': 'The delivery files are incomplete. Save this presentation before downloading the bundle.',
4
+ '成品包超过 128 MiB,请分别下载文件': 'The delivery bundle exceeds 128 MiB. Download the files separately.',
3
5
  '文件不是普通文件或超过 64 MiB': 'The file is not a regular file or exceeds 64 MiB.',
4
6
  '项目索引超过 8 MiB,请备份后整理 projects.jsonl': 'The project index exceeds 8 MiB. Back it up before reviewing projects.jsonl.',
5
7
  '项目索引已满,请备份后整理 projects.jsonl': 'The project index is full. Back it up before reviewing projects.jsonl.',
@@ -34,6 +36,10 @@ export function englishWorkbenchMessage(message) {
34
36
  return 'This field cannot be edited from the panel.';
35
37
  if (/没有可撤销|无法撤销|历史记录为空/.test(message))
36
38
  return 'No change is available to undo.';
39
+ if (/至少保留一页/.test(message))
40
+ return 'Keep at least one slide in the presentation.';
41
+ if (/工程页数超过 120/.test(message))
42
+ return 'A presentation can contain at most 120 slides.';
37
43
  if (/没有.*图片|超过.*MiB|图片.*无效/.test(message))
38
44
  return 'The image is invalid or too large. Choose another PNG or JPEG image.';
39
45
  return /[\u3400-\u9fff]/.test(message) ? 'The operation failed. Check the project files and DSH logs, then try again.' : message;
package/lib/workbench.js CHANGED
@@ -5,10 +5,12 @@ import { homedir } from 'node:os';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { getEngine } from './execution.js';
7
7
  import { bilingualQuality, englishWorkbenchMessage } from './workbench-language.js';
8
+ import { zipSync, strToU8 } from 'fflate';
8
9
  export const PPT_WORKBENCH_ROUTE = '/api/dsh-ppt/workbench';
9
10
  export const PPT_DOWNLOAD_ROUTE = '/api/dsh-ppt/download';
10
11
  const REQUEST_LIMIT = 12 * 1024 * 1024;
11
12
  const FILE_LIMIT = 64 * 1024 * 1024;
13
+ const BUNDLE_LIMIT = 128 * 1024 * 1024;
12
14
  const digest = (value) => createHash('sha256').update(value).digest('hex');
13
15
  function ordinaryFile(path) {
14
16
  const stat = lstatSync(path);
@@ -188,6 +190,17 @@ export class PptWorkbench {
188
190
  await this.remember(result, args);
189
191
  return { ...await this.view(entry.id), changed: result.changed };
190
192
  }
193
+ if (body.operation === 'duplicate' || body.operation === 'remove') {
194
+ if (typeof body.slide !== 'string' || !manifest.slides.some((s) => s.id === body.slide))
195
+ throw new Error('页面不存在');
196
+ const args = { deckPath: entry.path, expectedRevision: body.expectedRevision, signal, [body.operation]: [body.slide] };
197
+ const result = await engine.editDeck(args);
198
+ await this.remember(result, args);
199
+ const view = await this.view(entry.id);
200
+ const originalIndex = manifest.slides.findIndex((s) => s.id === body.slide);
201
+ const selected = view.project.slides[Math.min(originalIndex + (body.operation === 'duplicate' ? 1 : 0), view.project.slides.length - 1)].id;
202
+ return { ...view, selected };
203
+ }
191
204
  if (body.operation === 'undo') {
192
205
  await engine.undoDeck({ deckPath: entry.path, expectedRevision: body.expectedRevision, signal });
193
206
  return this.view(entry.id);
@@ -202,19 +215,45 @@ export class PptWorkbench {
202
215
  return denied;
203
216
  try {
204
217
  const params = new URL(request.url).searchParams, kind = params.get('kind');
205
- if (!['html', 'pptx', 'json'].includes(String(kind)))
218
+ if (!['html', 'pptx', 'json', 'bundle'].includes(String(kind)))
206
219
  return fail(400, '不支持此文件类型');
207
220
  const { entry, manifest, files } = await this.project(params.get('id'));
208
221
  if (String(manifest.revision) !== params.get('revision'))
209
222
  return fail(409, '项目已被修改,请重新打开后下载');
210
223
  request.signal.throwIfAborted();
211
- const path = files[kind], bytes = ordinaryFile(path);
224
+ let name, bytes;
225
+ if (kind === 'bundle') {
226
+ const entries = {};
227
+ let total = 0;
228
+ for (const path of Object.values(files)) {
229
+ if (!existsSync(path))
230
+ return fail(400, '成品文件不完整,请先保存一次再下载成品包');
231
+ const content = ordinaryFile(path);
232
+ total += content.byteLength;
233
+ if (total > BUNDLE_LIMIT)
234
+ return fail(413, '成品包超过 128 MiB,请分别下载文件');
235
+ entries[basename(path)] = content;
236
+ }
237
+ // Only the three known sibling artifacts are included. No directory walks,
238
+ // local configuration, attachment paths or journal files enter the archive.
239
+ entries['README.txt'] = strToU8('演示文稿成品包 / Presentation delivery bundle\n\nPPTX: 在 PowerPoint / WPS 中编辑。 / Edit in PowerPoint or WPS.\nHTML: 解压后用浏览器打开,支持离线放映。 / Extract and open in a browser for offline playback.\nJSON: 保留以便继续在 DSH 中编辑。 / Keep this project file for future DSH edits.\n\n修订 / Revision: ' + manifest.revision + '\n');
240
+ // PPTX and embedded images are already compressed; store to avoid a large
241
+ // synchronous recompression on the host's event loop.
242
+ bytes = zipSync(entries, { level: 0 });
243
+ name = basename(entry.path, '.json') + '-delivery.zip';
244
+ }
245
+ else {
246
+ const path = files[kind];
247
+ bytes = ordinaryFile(path);
248
+ name = basename(path);
249
+ }
250
+ request.signal.throwIfAborted();
212
251
  if ((await this.project(entry.id)).manifest.revision !== manifest.revision)
213
252
  return fail(409, '项目已被修改,请重新打开后下载');
214
- const type = kind === 'pptx' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' : kind === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8';
253
+ const type = kind === 'bundle' ? 'application/zip' : kind === 'pptx' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' : kind === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8';
215
254
  return new Response(new Uint8Array(bytes), { headers: {
216
255
  'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
217
- 'content-disposition': `attachment; filename="presentation.${kind}"; filename*=UTF-8''${encodeURIComponent(basename(path))}`,
256
+ 'content-disposition': `attachment; filename="presentation.${kind === 'bundle' ? 'zip' : kind}"; filename*=UTF-8''${encodeURIComponent(name)}`,
218
257
  } });
219
258
  }
220
259
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ppt",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "DSH 演示文稿插件:生成 HTML、可编辑 PPTX 和工程文件,支持图表、轻量编辑、撤销与质量检查。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -78,7 +78,8 @@
78
78
  "author": "stardustlc",
79
79
  "packageManager": "pnpm@11.7.0",
80
80
  "dependencies": {
81
- "@office-kit/pptx": "0.21.0"
81
+ "@office-kit/pptx": "0.21.0",
82
+ "fflate": "0.8.3"
82
83
  },
83
84
  "peerDependencies": {
84
85
  "@deepseek-ai/libreoffice-kit": "0.1.5"
@@ -35,7 +35,7 @@ allowed-tools: "Bash, Read, Write, Edit"
35
35
  - 动效默认开(页间转场 + 要点逐条入场);严肃/打印场景传 `motion: 'off'`。
36
36
  → 用表格 / 金句 / 备注 / 结构化 slides 前读 `references/syntax.md`。
37
37
  5. **Verify 校验与修正**:调 `ppt_check` 检查具体页;在浏览器操作放映、总览和备注,查看图片/图表。若可选官方引擎可用,调 `ppt_render` 读取最终 PPTX 生成逐页 PNG/PDF,逐页看画面。修正用 `ppt_edit`,以 `expectedRevision` 防冲突;需要时 `ppt_undo`。修改后重新渲染,以收据复核当前文件。文件头/结构通过不等同于视觉验收,静态估算、渲染、视觉检查必须分开说明。
38
- 6. **Deliver 交付**:给用户三个绝对路径,说明 HTML 直接放映(← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印),PPTX 用 PowerPoint / WPS / Keynote 打开(备注在演示者视图可见,bullets 页放映时逐条点击出现)。DSH 用户也可在「设置 → 演示文稿」修改文字、替换 PNG/JPEG、移动页面、撤销和下载最新文件;已有 JSON 工程先 `ppt_check` 才会出现在项目列表。`ppt_edit.order` 使用每个稳定页面 ID 各一次,与正文修改合并成一次撤销。
38
+ 6. **Deliver 交付**:给用户三个绝对路径,说明 HTML 直接放映(← → 翻页 · F 全屏 · G 总览 · S 备注 · P 打印),PPTX 用 PowerPoint / WPS / Keynote 打开(备注在演示者视图可见,bullets 页放映时逐条点击出现)。DSH 用户也可在「设置 → 演示文稿」修改文字、替换 PNG/JPEG、移动/复制/删除页面、撤销和下载最新文件;已有 JSON 工程先 `ppt_check` 才会出现在项目列表。`ppt_edit.order` 使用每个稳定页面 ID 各一次;`duplicate`、`remove` 使用修改前的页码或稳定 ID,复制保留图片/图表/备注,最终保留 1–120 页。结构与正文修改可合为一次撤销。
39
39
 
40
40
  ## 按需装载索引
41
41
 
@@ -16,7 +16,9 @@ Agent 先准备实际内容,再生成可放映 HTML 与可编辑 PPTX。用户
16
16
 
17
17
  先 `ppt_check {deckPath:"report.json"}` 取得当前revision和页面ID。修改:`ppt_edit {deckPath:"report.json",expectedRevision:0,edits:[{slide:3,patch:{title:"新标题",bullets:["事实一","行动"]}}]}`。slide为1起始页码或ID,其他页保留;重复指定页拒绝。可单独修改brand;brand:null清空品牌,logo:null清空Logo,空footer清空页脚。
18
18
 
19
- 编辑会原子更新整套HTML/PPTX/JSON,失败回滚。历史最多20次。`ppt_undo {deckPath:"report.json",expectedRevision:1}` 恢复上次状态,包含图片/图表/品牌;revision继续增长,旧修改不可覆盖撤销。仅编辑本插件JSON工程,不导入任意外部PPTX进行修改。编辑不能新增/删除页;表格扩大到需要分页时会要求另建文稿。
19
+ 复制与删除:`ppt_edit {deckPath:"report.json",expectedRevision:0,duplicate:[2],remove:[3]}`。两组引用都按修改前的1起始页码或稳定ID解析;副本紧跟原页,获得新ID,并保留图片、图表和备注。同一页不能同时复制和删除,不能重复指定,最终保留1–120页。可与edits和order合为一次修改,先编辑/排序,再复制/删除;order仍需包含每个原始页面ID各一次。
20
+
21
+ 编辑会原子更新整套HTML/PPTX/JSON,失败回滚。历史最多20次。`ppt_undo {deckPath:"report.json",expectedRevision:1}` 恢复上次状态,包含页面结构、图片/图表/品牌;revision继续增长,旧修改不可覆盖撤销。仅编辑本插件JSON工程,不导入任意外部PPTX进行修改。表格扩大到需要分页时会要求另建文稿。
20
22
 
21
23
  ## 检查与渲染
22
24
 
@@ -396,7 +396,7 @@ export async function editDeck(options = {}) {
396
396
  expected(current, options.expectedRevision)
397
397
  const next = clone(current)
398
398
  if (!Array.isArray(options.edits) || !options.edits.length) {
399
- if (options.brand === undefined && options.order === undefined) fail('请提供 edits、order 或 brand')
399
+ if (options.brand === undefined && options.order === undefined && options.duplicate === undefined && options.remove === undefined) fail('请提供 edits、order、duplicate、remove 或 brand')
400
400
  }
401
401
  const used = new Set(), allowed = new Set(['layout', 'title', 'subtitle', 'kicker', 'text', 'bullets', 'rows', 'notes', 'image', 'chart'])
402
402
  for (const edit of options.edits ?? []) {
@@ -425,6 +425,24 @@ export async function editDeck(options = {}) {
425
425
  if (options.order.some(id => !byId.has(id))) fail('order 含有不属于本工程的页面 ID')
426
426
  next.slides = options.order.map(id => byId.get(id))
427
427
  }
428
+ function selectedIds(value, field) {
429
+ if (value === undefined) return new Set()
430
+ if (!Array.isArray(value) || value.length === 0 || value.length > 120) fail(field + ' 需为 1-120 个页码或页面 ID')
431
+ const ids = value.map(ref => {
432
+ const slide = typeof ref === 'number' && Number.isInteger(ref) ? current.slides[ref - 1] : current.slides.find(s => s.id === ref)
433
+ if (!slide) fail(field + ' 找不到页面:' + ref)
434
+ return slide.id
435
+ })
436
+ if (new Set(ids).size !== ids.length) fail(field + ' 不能重复指定同一页')
437
+ return new Set(ids)
438
+ }
439
+ const duplicate = selectedIds(options.duplicate, 'duplicate'), remove = selectedIds(options.remove, 'remove')
440
+ if ([...duplicate].some(id => remove.has(id))) fail('同一页不能同时复制和删除')
441
+ const count = next.slides.length + duplicate.size - remove.size
442
+ if (count < 1) fail('至少保留一页')
443
+ if (count > 120) fail('工程页数超过 120')
444
+ if (duplicate.size || remove.size) next.slides = next.slides.flatMap(slide => remove.has(slide.id) ? [] :
445
+ duplicate.has(slide.id) ? [slide, { ...clone(slide), id: randomUUID() }] : [slide])
428
446
  if (options.brand !== undefined) next.brand = normalizeBrand(options.brand === null ? null : { ...next.brand, ...options.brand,
429
447
  ...(options.brand?.logo === null ? { logo: undefined, logoAssetId: undefined } : {}) }, next.assets, resolve(options.cwd || '.'))
430
448
  if (JSON.stringify(snapshot(next)) === JSON.stringify(snapshot(current))) return { ...result(current, pathsFor(state.path)), changed: false }