dsh-ppt 0.4.1 → 0.4.3

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3(2026-09-18)
4
+
5
+ - 修复:中文分句导致正文丢内容;`--content deck.md` 不读文件;>8 条要点静默丢弃;`deck.json` 版本写死;英文 deck 被标 zh-CN。
6
+ - 稳健性:三件套改为 tmp + rename 原子提交(失败回滚);过滤 Windows 保留名;文件名探测改一次目录快照。
7
+ - 测试 48 → 60 项。
8
+ ## 0.4.2(2026-09-11)
9
+
10
+ - 适配并验证官方 Harness 0.1.5-rc.1:整套同载、工具/技能契约与 Web 鉴权检查通过。
11
+ - Node 要求与宿主统一为 `^22.19.0 || >=24.0.0`;更新中英文兼容性说明。
12
+
13
+ ## 0.4.1
14
+
15
+ - 修复中文正文分页:中英文标点都作为句子边界,无标题纯文本严格按「每 5 句一页」分页,不再把整段中文当一句而截断丢正文。
16
+ - CLI `--content <path>` 与 `--content @<path>` 行为一致:路径存在即读取文件,README / SKILL.md 示例可直接复制运行。
17
+ - 单节要点超过 8 条时自动续页(第二页起标题带「(续)」),不再静默丢弃第 9 条之后的要点。
18
+ - manifest `version` 改从 `package.json` 读取,消除与发版号的漂移;测试同步改为动态断言。
19
+ - PPTX 文本与备注 run 的语言跟随 `lang`(zh-CN / en-US),英文 deck 不再被 PowerPoint 标成中文。
20
+
3
21
  ## 0.4.0
4
22
 
5
23
  - **动效(motion,默认开)**:HTML 放映页间淡入 + 要点按序逐条入场(打印自动兜底为静态);PPTX 每页原生淡入转场,`bullets` 页按段落构建、放映时点击一次出现一条要点(PowerPoint「出现,按段落」标准时序)。
package/README.en.md CHANGED
@@ -30,7 +30,7 @@ Example:
30
30
 
31
31
  ## Compatibility
32
32
 
33
- Verified against the tool and skill registration contracts in `@deepseek-ai/dsh@0.1.2-rc.1` on 2026-09-04 (no relevant API or documentation changes from alpha.5). Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
33
+ Verified with official `@deepseek-ai/dsh@0.1.5-rc.1` and Node `24.16.0` on 2026-09-11: all 18 components load alongside Modlens, with passing tool-schema, skill-registration and offline read-only invocation checks. Uses the `cordis.patch.yml` + `dsh.bundle.patch` bundle model. Node requirements match this Harness release: 22.19 or later within 22.x, or 24 or later. Live external-service workflows require separate configuration and validation.
34
34
 
35
35
  ## Installation
36
36
 
@@ -128,7 +128,7 @@ Output paths resolve against each tool call's `exec.agent.session.header.cwd`: o
128
128
 
129
129
  - `lang` argument: `zh` (default) / `en` / `bilingual` controls the player UI, page numbers, and closing defaults.
130
130
  - Content language is up to the author: for a bilingual deck, prefer "Chinese title + English subtitle" or generate two decks from the same outline.
131
- - Plugin docs, skill content, and error messages are bilingual.
131
+ - Plugin docs come in Chinese (README.md) and English (this file); CLI and skill error messages are in Chinese.
132
132
 
133
133
  ## Engineering quality
134
134
 
package/README.md CHANGED
@@ -32,7 +32,7 @@ DSH(DeepSeek Harness)演示文稿技能 + 工具插件:把一句话、一
32
32
 
33
33
  ## 兼容性
34
34
 
35
- 已按 `@deepseek-ai/dsh@0.1.2-rc.1` 的工具与技能注册契约验证(2026-09-04;相较 alpha.5 无相关 API / 文档变化)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
35
+ 已在官方 `@deepseek-ai/dsh@0.1.5-rc.1`、Node `24.16.0` 上验证(2026-09-11):18 个组件与 Modlens 同载,工具 schema、技能注册及离线只读调用检查通过。采用 `cordis.patch.yml` + `dsh.bundle.patch` 组合包模型。Node 要求与该版本 Harness 一致:22.19 及以上的 22.x,或 24 及以上。外部服务的实际业务操作需按各组件配置单独验证。
36
36
 
37
37
  ## 安装
38
38
 
@@ -130,7 +130,7 @@ dsh plugin --profile web remove dsh-ppt
130
130
 
131
131
  - `lang` 参数:`zh`(默认)/ `en` / `bilingual`,控制播放器界面、页码与结束页默认文案。
132
132
  - 内容语言由你撰写:双语 deck 推荐「中文标题 + 英文副标题」,或同一大纲分别生成中英两份。
133
- - 插件自身文档、技能正文、错误提示均中英双语。
133
+ - 插件文档提供中英两版(README.md / README.en.md);CLI 与技能的报错提示为中文。
134
134
 
135
135
  ## 工程质量
136
136
 
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "dsh-ppt",
3
- "version": "0.4.1",
4
- "description": "DSH 演示文稿技能 + 工具插件:一句话或一篇 Markdown 文档 完整演示文稿(独立 HTML 网页放映 + 可编辑 PPTX 导出),内置 5 套视觉主题与页间转场/要点入场动画(motion 可关),可配置默认主题/语言,中英双语,SKILL.md 薄路由 + references 按需装载,跨 harness。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "types": "lib/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./lib/index.d.ts",
11
- "default": "./lib/index.js"
12
- },
13
- "./skills/*": "./skills/*",
14
- "./deck-core": "./skills/dsh-ppt/scripts/deck-core.mjs",
15
- "./build-deck": "./skills/dsh-ppt/scripts/build-deck.mjs",
16
- "./cordis.patch.yml": "./cordis.patch.yml",
17
- "./package.json": "./package.json"
18
- },
19
- "files": [
20
- "lib",
21
- "skills",
22
- "cordis.patch.yml",
23
- "README.md",
24
- "README.en.md",
25
- "CHANGELOG.md",
26
- "LICENSE"
27
- ],
28
- "scripts": {
29
- "build": "tsc -p tsconfig.json",
30
- "typecheck": "tsc -p tsconfig.json --noEmit",
31
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
32
- "test:core": "node --test \"test/*.test.mjs\"",
33
- "smoke:cli": "node skills/dsh-ppt/scripts/build-deck.mjs --title \"DSH PPT Smoke\" --content @smoke/sample-deck.md --theme data --lang zh --out .smoke-deck"
34
- },
35
- "dsh": {
36
- "bundle": {
37
- "patch": "./cordis.patch.yml"
38
- }
39
- },
40
- "keywords": [
41
- "dsh",
42
- "deepseek-harness",
43
- "dsh-plugin",
44
- "plugin",
45
- "skills",
46
- "ppt",
47
- "pptx",
48
- "presentation",
49
- "slides",
50
- "html"
51
- ],
52
- "license": "MIT",
53
- "engines": {
54
- "node": ">=22"
55
- },
56
- "devDependencies": {
57
- "@types/node": "^24.0.0",
58
- "typescript": "^5.6.0"
59
- },
60
- "repository": {
61
- "type": "git",
62
- "url": "git+https://github.com/STARDUSTLC666/dsh-ppt.git"
63
- },
64
- "bugs": {
65
- "url": "https://github.com/STARDUSTLC666/dsh-ppt/issues"
66
- },
67
- "homepage": "https://github.com/STARDUSTLC666/dsh-ppt#readme",
68
- "author": "stardustlc",
69
- "packageManager": "pnpm@11.7.0"
70
- }
1
+ {
2
+ "name": "dsh-ppt",
3
+ "version": "0.4.3",
4
+ "description": "DSH 演示文稿插件:一句话或一篇 Markdown → 网页放映 + 可编辑 PPTX,内置 5 套主题与转场动画。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./skills/*": "./skills/*",
14
+ "./deck-core": "./skills/dsh-ppt/scripts/deck-core.mjs",
15
+ "./build-deck": "./skills/dsh-ppt/scripts/build-deck.mjs",
16
+ "./cordis.patch.yml": "./cordis.patch.yml",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "skills",
22
+ "cordis.patch.yml",
23
+ "README.md",
24
+ "README.en.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
32
+ "test:core": "node --test \"test/*.test.mjs\"",
33
+ "smoke:cli": "node skills/dsh-ppt/scripts/build-deck.mjs --title \"DSH PPT Smoke\" --content @smoke/sample-deck.md --theme data --lang zh --out .smoke-deck"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ }
39
+ },
40
+ "keywords": [
41
+ "dsh",
42
+ "deepseek-harness",
43
+ "dsh-plugin",
44
+ "plugin",
45
+ "skills",
46
+ "ppt",
47
+ "pptx",
48
+ "presentation",
49
+ "slides",
50
+ "html"
51
+ ],
52
+ "license": "MIT",
53
+ "engines": {
54
+ "node": "^22.19.0 || >=24.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "typescript": "^5.6.0"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/STARDUSTLC666/dsh-ppt.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/STARDUSTLC666/dsh-ppt/issues"
66
+ },
67
+ "homepage": "https://github.com/STARDUSTLC666/dsh-ppt#readme",
68
+ "author": "stardustlc",
69
+ "packageManager": "pnpm@11.7.0"
70
+ }
@@ -11,7 +11,7 @@
11
11
  * node build-deck.mjs --list-themes
12
12
  */
13
13
 
14
- import { readFileSync } from 'node:fs'
14
+ import { existsSync, readFileSync } from 'node:fs'
15
15
  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'
@@ -99,6 +99,9 @@ export async function main(argv = process.argv.slice(2), io = {}) {
99
99
  content = await readStdin(io.stdin ?? process.stdin)
100
100
  } else if (content.startsWith('@')) {
101
101
  content = readFileSync(resolvePath(content.slice(1)), 'utf8')
102
+ } else if (content.trim() !== '' && existsSync(resolvePath(content.trim()))) {
103
+ // README / SKILL.md 的示例直接传文件路径(不带 @)时也按文件读取
104
+ content = readFileSync(resolvePath(content.trim()), 'utf8')
102
105
  } else if (content.trim() === '' && slides === null) {
103
106
  throw new Error('dsh-ppt CLI:--content 不能为空(或用 --slides 传结构化幻灯片)')
104
107
  }
@@ -14,11 +14,25 @@
14
14
  * 零运行时依赖,仅使用 node:fs / node:path / node:zlib。
15
15
  */
16
16
 
17
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, 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.3.0'
21
+ /**
22
+ * manifest 版本跟随发布包 package.json;把 skills/dsh-ppt 独立复制到其他
23
+ * harness(没有 package.json)时回退为 0.0.0,避免版本号悄悄漂移。
24
+ */
25
+ function readDeckVersion() {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'))
28
+ const version = String(pkg.version ?? '').trim()
29
+ return version !== '' ? version : '0.0.0'
30
+ } catch {
31
+ return '0.0.0'
32
+ }
33
+ }
34
+
35
+ export const DECK_VERSION = readDeckVersion()
22
36
 
23
37
  // ---------------------------------------------------------------------------
24
38
  // 主题
@@ -239,13 +253,17 @@ export function clampInt(value, fallback, min, max) {
239
253
  return Math.min(max, Math.max(min, n))
240
254
  }
241
255
 
256
+ // Windows 保留设备名:首个点之前的主名命中即不可用(NUL、com1、aux.report.html 都算)。
257
+ const WINDOWS_RESERVED_FILE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i
258
+
242
259
  export function sanitizeFileName(input) {
243
260
  const base = String(input ?? 'deck').trim().replace(/\.(html?|pptx|json)$/i, '')
244
- const cleaned = base
261
+ let cleaned = base
245
262
  .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
246
263
  .replace(/\s+/g, '-')
247
264
  .replace(/-+/g, '-')
248
265
  .replace(/^-+|-+$/g, '')
266
+ if (WINDOWS_RESERVED_FILE_NAME.test(cleaned)) cleaned = 'deck-' + cleaned
249
267
  return cleaned.slice(0, 120) || 'deck'
250
268
  }
251
269
 
@@ -256,7 +274,10 @@ const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
256
274
  * 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
257
275
  */
258
276
  function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
259
- const isAvailable = (candidate) => DECK_ARTIFACT_EXTENSIONS.every((ext) => !existsSync(resolvePath(outputDir, candidate + ext)))
277
+ // 一次 readdir 建集,替代逐个候选 existsSync 探测(最坏 ~3000 stat);
278
+ // 统一小写比较,避免 Windows 大小写不敏感文件系统把已占用的名字判成可用。
279
+ const existing = new Set(readdirSync(outputDir).map((name) => name.toLowerCase()))
280
+ const isAvailable = (candidate) => DECK_ARTIFACT_EXTENSIONS.every((ext) => !existing.has((candidate + ext).toLowerCase()))
260
281
  if (overwrite || isAvailable(requestedFileName)) return requestedFileName
261
282
  for (let index = 1; index < 1000; index += 1) {
262
283
  const candidate = requestedFileName + '-' + index
@@ -299,7 +320,7 @@ export function stripInlineMarkdown(value) {
299
320
  export function splitSentences(value) {
300
321
  const text = String(value ?? '').trim()
301
322
  if (text === '') return []
302
- const parts = text.split(/(?<=[.!?。!?…])\s+/).map((part) => part.trim()).filter(Boolean)
323
+ const parts = text.split(/(?<=[。!?])|(?<=[.!?…])\s+/).map((part) => part.trim()).filter(Boolean)
303
324
  return parts.length > 0 ? parts : [text]
304
325
  }
305
326
 
@@ -552,16 +573,16 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
552
573
  if (section.heading === '') {
553
574
  const points = [...section.bullets, ...section.paragraphs]
554
575
  .flatMap((part) => splitSentences(part))
555
- .slice(0, 8)
556
- if (points.length > 0) {
576
+ // 超过 8 条自动续页,不再静默丢弃;备注只挂在第一页
577
+ for (const [pageIndex, pagePoints] of chunk(points, 8).entries()) {
557
578
  pointIndex += 1
558
579
  const slide = {
559
580
  layout: 'bullets',
560
581
  kicker: ui.pointKicker + ' ' + pointIndex,
561
- title: truncate(points[0], 40) || ui.pointKicker + ' ' + pointIndex,
562
- bullets: points,
582
+ title: truncate(pagePoints[0], 40) || ui.pointKicker + ' ' + pointIndex,
583
+ bullets: pagePoints,
563
584
  }
564
- if (specials.length === 0 && notes !== '') slide.notes = notes
585
+ if (pageIndex === 0 && specials.length === 0 && notes !== '') slide.notes = notes
565
586
  slides.push(slide)
566
587
  }
567
588
  continue
@@ -569,11 +590,16 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
569
590
  const points = [
570
591
  ...section.bullets,
571
592
  ...section.paragraphs.flatMap((part) => splitSentences(part)),
572
- ].slice(0, 8)
573
- if (points.length > 0) {
574
- const slide = { layout: 'bullets', kicker: section.heading, title: section.heading, bullets: points }
575
- if (specials.length === 0 && notes !== '') slide.notes = notes
576
- slides.push(slide)
593
+ ]
594
+ const pages = chunk(points, 8)
595
+ if (pages.length > 0) {
596
+ // 超过 8 条自动续页,不再静默丢弃;备注只挂在第一页
597
+ pages.forEach((pagePoints, pageIndex) => {
598
+ const label = pageIndex === 0 ? section.heading : section.heading + '(续)'
599
+ const slide = { layout: 'bullets', kicker: label, title: label, bullets: pagePoints }
600
+ if (pageIndex === 0 && specials.length === 0 && notes !== '') slide.notes = notes
601
+ slides.push(slide)
602
+ })
577
603
  } else if (specials.length === 0) {
578
604
  const slide = { layout: 'section', kicker: section.heading, title: section.heading }
579
605
  if (notes !== '') slide.notes = notes
@@ -674,6 +700,64 @@ export function normalizeBuildOptions(options = {}) {
674
700
  return { title, theme, language, motion, deck, outputDir, fileName, overwrite }
675
701
  }
676
702
 
703
+ /**
704
+ * 三件套落盘:三份内容全部先写同目录临时文件,再逐个 rename 提交。
705
+ * 任一步失败都清理临时文件,并把已提交的目标恢复为调用前的内容,
706
+ * 避免出现「json/html 已更新、pptx 还是旧的」这类混版状态。
707
+ */
708
+ function commitDeckArtifacts(artifacts) {
709
+ const stamp = process.pid.toString(36) + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
710
+ const items = artifacts.map((item, index) => ({ ...item, tempPath: item.path + '.' + stamp + '-' + index + '.tmp' }))
711
+ const removeTemp = (item) => {
712
+ try {
713
+ rmSync(item.tempPath, { force: true })
714
+ } catch {}
715
+ }
716
+
717
+ // 阶段 0:记下已有产物内容用于回滚;读不出来(如被同名目录占用)先报错,不碰任何文件。
718
+ const previous = new Map()
719
+ for (const item of items) {
720
+ if (!existsSync(item.path)) continue
721
+ try {
722
+ previous.set(item.path, readFileSync(item.path))
723
+ } catch (error) {
724
+ throw new Error('dsh-ppt:三件套写入失败(' + item.path + ' 已存在但无法读取),原文件未改动:' + error.message)
725
+ }
726
+ }
727
+
728
+ // 阶段 1:全部写临时文件;这一步失败时目标文件尚未被改动。
729
+ let written = 0
730
+ try {
731
+ for (const item of items) {
732
+ writeFileSync(item.tempPath, item.data, item.encoding)
733
+ written += 1
734
+ }
735
+ } catch (error) {
736
+ for (const item of items.slice(0, written + 1)) removeTemp(item)
737
+ throw new Error('dsh-ppt:写入临时文件失败(' + items[written].path + '),原三件套未改动:' + error.message)
738
+ }
739
+
740
+ // 阶段 2:逐个 rename 提交;中途失败则把已提交的目标回滚成旧内容。
741
+ const committed = []
742
+ try {
743
+ for (const item of items) {
744
+ renameSync(item.tempPath, item.path)
745
+ committed.push(item)
746
+ }
747
+ } catch (error) {
748
+ const failedPath = items[committed.length].path
749
+ for (const item of items) removeTemp(item)
750
+ for (const item of committed) {
751
+ try {
752
+ const previousData = previous.get(item.path)
753
+ if (previousData === undefined) rmSync(item.path, { force: true })
754
+ else writeFileSync(item.path, previousData)
755
+ } catch {}
756
+ }
757
+ throw new Error('dsh-ppt:提交三件套失败(' + failedPath + '),已回滚为原文件:' + error.message)
758
+ }
759
+ }
760
+
677
761
  export function buildDeck(options = {}) {
678
762
  const normalized = normalizeBuildOptions(options)
679
763
  const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
@@ -693,9 +777,12 @@ export function buildDeck(options = {}) {
693
777
  const htmlPath = resolvePath(outputDir, fileName + '.html')
694
778
  const pptxPath = resolvePath(outputDir, fileName + '.pptx')
695
779
 
696
- writeFileSync(jsonPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
697
- writeFileSync(htmlPath, renderHtml(manifest, theme, language), 'utf8')
698
- writeFileSync(pptxPath, buildPptx(manifest, theme, language))
780
+ // 先生成三份内容再统一提交:生成阶段抛错时也不会留下半套文件。
781
+ commitDeckArtifacts([
782
+ { path: jsonPath, data: JSON.stringify(manifest, null, 2) + '\n', encoding: 'utf8' },
783
+ { path: htmlPath, data: renderHtml(manifest, theme, language), encoding: 'utf8' },
784
+ { path: pptxPath, data: buildPptx(manifest, theme, language) },
785
+ ])
699
786
 
700
787
  return {
701
788
  ok: true,
@@ -1058,6 +1145,7 @@ function paragraphXml(text, options = {}) {
1058
1145
  size = 2000,
1059
1146
  color = 'FFFFFF',
1060
1147
  bold = false,
1148
+ lang = 'zh-CN',
1061
1149
  align = 'l',
1062
1150
  bullet = false,
1063
1151
  font = 'Arial',
@@ -1076,7 +1164,7 @@ function paragraphXml(text, options = {}) {
1076
1164
  pPr = '<a:pPr' + (parts.length > 0 ? ' ' + parts.join(' ') : '') + '><a:buNone/></a:pPr>'
1077
1165
  }
1078
1166
  return '<a:p>' + pPr +
1079
- '<a:r><a:rPr lang="zh-CN" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1167
+ '<a:r><a:rPr lang="' + lang + '" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1080
1168
  '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1081
1169
  '<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(runFont) + '"/></a:rPr>' +
1082
1170
  '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p>'
@@ -1103,8 +1191,9 @@ function accentBarXml(id, name, box, color) {
1103
1191
  }
1104
1192
 
1105
1193
  /** 原生 OOXML 表格(graphicFrame + a:tbl):首行主题色表头,隔行面板色。 */
1106
- function tableGraphicXml(id, name, box, rows, theme) {
1194
+ function tableGraphicXml(id, name, box, rows, theme, langId) {
1107
1195
  const p = theme.palette
1196
+ const lang = langId || 'zh-CN'
1108
1197
  const font = pptxFontName(theme.fonts.body)
1109
1198
  const safeRows = rows.length > 0 ? rows : [['']]
1110
1199
  const cols = Math.max(1, safeRows[0].length)
@@ -1113,7 +1202,7 @@ function tableGraphicXml(id, name, box, rows, theme) {
1113
1202
  const rowH = 420000
1114
1203
  const cellXml = (text, fill, color, bold) =>
1115
1204
  '<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
1116
- '<a:r><a:rPr lang="zh-CN" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1205
+ '<a:r><a:rPr lang="' + lang + '" sz="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
1117
1206
  '<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
1118
1207
  '<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(font) + '"/></a:rPr>' +
1119
1208
  '<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
@@ -1142,6 +1231,9 @@ function slideShapeList(slide, index, total, theme, langId) {
1142
1231
  const p = theme.palette
1143
1232
  const font = pptxFontName(theme.fonts.heading)
1144
1233
  const bodyFont = pptxFontName(theme.fonts.body) // 保留:v0.1 后续用于统一正文字体
1234
+ const lang = langId || 'zh-CN'
1235
+ // PPTX 每个文本 run 的语言跟随 deck.lang(zh-CN / en-US),不再写死中文
1236
+ const para = (content, options = {}) => paragraphXml(content, { lang, ...options })
1145
1237
  const shapes = []
1146
1238
  const kicker = slide.kicker || ''
1147
1239
  const title = slide.title || ''
@@ -1153,95 +1245,95 @@ function slideShapeList(slide, index, total, theme, langId) {
1153
1245
  if (slide.layout === 'cover') {
1154
1246
  shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 2250000, w: 240000, h: 1600000 }, p.accent))
1155
1247
  shapes.push(textShapeXml(idBase + 2, 'Title', { x: 1550000, y: 2160000, w: 9250000, h: 1800000 }, [
1156
- paragraphXml(title, { size: 4400, color: p.fg, bold: true, font }),
1248
+ para(title, { size: 4400, color: p.fg, bold: true, font }),
1157
1249
  ]))
1158
1250
  if (subtitle !== '') {
1159
1251
  shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1570000, y: 4150000, w: 9000000, h: 1200000 }, [
1160
- paragraphXml(subtitle, { size: 2200, color: p.muted, font: theme.fonts.body }),
1252
+ para(subtitle, { size: 2200, color: p.muted, font: theme.fonts.body }),
1161
1253
  ]))
1162
1254
  }
1163
1255
  if (kicker !== '') {
1164
1256
  shapes.push(textShapeXml(idBase + 4, 'Kicker', { x: 1570000, y: 5800000, w: 7000000, h: 500000 }, [
1165
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1257
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1166
1258
  ]))
1167
1259
  }
1168
1260
  } else if (slide.layout === 'section') {
1169
1261
  if (kicker !== '') {
1170
1262
  shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 1050000, y: 2250000, w: 9000000, h: 500000 }, [
1171
- paragraphXml(kicker, { size: 1600, color: p.accent, bold: true, font: theme.fonts.body }),
1263
+ para(kicker, { size: 1600, color: p.accent, bold: true, font: theme.fonts.body }),
1172
1264
  ]))
1173
1265
  }
1174
1266
  shapes.push(accentBarXml(idBase + 2, 'Accent bar', { x: 1050000, y: 2850000, w: 1600000, h: 160000 }, p.accent2))
1175
1267
  shapes.push(textShapeXml(idBase + 3, 'Title', { x: 1050000, y: 3150000, w: 10200000, h: 1400000 }, [
1176
- paragraphXml(title, { size: 4000, color: p.fg, bold: true, font }),
1268
+ para(title, { size: 4000, color: p.fg, bold: true, font }),
1177
1269
  ]))
1178
1270
  if (subtitle !== '') {
1179
1271
  shapes.push(textShapeXml(idBase + 4, 'Subtitle', { x: 1070000, y: 4750000, w: 9000000, h: 900000 }, [
1180
- paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1272
+ para(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1181
1273
  ]))
1182
1274
  }
1183
1275
  } else if (slide.layout === 'statement') {
1184
1276
  shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 1900000, w: 200000, h: 2800000 }, p.accent))
1185
1277
  shapes.push(textShapeXml(idBase + 2, 'Statement', { x: 1500000, y: 1950000, w: 9400000, h: 2700000 }, [
1186
- paragraphXml(title || slide.text || '', { size: 3600, color: p.fg, bold: true, font }),
1278
+ para(title || slide.text || '', { size: 3600, color: p.fg, bold: true, font }),
1187
1279
  ]))
1188
1280
  if (subtitle !== '') {
1189
1281
  shapes.push(textShapeXml(idBase + 3, 'Subtitle', { x: 1520000, y: 4900000, w: 9000000, h: 800000 }, [
1190
- paragraphXml(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1282
+ para(subtitle, { size: 1800, color: p.muted, font: theme.fonts.body }),
1191
1283
  ]))
1192
1284
  }
1193
1285
  } else if (slide.layout === 'quote') {
1194
1286
  shapes.push(textShapeXml(idBase + 1, 'Quote mark', { x: 900000, y: 1050000, w: 1700000, h: 1900000 }, [
1195
- paragraphXml('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1287
+ para('\u201C', { size: 9600, color: p.accent, bold: true, font }),
1196
1288
  ]))
1197
1289
  shapes.push(textShapeXml(idBase + 2, 'Quote', { x: 1050000, y: 2550000, w: 10100000, h: 2600000 }, [
1198
- paragraphXml(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1290
+ para(title || slide.text || '', { size: 3000, color: p.fg, bold: true, font }),
1199
1291
  ]))
1200
1292
  if (subtitle !== '') {
1201
1293
  shapes.push(textShapeXml(idBase + 3, 'Attribution', { x: 1070000, y: 5300000, w: 9000000, h: 600000 }, [
1202
- paragraphXml('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1294
+ para('\u2014\u2014 ' + subtitle, { size: 1600, color: p.muted, font: theme.fonts.body }),
1203
1295
  ]))
1204
1296
  }
1205
1297
  } else if (slide.layout === 'table') {
1206
1298
  const hasTitle = title !== '' && title !== kicker
1207
1299
  if (kicker !== '') {
1208
1300
  shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1209
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1301
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1210
1302
  ]))
1211
1303
  }
1212
1304
  if (hasTitle) {
1213
1305
  shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1214
- paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1306
+ para(title, { size: 3400, color: p.fg, bold: true, font }),
1215
1307
  ]))
1216
1308
  }
1217
- shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme))
1309
+ shapes.push(tableGraphicXml(idBase + 3, 'Table', { x: 900000, y: hasTitle ? 1950000 : 900000, w: 10300000 }, Array.isArray(slide.rows) ? slide.rows : [], theme, lang))
1218
1310
  } else if (slide.layout === 'closing') {
1219
1311
  shapes.push(textShapeXml(idBase + 1, 'Title', { x: 1050000, y: 2300000, w: 10200000, h: 1700000 }, [
1220
- paragraphXml(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
1312
+ para(title || '谢谢', { size: 5200, color: p.fg, bold: true, align: 'ctr', font }),
1221
1313
  ]))
1222
1314
  if (subtitle !== '') {
1223
1315
  shapes.push(textShapeXml(idBase + 2, 'Subtitle', { x: 1050000, y: 4200000, w: 10200000, h: 900000 }, [
1224
- paragraphXml(subtitle, { size: 2000, color: p.accent, bold: true, align: 'ctr', font: theme.fonts.body }),
1316
+ para(subtitle, { size: 2000, color: p.accent, bold: true, align: 'ctr', font: theme.fonts.body }),
1225
1317
  ]))
1226
1318
  }
1227
1319
  } else {
1228
1320
  // bullets(默认布局)
1229
1321
  if (kicker !== '') {
1230
1322
  shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
1231
- paragraphXml(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1323
+ para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
1232
1324
  ]))
1233
1325
  }
1234
1326
  shapes.push(textShapeXml(idBase + 2, 'Title', { x: 900000, y: 920000, w: 10300000, h: 800000 }, [
1235
- paragraphXml(title, { size: 3400, color: p.fg, bold: true, font }),
1327
+ para(title, { size: 3400, color: p.fg, bold: true, font }),
1236
1328
  ]))
1237
1329
  const bodyParagraphs = bullets.length > 0
1238
- ? bullets.map((bullet) => paragraphXml(bullet, { size: 2000, color: p.fg, bullet: true, font: theme.fonts.body, spaceAfter: 600 }))
1239
- : [paragraphXml(subtitle || '', { size: 2000, color: p.fg, font: theme.fonts.body })]
1330
+ ? bullets.map((bullet) => para(bullet, { size: 2000, color: p.fg, bullet: true, font: theme.fonts.body, spaceAfter: 600 }))
1331
+ : [para(subtitle || '', { size: 2000, color: p.fg, font: theme.fonts.body })]
1240
1332
  shapes.push(textShapeXml(idBase + 3, 'Body', { x: 1050000, y: 1850000, w: 10100000, h: 4500000 }, bodyParagraphs))
1241
1333
  }
1242
1334
 
1243
1335
  shapes.push(textShapeXml(idBase + 9, 'Page number', { x: 10600000, y: 6250000, w: 1200000, h: 400000 }, [
1244
- paragraphXml(footer, { size: 1000, color: p.muted, align: 'r', font: theme.fonts.body }),
1336
+ para(footer, { size: 1000, color: p.muted, align: 'r', font: theme.fonts.body }),
1245
1337
  ]))
1246
1338
 
1247
1339
  return shapes.join('')
@@ -1366,10 +1458,11 @@ function notesMasterRelXml() {
1366
1458
  '</Relationships>'
1367
1459
  }
1368
1460
 
1369
- function notesSlideXml(notes) {
1461
+ function notesSlideXml(notes, langId) {
1462
+ const lang = langId || 'zh-CN'
1370
1463
  const paragraphs = String(notes ?? '').split('\n')
1371
1464
  .filter((line) => line.trim() !== '')
1372
- .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>')
1465
+ .map((line) => '<a:p><a:r><a:rPr lang="' + lang + '" sz="1200" dirty="0"/><a:t>' + escapeXml(line) + '</a:t></a:r></a:p>')
1373
1466
  .join('')
1374
1467
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1375
1468
  '<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
@@ -1635,10 +1728,10 @@ export function buildPptx(manifest, themeInput, languageInput) {
1635
1728
  }
1636
1729
  slides.forEach((slide, index) => {
1637
1730
  const hasNotes = noteIndexes.includes(index)
1638
- entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id, motion) })
1731
+ entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.attr, motion) })
1639
1732
  entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1640
1733
  if (hasNotes) {
1641
- entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes) })
1734
+ entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes, language.attr) })
1642
1735
  entries.push({ name: 'ppt/notesSlides/_rels/notesSlide' + (index + 1) + '.xml.rels', data: notesSlideRelXml(index + 1) })
1643
1736
  }
1644
1737
  })