dsh-ppt-studio 1.1.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/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- package/templates/tech-dark/template.yaml +14 -0
package/lib/templates.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模板库:PPT 工作室内置模板(templates/<id>/,随插件分发)。
|
|
3
|
+
* 模板 = 版式母版包:template.yaml 元数据 + deck.yaml(完整 theme)+ pages/_*.yaml 母版页
|
|
4
|
+
* + preview.png(脚本生成)。参考优先级(2026-09-18 用户政策):用户给的模板/既有 ppt > (用户明确要求时的)内置模板 >
|
|
5
|
+
* 按题材自己设计风格(默认路径,内置模板不作兜底)。
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, readdir, rm } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { join, dirname, resolve, sep } from 'node:path'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import YAML from 'yaml'
|
|
13
|
+
|
|
14
|
+
export const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 用户层模板目录:`<dshHome>/ppt-studio/templates`。
|
|
18
|
+
*
|
|
19
|
+
* 为什么必须有这一层(2026-09-18 用户提出"每人维护自己的模板库"时发现的坑):
|
|
20
|
+
* 原先 `registerTemplate` 把收纳的模板写进 `TEMPLATES_DIR`,而那是**插件包自己的目录**——
|
|
21
|
+
* 插件装在 `profiles/web/node_modules/` 下,下次 `dsh plugin add <新资产>` 时 pnpm 会重新物化包,
|
|
22
|
+
* **用户自己攒的模板会被整目录覆盖掉**。用户层目录在 node_modules 之外,升级不碰。
|
|
23
|
+
*
|
|
24
|
+
* **每次调用求值**(故意不做成模块常量):测试/CI 可以把 `DSH_HOME` 指到临时目录来验证这一层,
|
|
25
|
+
* 而常量会在 import 时就定死、无法覆盖。
|
|
26
|
+
*/
|
|
27
|
+
export function userTemplatesDir() {
|
|
28
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
29
|
+
return join(home, 'ppt-studio', 'templates')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 按 id 找模板目录:**用户层优先**(同名时用户覆盖随包),再找随包层。找不到返回 null。 */
|
|
33
|
+
export function templateDirOf(id) {
|
|
34
|
+
for (const base of [userTemplatesDir(), TEMPLATES_DIR]) {
|
|
35
|
+
const d = join(base, id)
|
|
36
|
+
if (existsSync(join(d, 'deck.yaml'))) return d
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读"要动文本手术"的文件时统一归一:去 BOM + CRLF→LF。
|
|
43
|
+
*
|
|
44
|
+
* 为什么必须做(2026-09-18 实测,创意工坊 PR 审核反馈的根因之一):
|
|
45
|
+
* 本模块用 `/pages:\n[\s\S]*$/` 这类**行锚定正则**改写 deck.yaml。JS 的 `.` 不吃 `\r`,
|
|
46
|
+
* 而 `$`(m 模式)只落在 `\n` 前——所以 CRLF 文本上这些正则**一条都不匹配**,
|
|
47
|
+
* `.replace()` 静默返回原文(不报错)。后果分两种,都很难查:
|
|
48
|
+
* - materializeTemplate:新工作区 deck.yaml 仍引用 `pages/_cover.yaml`,而该页**故意没被复制**
|
|
49
|
+
* (它是被"正式化"的首母版)→ resolveDeck 抛 `page file missing: pages/_cover.yaml`,
|
|
50
|
+
* 整条自检在干净克隆里直接崩(Windows + Git for Windows 默认 core.autocrlf=true 就会这样)。
|
|
51
|
+
* - registerTemplate:收纳出来的模板 deck.yaml 的 pages 段没被替换 → 引用一堆不存在的页。
|
|
52
|
+
* 为什么本机一直没暴露:仓库工作区是脚本写出来的 LF(`core.autocrlf=true` 只在**检出**时转换),
|
|
53
|
+
* 而**干净克隆**是检出——于是"本机全绿、用户全崩"。
|
|
54
|
+
* 防线是两条一起:`.gitattributes`(`* text=auto eol=lf`)保证 git 安装与 tgz 安装同字节;
|
|
55
|
+
* 这里保证**用户手改过的 CRLF/BOM 文件**(Windows 记事本默认加 BOM)也不会静默失效。
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeText(raw) {
|
|
58
|
+
return String(raw).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把 deck.yaml 的 `pages:` 段(至文件末尾)整体换成给定页清单。
|
|
63
|
+
* **不匹配就抛**:这条改写过去是静默的——CRLF(见 normalizeText)或缺少 pages: 段时
|
|
64
|
+
* `.replace()` 原样返回,产物是一个"引用了不存在页"的 deck,错误推迟到 resolveDeck 才炸,
|
|
65
|
+
* 且报的是"页文件缺失"(离真因隔了一层)。宁可当场报出真因。
|
|
66
|
+
*/
|
|
67
|
+
function rewritePagesSection(text, refs) {
|
|
68
|
+
const re = /^pages:\n[\s\S]*$/m
|
|
69
|
+
if (!re.test(text)) throw new Error('deck.yaml 缺少顶格 `pages:` 段——无法改写页清单(模板 deck.yaml 必须含 pages:)')
|
|
70
|
+
return text.replace(re, `pages:\n${refs.map((r) => ` - ${r}`).join('\n')}\n`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 模板清单(轻量元数据,不读母版页主体)。
|
|
75
|
+
* 合并两层并标注来源:`source: 'user'`(用户自建,可删)/ `'bundled'`(随插件分发,删了升级会回来)。
|
|
76
|
+
* **用户层优先**:同名 id 覆盖随包同名模板,并在条目上标 `shadowsBundled`(工具会如实说明)。
|
|
77
|
+
*/
|
|
78
|
+
export async function listTemplates() {
|
|
79
|
+
const out = []
|
|
80
|
+
const seen = new Set()
|
|
81
|
+
for (const [base, source] of [[userTemplatesDir(), 'user'], [TEMPLATES_DIR, 'bundled']]) {
|
|
82
|
+
if (!existsSync(base)) continue
|
|
83
|
+
for (const id of (await readdir(base, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
84
|
+
if (seen.has(id)) continue
|
|
85
|
+
const metaFile = join(base, id, 'template.yaml')
|
|
86
|
+
if (!existsSync(metaFile)) continue
|
|
87
|
+
try {
|
|
88
|
+
const meta = YAML.parse(await readFile(metaFile, 'utf8')) ?? {}
|
|
89
|
+
const dir = join(base, id)
|
|
90
|
+
const shadowsBundled = source === 'user' && existsSync(join(TEMPLATES_DIR, id, 'template.yaml'))
|
|
91
|
+
seen.add(id)
|
|
92
|
+
out.push({
|
|
93
|
+
id,
|
|
94
|
+
source,
|
|
95
|
+
dir,
|
|
96
|
+
...(shadowsBundled ? { shadowsBundled: true } : {}),
|
|
97
|
+
name: meta.name ?? id,
|
|
98
|
+
style: meta.style ?? '',
|
|
99
|
+
scene: meta.scene ?? '',
|
|
100
|
+
desc: meta.desc ?? '',
|
|
101
|
+
words: meta.words ?? '',
|
|
102
|
+
colors: meta.colors ?? [],
|
|
103
|
+
preview: existsSync(join(dir, 'preview.png')) ? join(dir, 'preview.png') : null,
|
|
104
|
+
pages: meta.pages ?? [],
|
|
105
|
+
})
|
|
106
|
+
} catch { /* 坏模板跳过 */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 模板详情(模板工作区复制用):deck.yaml 全文 + 母版页 {ref, yaml} + 媒体清单。两层里按 id 找(用户层优先)。 */
|
|
113
|
+
export async function templateWorkspace(id) {
|
|
114
|
+
const dir = templateDirOf(id)
|
|
115
|
+
if (!dir) throw new Error(`模板不存在:${id}(可用 ppt_templates 查看列表)`)
|
|
116
|
+
const deckFile = join(dir, 'deck.yaml')
|
|
117
|
+
const deck = normalizeText(await readFile(deckFile, 'utf8'))
|
|
118
|
+
const metaFile = join(dir, 'template.yaml')
|
|
119
|
+
const meta = existsSync(metaFile) ? (YAML.parse(await readFile(metaFile, 'utf8')) ?? {}) : {}
|
|
120
|
+
const pages = []
|
|
121
|
+
for (const ref of meta.pages ?? []) {
|
|
122
|
+
const p = join(dir, ref)
|
|
123
|
+
if (existsSync(p)) pages.push({ ref, yaml: normalizeText(await readFile(p, 'utf8')) })
|
|
124
|
+
}
|
|
125
|
+
const mediaDir = join(dir, 'media')
|
|
126
|
+
const media = existsSync(mediaDir) ? (await import('node:fs/promises').then((f) => f.readdir(mediaDir))).filter((n) => !n.startsWith('.')) : []
|
|
127
|
+
return { id, dir, meta, deck, pages, media }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 模板收纳(ppt_template_add):把任意 deck 工程(通常是 ppt_import 产物)收纳为模板包。
|
|
132
|
+
* - 模板主题:取工程 theme(colors/textStyles/safeArea/grid/minFontSize 原样保留)。
|
|
133
|
+
* - 模板页面:工程全部页面复制为 pages/_NN_<name>.yaml(内容保留作版式参照);media/ 一并复制。
|
|
134
|
+
* - 缩略图:第一页 render + Edge 截图 → preview.png。
|
|
135
|
+
* - 防呆:工程必须可 resolveDeck;id 冲突自动 -2/-3;**拒绝写进随包目录**(升级会覆盖 ⇒ 用户白攒)。
|
|
136
|
+
* @param dir 源工程目录(含 deck.yaml)
|
|
137
|
+
* @param opts {id, name, style, scene, desc, words, sourcePptx}
|
|
138
|
+
* @param opts.targetDir 目标层目录(**缺省 = 用户层** `<dshHome>/ppt-studio/templates`;测试可覆盖)
|
|
139
|
+
*/
|
|
140
|
+
export async function registerTemplate(dir, opts = {}, { targetDir = userTemplatesDir() } = {}) {
|
|
141
|
+
const { resolveDeck } = await import('./pptd/schema.js')
|
|
142
|
+
const { renderDeck } = await import('./pptd/render-html.js')
|
|
143
|
+
const { writeFile, readFile, copyFile, mkdir, rm } = await import('node:fs/promises')
|
|
144
|
+
// 防呆:随包目录只读。写进去等于"用户攒的模板下次升级就没了"——直接拦下并告诉他去哪儿写。
|
|
145
|
+
const absTarget = resolve(targetDir)
|
|
146
|
+
const absBundled = resolve(TEMPLATES_DIR)
|
|
147
|
+
if (absTarget === absBundled || absTarget.startsWith(absBundled + sep)) {
|
|
148
|
+
throw new Error(`拒绝把模板写进随包目录(${targetDir}):插件升级会覆盖它,用户模板会丢;请用用户层目录(缺省即是):${userTemplatesDir()}`)
|
|
149
|
+
}
|
|
150
|
+
const ctx = await resolveDeck(dir) // 校验 + 归一化(读时快照)
|
|
151
|
+
if (ctx.pages.length === 0) throw new Error('工程没有页面,无法收纳')
|
|
152
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '') || 'template'
|
|
153
|
+
let id = slug(opts.id ?? opts.name ?? ctx.deck.title ?? 'template')
|
|
154
|
+
let n = 2
|
|
155
|
+
while (existsSync(join(targetDir, id))) { id = `${id}-${n++}` }
|
|
156
|
+
const tplDir = join(targetDir, id)
|
|
157
|
+
const pagesDir = join(tplDir, 'pages')
|
|
158
|
+
await mkdir(pagesDir, { recursive: true })
|
|
159
|
+
// 母版页复制(保留内容作为版式参照)
|
|
160
|
+
const refs = []
|
|
161
|
+
for (const [i, p] of ctx.pages.entries()) {
|
|
162
|
+
const base = `_${String(i + 1).padStart(2, '0')}_${p.name.replace(/[^\w\u4e00-\u9fff-]+/g, '_').slice(0, 24) || 'page'}.yaml`
|
|
163
|
+
const ref = `pages/${base}`
|
|
164
|
+
await copyFile(join(dir, p.ref), join(tplDir, ref))
|
|
165
|
+
refs.push(ref)
|
|
166
|
+
}
|
|
167
|
+
// media(页面图/背景图引用)
|
|
168
|
+
if (existsSync(join(dir, 'media'))) {
|
|
169
|
+
await cp(join(dir, 'media'), pagesDir === '' ? tplDir : join(tplDir, 'media'))
|
|
170
|
+
}
|
|
171
|
+
// 模板 deck.yaml:引用全部母版页(保留原 theme;pages 段整体替换,不动前导换行)
|
|
172
|
+
// 必须先 normalizeText:CRLF 上 `/pages:\n[\s\S]*$/` 静默不匹配(用户手改过的 deck.yaml 就是 CRLF)
|
|
173
|
+
const deckTxt = rewritePagesSection(normalizeText(await readFile(join(dir, 'deck.yaml'), 'utf8')), refs)
|
|
174
|
+
await writeFile(join(tplDir, 'deck.yaml'), deckTxt)
|
|
175
|
+
// 收纳后自动声明清理(外部模板 = 参考资产:视觉叠层/页脚出血是有意的原始设计 → 批量声明;
|
|
176
|
+
// 剩余错误按类型统计记入 meta.cleanup,供使用者预知)
|
|
177
|
+
let cleanup = { declared: 0, outSafe: 0, remainingErrors: 0, detail: {} }
|
|
178
|
+
try {
|
|
179
|
+
const { renderDeck } = await import('./pptd/render-html.js')
|
|
180
|
+
const { verifyDeck, collectDeclarable } = await import('./verify.js')
|
|
181
|
+
const { applyAutoDeclare } = await import('./autodeclare.js')
|
|
182
|
+
const YAML = await import('yaml').then((m) => m.default)
|
|
183
|
+
// ① 出界声明:模板页超出安全区的元素(页脚带/出血/占位)批量写入 expectedOutOfSafeArea(有意设计)
|
|
184
|
+
const ctxA = await resolveDeck(tplDir)
|
|
185
|
+
const rA = await renderDeck(ctxA, { out: '_cleanup-tmp' })
|
|
186
|
+
let outSafe = 0
|
|
187
|
+
for (const pageL of rA.layout.pages) {
|
|
188
|
+
const sa = pageL.safeArea ?? null
|
|
189
|
+
if (!sa) continue
|
|
190
|
+
const outer = pageL.elements.filter((e) => {
|
|
191
|
+
const b = e.bounds
|
|
192
|
+
return b.x < sa.left - 1 || b.y < sa.top - 1 || b.x + b.w > ctxA.size.width - sa.right + 1 || b.y + b.h > ctxA.size.height - sa.bottom + 1
|
|
193
|
+
})
|
|
194
|
+
if (!outer.length) continue
|
|
195
|
+
const file = ctxA.pages[pageL.index].file
|
|
196
|
+
const text = await readFile(file, 'utf8')
|
|
197
|
+
const doc = YAML.parseDocument(text)
|
|
198
|
+
const add = outer.map((e) => e.id)
|
|
199
|
+
// yaml@2:doc.get() 返回 AST 节点(toJS 裸调用抛 "A document argument is required")→ doc.toJS() 归一
|
|
200
|
+
const data = doc.toJS() ?? {}
|
|
201
|
+
const list = Array.isArray(data.expectedOutOfSafeArea) ? data.expectedOutOfSafeArea : []
|
|
202
|
+
const all = [...list.map(String), ...add]
|
|
203
|
+
doc.setIn(['expectedOutOfSafeArea'], doc.createNode([...new Set(all)]))
|
|
204
|
+
await writeFile(file, String(doc))
|
|
205
|
+
outSafe += add.length
|
|
206
|
+
}
|
|
207
|
+
// ② 重叠声明 + 分类统计
|
|
208
|
+
const ctx2 = await resolveDeck(tplDir)
|
|
209
|
+
const r2 = await renderDeck(ctx2, { out: '_cleanup-tmp' })
|
|
210
|
+
cleanup.declared = (await applyAutoDeclare(ctx2, r2.layout)).reduce((n, a) => n + a.added, 0)
|
|
211
|
+
const ctx3 = await resolveDeck(tplDir)
|
|
212
|
+
const r3 = await renderDeck(ctx3, { out: '_cleanup-tmp' })
|
|
213
|
+
const v3 = verifyDeck(r3.layout)
|
|
214
|
+
const detail = {}
|
|
215
|
+
for (const line of v3.text.split('\n').filter((l) => l.includes('[✗]'))) {
|
|
216
|
+
const code = (line.match(/\[✗\] (\w+)/) ?? [])[1] ?? 'other'
|
|
217
|
+
detail[code] = (detail[code] ?? 0) + 1
|
|
218
|
+
}
|
|
219
|
+
cleanup = { declared: cleanup.declared, outSafe, remainingErrors: Object.values(detail).reduce((a, b) => a + b, 0), detail }
|
|
220
|
+
} catch { /* 清理失败不阻塞收纳(模板仍可用) */
|
|
221
|
+
} finally {
|
|
222
|
+
await rm(join(tplDir, '_cleanup-tmp'), { recursive: true, force: true }).catch(() => {})
|
|
223
|
+
}
|
|
224
|
+
const cleanupNote = [
|
|
225
|
+
cleanup.outSafe ? `已声明 ${cleanup.outSafe} 个出界元素为模板有意设计` : '',
|
|
226
|
+
cleanup.declared ? `已声明 ${cleanup.declared} 对预期重叠` : '',
|
|
227
|
+
cleanup.remainingErrors ? `剩余 ${cleanup.remainingErrors} 条(${Object.entries(cleanup.detail).map(([k, v]) => `${k}×${v}`).join('、')}——模板固有内容问题,复制工作区后按 verify 修复)` : '',
|
|
228
|
+
].filter(Boolean).join(';') || '模板已洁净'
|
|
229
|
+
// 双轨真相层 v0.9.0:保留原始 pptx → template.pptx,Office 真渲染整页 → previews/NN.png
|
|
230
|
+
//(骨架层 deck.yaml 机器可验证;真相层零失真供视觉参考——"按模板做" = "参考用户给的 ppt 制作")
|
|
231
|
+
let srcPptx = null
|
|
232
|
+
for (const p of [opts.sourcePptx, join(dir, 'source.pptx')]) {
|
|
233
|
+
if (p && existsSync(p)) {
|
|
234
|
+
srcPptx = join(tplDir, 'template.pptx')
|
|
235
|
+
await copyFile(p, srcPptx)
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
let previewsDir = null
|
|
240
|
+
if (srcPptx) {
|
|
241
|
+
try {
|
|
242
|
+
const { findPowerPoint, renderPptxToPng } = await import('./msrender.js')
|
|
243
|
+
if (findPowerPoint()) {
|
|
244
|
+
previewsDir = join(tplDir, 'previews')
|
|
245
|
+
const r = await renderPptxToPng(srcPptx, previewsDir, { timeoutMs: 300000 })
|
|
246
|
+
if (!r.pages) throw new Error('渲染结果为空')
|
|
247
|
+
}
|
|
248
|
+
} catch { /* 无 Office / 渲染失败:真相层缺参考页不阻塞收纳(骨架层仍可用) */
|
|
249
|
+
if (previewsDir) await rm(previewsDir, { recursive: true, force: true }).catch(() => {})
|
|
250
|
+
previewsDir = null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// template.yaml 元数据
|
|
254
|
+
const meta = {
|
|
255
|
+
id,
|
|
256
|
+
name: opts.name ?? ctx.deck.title ?? id,
|
|
257
|
+
style: opts.style ?? '外部导入模板',
|
|
258
|
+
scene: opts.scene ?? '',
|
|
259
|
+
desc: opts.desc ?? '由用户导入的 deck 工程收纳(主题/版式取自原工程;可编辑后复用)',
|
|
260
|
+
words: opts.words ?? '',
|
|
261
|
+
colors: Object.values(ctx.colors ?? {}).filter((c) => typeof c === 'string'),
|
|
262
|
+
pages: refs,
|
|
263
|
+
cleanup: cleanupNote,
|
|
264
|
+
...(srcPptx ? { sourcePptx: 'template.pptx' } : {}),
|
|
265
|
+
...(previewsDir ? { previews: 'previews' } : {}),
|
|
266
|
+
}
|
|
267
|
+
await writeFile(join(tplDir, 'template.yaml'), YAML.stringify(meta))
|
|
268
|
+
// 缩略图:从**模板目录**自身渲染(ctx.dir=tplDir,out 相对名)+ Edge 截图 → preview.png
|
|
269
|
+
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
270
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
271
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
272
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'].find((p) => existsSync(p))
|
|
273
|
+
let preview = null
|
|
274
|
+
try {
|
|
275
|
+
const ctxT = await resolveDeck(tplDir)
|
|
276
|
+
const r = await renderDeck(ctxT, { out: '_preview-tmp' })
|
|
277
|
+
if (edge) {
|
|
278
|
+
const { spawn } = await import('node:child_process')
|
|
279
|
+
const url = 'file:///' + join(tplDir, '_preview-tmp', r.htmlFiles[0]).replace(/\\/g, '/')
|
|
280
|
+
preview = join(tplDir, 'preview.png')
|
|
281
|
+
await new Promise((resolve, reject) => {
|
|
282
|
+
const child = spawn(edge, ['--headless=new', '--disable-gpu', '--no-first-run', '--hide-scrollbars',
|
|
283
|
+
`--window-size=${ctxT.size.width},${ctxT.size.height}`, `--screenshot=${preview}`, url], { stdio: 'ignore', windowsHide: true })
|
|
284
|
+
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Edge 退出码 ${code}`)))
|
|
285
|
+
child.on('error', reject)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
} catch (e) { /* 无浏览器/渲染失败:template.yaml 仍生成,preview 留空 */ }
|
|
289
|
+
await rm(join(tplDir, '_preview-tmp'), { recursive: true, force: true }).catch(() => {})
|
|
290
|
+
return {
|
|
291
|
+
id,
|
|
292
|
+
dir: tplDir,
|
|
293
|
+
preview,
|
|
294
|
+
pages: refs.length,
|
|
295
|
+
meta,
|
|
296
|
+
// 落在哪一层:user = 用户模板库(缺省,升级不丢);custom = 调用方指定的目录(测试/工具用)
|
|
297
|
+
layer: absTarget === resolve(userTemplatesDir()) ? 'user' : 'custom',
|
|
298
|
+
sourcePptx: srcPptx ? join(tplDir, 'template.pptx') : null,
|
|
299
|
+
previews: previewsDir ? join(tplDir, 'previews') : null,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 删除**用户自建**模板(ppt_template_remove)。
|
|
305
|
+
* 随包模板(bundled)**拒绝删除**:它随插件分发、升级会重新出现,删了只会让人困惑("删了又回来");
|
|
306
|
+
* 要"不用它"就不选它即可。用户层模板随便删。
|
|
307
|
+
*/
|
|
308
|
+
export async function removeTemplate(id, { targetDir = userTemplatesDir() } = {}) {
|
|
309
|
+
const userDir = join(targetDir, id)
|
|
310
|
+
if (existsSync(join(userDir, 'template.yaml'))) {
|
|
311
|
+
await rm(userDir, { recursive: true, force: true })
|
|
312
|
+
return { id, removed: true, dir: userDir }
|
|
313
|
+
}
|
|
314
|
+
if (existsSync(join(TEMPLATES_DIR, id, 'template.yaml'))) {
|
|
315
|
+
throw new Error(`「${id}」是随包模板(随插件分发,升级会重新出现),不能删。不想用它就别选它;同名覆盖请用别的 id 收纳你自己的版本`)
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`模板库里没有「${id}」(可用 ppt_templates 看清单)`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 从模板物化工作区(v0.7.0:参考素材与正式页分离):
|
|
322
|
+
* - 母版页 pages/_*.yaml:参考素材,**不注册进 deck.pages**(不进 render/verify 门禁);
|
|
323
|
+
* - 正式页 pages/01_opening.yaml:模板第一母版的正式化副本,**注册进门禁**(先 autoDeclare 声明模板固有叠层,
|
|
324
|
+
* 剩余为模板原文案残留,替换后自然干净);
|
|
325
|
+
* - media/ 跟随复制(外部模板含图片)。
|
|
326
|
+
* @returns { dir, formal, refs, mediaCount, cleanup }
|
|
327
|
+
*/
|
|
328
|
+
export async function materializeTemplate(dir, id, { name } = {}) {
|
|
329
|
+
const { writeFile, mkdir, copyFile, access } = await import('node:fs/promises')
|
|
330
|
+
try {
|
|
331
|
+
await access(join(dir, 'deck.yaml'))
|
|
332
|
+
throw new Error(`目标目录已存在 deck.yaml(${join(dir, 'deck.yaml')}),拒绝覆盖;请换一个空目录`)
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if ((e?.message ?? '').includes('拒绝覆盖')) throw e
|
|
335
|
+
// access 失败(目录不存在):放行
|
|
336
|
+
}
|
|
337
|
+
const t = await templateWorkspace(id)
|
|
338
|
+
await mkdir(join(dir, 'pages'), { recursive: true })
|
|
339
|
+
await mkdir(join(dir, 'media'), { recursive: true })
|
|
340
|
+
const first = t.pages[0]
|
|
341
|
+
const formal = 'pages/01_opening.yaml'
|
|
342
|
+
const firstYaml = first ? first.yaml.replace(/^pageType:.*$/m, 'pageType: content') : 'pageType: content\nelements: []\n'
|
|
343
|
+
await writeFile(join(dir, formal), firstYaml)
|
|
344
|
+
// 双轨真相层 v0.9.0:referenceTemplate 注入(创作前"看模板真身";与参考用户 ppt 制作等价)
|
|
345
|
+
const tplMeta = t.meta ?? {}
|
|
346
|
+
let refBlock = ''
|
|
347
|
+
const refDirSrc = t.dir
|
|
348
|
+
const hasTruth = existsSync(join(refDirSrc, 'template.pptx')) || existsSync(join(refDirSrc, 'previews')) || tplMeta.styleAudit
|
|
349
|
+
if (hasTruth) {
|
|
350
|
+
const lines = ['referenceTemplate:', ` id: ${JSON.stringify(id)}`, ` name: ${JSON.stringify(name ?? tplMeta.name ?? id)}`]
|
|
351
|
+
if (existsSync(join(refDirSrc, 'template.pptx'))) lines.push(' source: reference/template.pptx')
|
|
352
|
+
if (existsSync(join(refDirSrc, 'previews'))) {
|
|
353
|
+
const pvs = (await readdir(join(refDirSrc, 'previews'))).filter((f) => /\.png$/i.test(f)).sort()
|
|
354
|
+
if (pvs.length) lines.push(' previews:', ...pvs.map((f) => ` - reference/previews/${f}`))
|
|
355
|
+
}
|
|
356
|
+
if (tplMeta.styleAudit) lines.push(' audit: reference/audit.yaml')
|
|
357
|
+
if (lines.length > 3) refBlock = '\n' + lines.join('\n') + '\n'
|
|
358
|
+
}
|
|
359
|
+
const deck = normalizeText(t.deck)
|
|
360
|
+
.replace(/^title:.*$/m, `title: ${JSON.stringify(name ?? tplMeta.name ?? '未命名')}`)
|
|
361
|
+
const deckWithPages = rewritePagesSection(deck, [formal])
|
|
362
|
+
await writeFile(join(dir, 'deck.yaml'), deckWithPages + refBlock)
|
|
363
|
+
const refs = []
|
|
364
|
+
for (const p of t.pages) {
|
|
365
|
+
if (p.ref === first.ref) continue // 首母版已正式化
|
|
366
|
+
await writeFile(join(dir, p.ref), p.yaml)
|
|
367
|
+
refs.push(p.ref)
|
|
368
|
+
}
|
|
369
|
+
let mediaCount = 0
|
|
370
|
+
for (const m of t.media) {
|
|
371
|
+
await copyFile(join(t.dir, 'media', m), join(dir, 'media', m))
|
|
372
|
+
mediaCount++
|
|
373
|
+
}
|
|
374
|
+
// 真相层参考拷贝:reference/{template.pptx,previews/,audit.yaml}(工作区自包含,模板更新不影响已物化工作区)
|
|
375
|
+
let reference = null
|
|
376
|
+
if (hasTruth) {
|
|
377
|
+
const refDir = join(dir, 'reference')
|
|
378
|
+
await mkdir(refDir, { recursive: true })
|
|
379
|
+
const copied = []
|
|
380
|
+
if (existsSync(join(t.dir, 'template.pptx'))) {
|
|
381
|
+
await copyFile(join(t.dir, 'template.pptx'), join(refDir, 'template.pptx'))
|
|
382
|
+
copied.push('template.pptx')
|
|
383
|
+
}
|
|
384
|
+
if (existsSync(join(t.dir, 'previews'))) {
|
|
385
|
+
await cp(join(t.dir, 'previews'), join(refDir, 'previews'))
|
|
386
|
+
copied.push('previews/')
|
|
387
|
+
}
|
|
388
|
+
if (tplMeta.styleAudit) {
|
|
389
|
+
await writeFile(join(refDir, 'audit.yaml'), YAML.stringify({ ...tplMeta.styleAudit, templateId: id, templateName: tplMeta.name ?? id }))
|
|
390
|
+
copied.push('audit.yaml')
|
|
391
|
+
}
|
|
392
|
+
reference = { dir: refDir, files: copied }
|
|
393
|
+
}
|
|
394
|
+
return { dir, formal, refs, mediaCount, cleanup: tplMeta.cleanup ?? null, meta: tplMeta, firstRef: first?.ref, reference }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function cp(src, dest) {
|
|
398
|
+
const { cp } = await import('node:fs/promises')
|
|
399
|
+
await cp(src, dest, { recursive: true }).catch(() => {})
|
|
400
|
+
}
|