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/tools.js
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ppt-studio 模型工具面(首版):
|
|
3
|
+
* ppt_check / ppt_render / ppt_shot / ppt_verify / ppt_export / ppt_import
|
|
4
|
+
* / ppt_status / ppt_media
|
|
5
|
+
* 统一约定:deck 项目 = 目录(deck.yaml + pages/ + media/);路径由模型显式传。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { readFile, writeFile, mkdir, readdir, mkdtemp, rm } from 'node:fs/promises'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { pathToFileURL } from 'node:url'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import YAML from 'yaml'
|
|
14
|
+
import { resolveDeck, PptError } from './pptd/schema.js'
|
|
15
|
+
import { renderDeck } from './pptd/render-html.js'
|
|
16
|
+
import { exportPptx } from './pptd/export-pptx.js'
|
|
17
|
+
import { importPptx } from './pptd/import-pptx.js'
|
|
18
|
+
import { verifyDeck, measuredCrossCheck } from './verify.js'
|
|
19
|
+
import { SCHEMA_REF, scaffoldProject } from './scaffold.js'
|
|
20
|
+
import { applyAutoDeclare } from './autodeclare.js'
|
|
21
|
+
import { listTemplates, templateWorkspace, registerTemplate, materializeTemplate, removeTemplate, userTemplatesDir } from './templates.js'
|
|
22
|
+
import { surgicalPatch } from './surgical.js'
|
|
23
|
+
import { spliceIntoSource, sliceSource } from './splice.js'
|
|
24
|
+
import { measureLayout } from './measurement.js'
|
|
25
|
+
import { crosscheckReport } from './crosscheck.js'
|
|
26
|
+
import { buildPreview } from './preview-server.js'
|
|
27
|
+
import { findPowerPoint, renderPptxToPng } from './msrender.js'
|
|
28
|
+
import { runPythonExport, findPython } from './pptxPy.js'
|
|
29
|
+
import { imageInfo } from './imgmeta.js'
|
|
30
|
+
import { loadProjectDiag, loadSessionDiag } from './state.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 引擎解析(C1 决定,2026-09-04 用户拍板):
|
|
34
|
+
* - auto/缺省 = pptd(主引擎)优先;pptd 硬失败时**允许回退** python-pptx(报告醒目标注降级,绝不静默)。
|
|
35
|
+
* - 显式 engine=pptd / python-pptx:尊重用户显式选择,不自动回退。
|
|
36
|
+
*/
|
|
37
|
+
export function resolveEngine(engine) {
|
|
38
|
+
if (engine === 'python-pptx') return { engine: 'python-pptx', allowFallback: false }
|
|
39
|
+
if (engine === 'pptd') return { engine: 'pptd', allowFallback: false }
|
|
40
|
+
if (engine === undefined || engine === null || engine === '' || engine === 'auto') {
|
|
41
|
+
return { engine: 'pptd', allowFallback: true }
|
|
42
|
+
}
|
|
43
|
+
// 未知取值**不得静默当成 auto**(明文红线:不静默切换引擎)。
|
|
44
|
+
// 历史形状:`/ppt engine pptxgenjs` 被命令面接受并回执"已记录(待接入)",而这里把它静默映射成 pptd——
|
|
45
|
+
// 用户以为在用 pptxgenjs,实际跑的是 pptd,且报告里一个字都不提。现在带上 `unknown`,由调用方明说。
|
|
46
|
+
return { engine: 'pptd', allowFallback: true, unknown: String(engine) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** audit 质量档(C2 决定):禁用 autoDeclare,防一键声明掩盖真实问题。 */
|
|
50
|
+
export function blockedByAudit(quality) {
|
|
51
|
+
return quality === 'audit'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 预览 URL origin(发布友好,v0.6.3):完全来自当前 dsh 实例的监听地址(webServer 契约 host/port),
|
|
56
|
+
* 无任何硬编码。边界:监听 0.0.0.0/::(局域网部署)时回退 127.0.0.1(同机访问默认);远程用户
|
|
57
|
+
* 若经外部地址访问 GUI,以同源相对路径(或未来 client 面板从 location 构造 origin)为准。
|
|
58
|
+
*/
|
|
59
|
+
export function previewOrigin(ws) {
|
|
60
|
+
if (!ws || typeof ws.port !== 'number') return ''
|
|
61
|
+
let host = typeof ws.host === 'string' && ws.host ? ws.host : '127.0.0.1'
|
|
62
|
+
if (/^(0\.0\.0\.0|::|\[?\:\:\]?)$/.test(host)) host = '127.0.0.1'
|
|
63
|
+
if (/^\[.*\]$/.test(host) && host.includes('::')) return `http://${host}:${ws.port}`
|
|
64
|
+
return `http://${host}:${ws.port}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 页选择解析(v0.14.6 局部校验):'2' / '4,10' / '1,3-5' → Set<页码>(1 起);非法输入返回 null(= 全部)。
|
|
69
|
+
* 语义:局部校验只影响本次报告范围,不写入/覆盖 layout.json(避免部分 layout 带来的静默漏检)。
|
|
70
|
+
*/
|
|
71
|
+
export function parsePagesArg(s, max) {
|
|
72
|
+
if (s === undefined || s === null || String(s).trim() === '') return null
|
|
73
|
+
const set = new Set()
|
|
74
|
+
let any = false
|
|
75
|
+
for (const part of String(s).split(',')) {
|
|
76
|
+
const t = part.trim()
|
|
77
|
+
if (!t) continue
|
|
78
|
+
const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(t)
|
|
79
|
+
if (!m) return null
|
|
80
|
+
const a = Number(m[1])
|
|
81
|
+
const b = m[2] ? Number(m[2]) : a
|
|
82
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 1 || b < a) return null
|
|
83
|
+
for (let i = a; i <= b; i++) set.add(i)
|
|
84
|
+
any = true
|
|
85
|
+
}
|
|
86
|
+
if (!any) return null
|
|
87
|
+
if (max) for (const p of [...set]) if (p > max) set.delete(p)
|
|
88
|
+
return set.size ? set : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 当前质量档:项目级 state.json 优先,其次会话级(任一为 audit 即 audit)。
|
|
93
|
+
* 会话 id 取自**工具执行的 agent**(DSH 的 `execute(args, exec)` 第二参数,exec.agent.session.id)。
|
|
94
|
+
* 历史坑:曾用 `ctx.get('agent')`,但 DSH 只提供 `agents` 服务、从无 `agent` 服务——
|
|
95
|
+
* 恒为 undefined,会话级质量档/模板等设置被静默忽略(0.1.5-rc.2 适配修正)。
|
|
96
|
+
*/
|
|
97
|
+
/** 质量档 + **诊断**(2026-09-18 修):读取失败**必须可见**,绝不静默吞掉。
|
|
98
|
+
* 原来的实现是 `catch { return { quality: 'standard' } }`——审计档一旦因状态文件损坏而回落,
|
|
99
|
+
* 用户看到的是一个"正常"的 standard 轮次,而 audit 档的禁 autoDeclare / 强制视觉审阅 / 回读断言全部消失。
|
|
100
|
+
* 返回 `degraded` + `reason`,由调用方写进工具输出(诚实边界优于静默降级)。 */
|
|
101
|
+
export async function qualityOfDiag(agent, dir) {
|
|
102
|
+
const notes = []
|
|
103
|
+
let session = null
|
|
104
|
+
let proj = null
|
|
105
|
+
try {
|
|
106
|
+
const sid = agent?.session?.id ?? agent?.id
|
|
107
|
+
if (sid) {
|
|
108
|
+
const s = await loadSessionDiag(sid)
|
|
109
|
+
session = s.state
|
|
110
|
+
if (s.error) notes.push(s.error)
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
notes.push(`会话状态读取异常:${String(e?.message ?? e)}`)
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const p = await loadProjectDiag(dir)
|
|
117
|
+
proj = p.state
|
|
118
|
+
if (p.error) notes.push(p.error)
|
|
119
|
+
} catch (e) {
|
|
120
|
+
notes.push(`项目状态读取异常:${String(e?.message ?? e)}`)
|
|
121
|
+
}
|
|
122
|
+
const q = proj?.quality === 'audit' || session?.quality === 'audit' ? 'audit' : (proj?.quality ?? session?.quality ?? 'standard')
|
|
123
|
+
// engine 一并带出:`/ppt engine X` 写的是会话/项目档位,导出侧缺省时必须采纳它(见 ppt_export 注释)。
|
|
124
|
+
const engine = proj?.engine ?? session?.engine ?? 'auto'
|
|
125
|
+
return { quality: q, engine, degraded: notes.length > 0, reason: notes.join(';') }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 内联 defineTool 最小实现(零运行时依赖,生态惯例:插件自包含)。 */
|
|
129
|
+
export function defineTool(definition) {
|
|
130
|
+
return {
|
|
131
|
+
name: definition.name,
|
|
132
|
+
description: definition.description,
|
|
133
|
+
parameters: toJsonSchema(definition.parameters),
|
|
134
|
+
output: definition.output,
|
|
135
|
+
execute: definition.execute,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toJsonSchema(spec) {
|
|
140
|
+
const properties = {}
|
|
141
|
+
const required = []
|
|
142
|
+
for (const [key, meta] of Object.entries(spec || {})) {
|
|
143
|
+
const prop = { type: meta.type }
|
|
144
|
+
if (meta.description) prop.description = meta.description
|
|
145
|
+
if (meta.enum) prop.enum = meta.enum
|
|
146
|
+
if (meta.items) prop.items = meta.items
|
|
147
|
+
properties[key] = prop
|
|
148
|
+
if (meta.required) required.push(key)
|
|
149
|
+
}
|
|
150
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dirSchema = { type: 'string', required: true, description: 'deck 项目目录(含 deck.yaml)' }
|
|
154
|
+
|
|
155
|
+
function markdownResult(text) {
|
|
156
|
+
return {
|
|
157
|
+
schema: { type: 'string' },
|
|
158
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function loadCtx(dir) {
|
|
163
|
+
return resolveDeck(dir)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function registerTools(ctx) {
|
|
167
|
+
const reg = (tool) => ctx.effect(() => ctx.tools.register(defineTool(tool)), `ppt-studio: ${tool.name}`)
|
|
168
|
+
|
|
169
|
+
reg({
|
|
170
|
+
name: 'ppt_check',
|
|
171
|
+
description: '校验 deck 项目结构(deck.yaml/pages schema),返回错误清单;错误需修复后才能渲染/导出。line 元素可省略 bounds(由 points 自动推导)',
|
|
172
|
+
parameters: { dir: dirSchema },
|
|
173
|
+
output: markdownResult(),
|
|
174
|
+
async execute({ dir }) {
|
|
175
|
+
try {
|
|
176
|
+
const ctx0 = await loadCtx(dir)
|
|
177
|
+
const counts = { pages: ctx0.pages.length, elements: ctx0.pages.reduce((n, p) => n + (p.page.elements?.length ?? 0), 0) }
|
|
178
|
+
return `✓ PPTD 校验通过:${counts.pages} 页 / ${counts.elements} 元素(${join(dir, 'deck.yaml')})`
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return `✗ 校验失败:\n${errText(error)}`
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
reg({
|
|
186
|
+
name: 'ppt_schema',
|
|
187
|
+
description: 'PPTD DSL 语法速查(deck.yaml / 页面元素 / 主题 token / safeArea / 重叠声明 / 审阅约定)。不熟悉中间层语法时先调这个,避免盲写',
|
|
188
|
+
parameters: {},
|
|
189
|
+
output: markdownResult(),
|
|
190
|
+
async execute() {
|
|
191
|
+
return SCHEMA_REF
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
reg({
|
|
196
|
+
name: 'ppt_new',
|
|
197
|
+
description: '一键生成可跑通全链路的样例 deck 工程(deck.yaml + 4 个页面,含主题 token / 色块衬底 / expectedOverlaps / safeArea 示例)。已存在 deck.yaml 时拒绝覆盖。可用 template=<id> 从内置模板库复制工作区(theme + 母版页)',
|
|
198
|
+
parameters: {
|
|
199
|
+
dir: { type: 'string', required: true, description: '新工程目录(必须为空或不存在 deck.yaml)' },
|
|
200
|
+
name: { type: 'string', description: '工程标题,缺省 my-deck' },
|
|
201
|
+
template: { type: 'string', description: '内置模板 id(如 business-blue / academic-white / tech-dark / pitch-bold;缺省=示例工程)' },
|
|
202
|
+
},
|
|
203
|
+
output: markdownResult(),
|
|
204
|
+
async execute({ dir, name, template }) {
|
|
205
|
+
try {
|
|
206
|
+
if (template) {
|
|
207
|
+
// v0.7.0:母版页 = 参考素材(不进门禁);正式页 = 模板首母版副本(accept 门禁,先 autoDeclare)
|
|
208
|
+
const { mkdir } = await import('node:fs/promises')
|
|
209
|
+
await mkdir(dir, { recursive: true })
|
|
210
|
+
const w = await materializeTemplate(dir, template, { name })
|
|
211
|
+
const cleanup = w.cleanup ? `\n⚠ 模板自带清理说明:${w.cleanup}` : ''
|
|
212
|
+
const refLine = w.reference?.files?.length
|
|
213
|
+
? `\n - 参考层(双轨真相,创作前先看:read_image 看 reference/previews/*.png + 读 reference/audit.yaml——与参考用户给的 ppt 制作完全等价):${w.reference.files.join('、')}\n`
|
|
214
|
+
: ''
|
|
215
|
+
return `✓ 已从模板「${w.meta.name ?? template}」生成工作区:${dir}\n`
|
|
216
|
+
+ ` - deck.yaml(模板 theme 已就位;themeConformance=strict 模板一致性门禁;顶部 referenceTemplate 记录参考层)\n`
|
|
217
|
+
+ ` - 正式页:${w.formal}(注册进门禁;先跑 ppt_verify autoDeclare=true 声明模板固有有意叠层,剩余错误为模板原文案残留 → 替换内容后自然干净)\n`
|
|
218
|
+
+ ` - 参考母版页(_ 前缀,不注册不进门禁):${w.refs.length ? w.refs.join('、') : '(无)'}\n`
|
|
219
|
+
+ ` - 媒体:${w.mediaCount} 个(已复制)${refLine}${cleanup}\n`
|
|
220
|
+
+ `下一步:读参考层 → ppt_render → ppt_verify(autoDeclare=true)→ 替换正式页文案 → 逐页制作 → ppt_export`
|
|
221
|
+
}
|
|
222
|
+
const r = await scaffoldProject(dir, { name })
|
|
223
|
+
return `✓ 已生成样例工程:${r.dir}\n${r.files.map((f) => ` - ${f}`).join('\n')}\n下一步:ppt_check → ppt_render → ppt_verify(应 0 错误)`
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return `✗ 生成失败:${error?.message ?? String(error)}`
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
reg({
|
|
231
|
+
name: 'ppt_preview',
|
|
232
|
+
description: '生成 PPT 对话内预览(需求 A):渲染 deck → 静态服务 → 返回在线预览链接(绝对地址,点击即查看本页 + 整览),不用打开本地文件。做完一版/几页后调用,附在交付信息里',
|
|
233
|
+
parameters: {
|
|
234
|
+
dir: { type: 'string', required: true, description: 'deck 项目目录(含 deck.yaml)' },
|
|
235
|
+
},
|
|
236
|
+
output: markdownResult(),
|
|
237
|
+
async execute(args) {
|
|
238
|
+
const dir = args.dir
|
|
239
|
+
try {
|
|
240
|
+
const p = await buildPreview(dir)
|
|
241
|
+
// 绝对 URL:webServer 暴露 host/port(契约,动态适配任意部署)——不同用户的机器/端口各异,
|
|
242
|
+
// 但都来自"当前 dsh 实例"的监听地址,无任何硬编码路径/端口。
|
|
243
|
+
const ws = ctx.get('webServer')
|
|
244
|
+
const origin = previewOrigin(ws)
|
|
245
|
+
const href = (u) => origin + u
|
|
246
|
+
const note = origin
|
|
247
|
+
? ''
|
|
248
|
+
: '(预览服务未获得监听地址,改提供相对路径;在 GUI 同源访问)'
|
|
249
|
+
return [
|
|
250
|
+
`✓ 在线预览已生成(点击链接直接查看,无需打开本地文件)${note}:`,
|
|
251
|
+
'',
|
|
252
|
+
`本页预览:${href(p.url)}`,
|
|
253
|
+
`整览(全部页面):${href(p.overviewUrl)}`,
|
|
254
|
+
'',
|
|
255
|
+
`共 ${p.pages} 页;预览根:${p.previewRoot}`,
|
|
256
|
+
].join('\n')
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return `✗ 预览失败:\n${errText(error)}`
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
reg({
|
|
264
|
+
name: 'ppt_visual',
|
|
265
|
+
description: 'Office 真渲染通道(v0.8.0):用本机 PowerPoint COM 把 .pptx 逐页渲染成 PNG(1920×1080)——① 理解用户原稿(参考任务先看后做)② 成品视觉审核(导出后对 pptx 审核真实观感)。pages="15" / "15,18" 只渲染指定页(保持原页号命名;A5 反馈二)——看一页不再等整册。无 Office 时不可用(自动降级 HTML 预览+结构断言)。渲染后请逐页 read_image 查看;视觉理解写入设计摘要/交付说明',
|
|
266
|
+
parameters: {
|
|
267
|
+
pptx: { type: 'string', required: true, description: '源/成品 .pptx 绝对路径(用户原稿或 out.pptx)' },
|
|
268
|
+
out: { type: 'string', description: '输出目录(相对当前目录或绝对;缺省 <pptx 同目录>/..-rendered)' },
|
|
269
|
+
pages: { type: 'string', description: '仅渲染指定页(如 "15" / "15,18" / "1,3-5";缺省全部;页号 = 幻灯原页号)' },
|
|
270
|
+
},
|
|
271
|
+
output: markdownResult(),
|
|
272
|
+
async execute(args) {
|
|
273
|
+
try {
|
|
274
|
+
if (!findPowerPoint()) {
|
|
275
|
+
return '⚠ 本机无 Microsoft Office(PowerPoint COM 不可用):Office 真渲染通道不可用。可继续使用 HTML 预览(ppt_preview)+ 结构断言;本步骤不影响其他工作流。'
|
|
276
|
+
}
|
|
277
|
+
const pagesSel = typeof args.pages === 'string' ? parsePagesArg(args.pages) : null
|
|
278
|
+
const outDir = args.out ?? join(dirname(args.pptx), 'rendered')
|
|
279
|
+
const r = await renderPptxToPng(args.pptx, outDir, { pages: pagesSel ? [...pagesSel] : undefined })
|
|
280
|
+
if (!r.pages) throw new Error('渲染结果为空(请检查 pptx 是否可打开)')
|
|
281
|
+
return [
|
|
282
|
+
`✓ Office 真渲染完成(${r.pages} 页${pagesSel ? `,页码 [${[...pagesSel].sort((a, b) => a - b).join(', ')}]` : ''} → ${outDir}):`,
|
|
283
|
+
'',
|
|
284
|
+
...r.files.map((f) => ` - ${f}`),
|
|
285
|
+
'',
|
|
286
|
+
'下一步:逐页 read_image 查看(理解原稿/审核成品观感)。',
|
|
287
|
+
].join('\n')
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return `✗ Office 真渲染失败:${error?.message ?? String(error)}\n(可降级:ppt_preview HTML 预览 + ppt_verify 结构断言)`
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
reg({
|
|
295
|
+
name: 'ppt_templates',
|
|
296
|
+
description: '模板库列表(id/名称/风格/适用场景/预览图路径)。分两层:**用户自建**(`<dshHome>/ppt-studio/templates`,可删、升级不丢)+ **随包**(只读,不能删)。**只在用户明确要求用内置模板、或用户问"有哪些模板"时才展示**——用户没提模板时应按题材自己设计风格(见工作流 6b);选定后用 ppt_new dir=... template=<id> 或 /ppt template <id> 生成工作区',
|
|
297
|
+
parameters: {},
|
|
298
|
+
output: markdownResult(),
|
|
299
|
+
async execute() {
|
|
300
|
+
const list = await listTemplates()
|
|
301
|
+
if (!list.length) return '(模板库为空)'
|
|
302
|
+
const bundled = list.filter((t) => t.source === 'bundled')
|
|
303
|
+
const mine = list.filter((t) => t.source === 'user')
|
|
304
|
+
const body = (arr) => arr.map((t) => `## ${t.id} — ${t.name}${t.shadowsBundled ? '(**覆盖了同名随包模板**)' : ''}\n风格:${t.style}\n适用:${t.scene}\n关键词:${t.words}\n色板:${t.colors.join(' ')}\n预览图:${t.preview ?? '(未生成)'}\n`).join('\n---\n')
|
|
305
|
+
const head = `模板库(${list.length} 套:随包 ${bundled.length} + 你自己的 ${mine.length})\n\n`
|
|
306
|
+
const mineBlock = mine.length ? `# 你自己的模板(可删:ppt_template_remove id=<id>)\n\n${body(mine)}\n\n---\n\n` : '# 你自己的模板:暂无(让模型把你给的 .pptx 加进来:ppt_template_add pptx=<路径>)\n\n---\n\n'
|
|
307
|
+
const bundledBlock = bundled.length ? `# 随包模板(随插件分发,**不能删**)\n\n${body(bundled)}` : '# 随包模板:无'
|
|
308
|
+
return head + mineBlock + bundledBlock + `\n使用:ppt_new dir=<新目录> template=<id>(复制模板工作区;模板一致性断言 themeConformance=strict 默认开启)\n你的模板库目录:${userTemplatesDir()}(在插件包之外,升级不会丢)`
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
reg({
|
|
313
|
+
name: 'ppt_template_styleaudit',
|
|
314
|
+
description: '模板风格审计(双轨真相层 v0.9.0,设计时声明):对已收纳模板写入/查看 styleAudit——视觉审计产物(配色/字体/版式规则/装饰清单/审阅页)。写法:先用 read_image 逐页/抽样看该模板整页参考(previews/*.png,Office 真渲染),把视觉理解写成 YAML 传 auditYaml;此后每次"按模板做"直接读审计缓存,不必重看。不传 auditYaml = 仅查看素材/审计现状',
|
|
315
|
+
parameters: {
|
|
316
|
+
id: { type: 'string', required: true, description: '模板 id(ppt_templates 可查)' },
|
|
317
|
+
auditYaml: { type: 'string', description: '视觉审计 YAML(可选):{palette:[hex],fonts:{title,body},layout:"…",decorations:[…],pagesReviewed:[…],notes:"…"}' },
|
|
318
|
+
},
|
|
319
|
+
output: markdownResult(),
|
|
320
|
+
async execute({ id, auditYaml }) {
|
|
321
|
+
try {
|
|
322
|
+
const t = await templateWorkspace(id)
|
|
323
|
+
if (!auditYaml) {
|
|
324
|
+
const pvDir = join(t.dir, 'previews')
|
|
325
|
+
const pvs = existsSync(pvDir) ? (await readdir(pvDir)).filter((f) => /\.png$/i.test(f)).sort() : []
|
|
326
|
+
return [
|
|
327
|
+
`模板「${t.meta.name ?? id}」参考素材:`,
|
|
328
|
+
...(existsSync(join(t.dir, 'template.pptx')) ? [` - 真相层:${join(t.dir, 'template.pptx')}`] : [' - 真相层:无(内置模板无原始 pptx——YAML 即真相)']),
|
|
329
|
+
...(pvs.length ? [' - 整页参考(Office 真渲染):', ...pvs.map((f) => ` ${join(pvDir, f)}`)] : [' - 整页参考:无(无 Office 收纳/渲染失败——可用 ppt_visual 对源 pptx 补渲染)']),
|
|
330
|
+
'',
|
|
331
|
+
'当前 styleAudit:',
|
|
332
|
+
t.meta.styleAudit ? YAML.stringify(t.meta.styleAudit) : '(无——尚未审计)',
|
|
333
|
+
'',
|
|
334
|
+
'写法:read_image 看整页参考 → 视觉理解写入 auditYaml(palette/fonts/layout/decorations/pagesReviewed/notes)。',
|
|
335
|
+
].join('\n')
|
|
336
|
+
}
|
|
337
|
+
const a = YAML.parse(auditYaml)
|
|
338
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) return '✗ styleAudit 必须是 YAML 对象(palette/fonts/layout/decorations/…)'
|
|
339
|
+
const metaFile = join(t.dir, 'template.yaml')
|
|
340
|
+
const doc = YAML.parseDocument(await readFile(metaFile, 'utf8'))
|
|
341
|
+
doc.set('styleAudit', doc.createNode(a))
|
|
342
|
+
await writeFile(metaFile, String(doc))
|
|
343
|
+
return `✓ styleAudit 已写入模板「${t.meta.name ?? id}」→ ${metaFile}\n 字段:${Object.keys(a).join(', ')}\n下次 materialize 会复制为工作区 reference/audit.yaml——"按模板做"时先读它 + 看 reference/previews(与参考用户 ppt 制作完全等价)`
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return `✗ 风格审计失败:${error?.message ?? String(error)}`
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
reg({
|
|
351
|
+
name: 'ppt_template_add',
|
|
352
|
+
description: '把用户的模板/成品 PPT 加进**他自己的本地模板库**(写入 `<dshHome>/ppt-studio/templates`,在插件包之外 ⇒ **升级不丢**):直接给 .pptx 即可(内部先 import:保留原始 pptx 作真相层 + 有 Office 时整页真渲染),也接受已导入的 deck 工程目录。模板库分两层:用户层(可删)+ 随包层(只读,拒写)',
|
|
353
|
+
parameters: {
|
|
354
|
+
pptx: { type: 'string', description: '源 .pptx 绝对路径(推荐,一步到位;内部自动 import 成工程再收纳)' },
|
|
355
|
+
dir: { type: 'string', description: '已导入的 deck 工程目录(含 deck.yaml)——与 pptx 二选一' },
|
|
356
|
+
id: { type: 'string', description: '模板 id(缺省按标题 slug 化;冲突自动加后缀)' },
|
|
357
|
+
name: { type: 'string', description: '模板名称(缺省取工程标题)' },
|
|
358
|
+
style: { type: 'string', description: '风格标签(如 企业蓝/学术白)' },
|
|
359
|
+
scene: { type: 'string', description: '适用场景' },
|
|
360
|
+
sourcePptx: { type: 'string', description: '原始 .pptx 绝对路径(可选;缺省自动取工程内 source.pptx)' },
|
|
361
|
+
},
|
|
362
|
+
output: markdownResult(),
|
|
363
|
+
async execute(args) {
|
|
364
|
+
if (!args.pptx && !args.dir) return '✗ 需要 `pptx=<.pptx 绝对路径>` 或 `dir=<deck 工程目录>`'
|
|
365
|
+
let tmp = null
|
|
366
|
+
try {
|
|
367
|
+
let srcDir = args.dir
|
|
368
|
+
if (args.pptx) {
|
|
369
|
+
if (!existsSync(args.pptx)) return `✗ 找不到文件:${args.pptx}`
|
|
370
|
+
tmp = await mkdtemp(join(tmpdir(), 'ppt-tpl-add-'))
|
|
371
|
+
await importPptx(args.pptx, tmp) // 导入即带 source.pptx(真相层)
|
|
372
|
+
srcDir = tmp
|
|
373
|
+
}
|
|
374
|
+
const r = await registerTemplate(srcDir, {
|
|
375
|
+
id: args.id, name: args.name, style: args.style, scene: args.scene, sourcePptx: args.sourcePptx,
|
|
376
|
+
})
|
|
377
|
+
const track = [
|
|
378
|
+
r.sourcePptx ? `真相层:${r.sourcePptx}(原始 pptx,零失真)` : null,
|
|
379
|
+
r.previews ? `整页参考:${r.previews}(Office 真渲染 ${await countPng(r.previews)} 页)` : null,
|
|
380
|
+
`层级:${r.layer === 'user' ? `你的模板库(升级不丢)` : '自定义目录'}|目录:${r.dir}`,
|
|
381
|
+
].filter(Boolean)
|
|
382
|
+
return `✓ 已加入模板库:「${r.meta.name}」(id=${r.id},${r.pages} 张母版页)\n${track.map((t) => ` - ${t}`).join('\n')}\n - 预览图:${r.preview ?? '(未生成:无浏览器或渲染失败,模板仍可用)'}\n用法:ppt_new dir=<工作区> template=${r.id};要删:ppt_template_remove id=${r.id}\n(可选)ppt_template_styleaudit id=${r.id}:先用 read_image 看整页参考 → 写风格审计缓存`
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return `✗ 收纳失败:${error?.message ?? String(error)}`
|
|
385
|
+
} finally {
|
|
386
|
+
if (tmp) await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
reg({
|
|
392
|
+
name: 'ppt_template_remove',
|
|
393
|
+
description: '从**用户自己的模板库**里删除模板(`<dshHome>/ppt-studio/templates/<id>`)。**随包模板拒删**(它随插件分发,升级会重新出现,删了只会让人困惑);若被删的 id 与随包同名(覆盖关系),删完随包那份会重新可见',
|
|
394
|
+
parameters: {
|
|
395
|
+
id: { type: 'string', required: true, description: '要删的模板 id(`ppt_templates` 可查;只允许删 source=user 的)' },
|
|
396
|
+
},
|
|
397
|
+
output: markdownResult(),
|
|
398
|
+
async execute({ id }) {
|
|
399
|
+
try {
|
|
400
|
+
const r = await removeTemplate(id)
|
|
401
|
+
return `✓ 已从你的模板库删除「${r.id}」\n - ${r.dir}\n(若这个 id 原本覆盖了随包同名模板,随包那份现在重新可见)`
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return `✗ 删除失败:${error?.message ?? String(error)}`
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
reg({
|
|
409
|
+
name: 'ppt_render',
|
|
410
|
+
description: '渲染 deck 项目 → preview/*.html 与 layout.json(数字审阅的数据源)。每页制作后运行',
|
|
411
|
+
parameters: { dir: dirSchema, debug: { type: 'boolean', description: '文本框描边调试(默认 false)' } },
|
|
412
|
+
output: markdownResult(),
|
|
413
|
+
async execute({ dir, debug }) {
|
|
414
|
+
try {
|
|
415
|
+
const ctx0 = await loadCtx(dir)
|
|
416
|
+
const r = await renderDeck(ctx0, { debug: !!debug })
|
|
417
|
+
const chartNote = r.chartWarnings?.length
|
|
418
|
+
? `\n\n⚠ 图表数据检查(${r.chartWarnings.length} 处):\n${r.chartWarnings.map((w) => ` - ${w}`).join('\n')}`
|
|
419
|
+
: ''
|
|
420
|
+
return `✓ 渲染完成:${r.htmlFiles.length} 页 HTML + layout.json → ${r.outDir}\n${r.htmlFiles.map((f) => ` - ${f}`).join('\n')}${chartNote}`
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return `✗ 渲染失败:\n${errText(error)}`
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
reg({
|
|
428
|
+
name: 'ppt_shot',
|
|
429
|
+
description: '用本机 Edge headless 截图 preview/*.html → PNG(视觉审阅输入;无浏览器时返回降级提示)。overview=true:截整览 deck.html(跨页节奏视觉审阅,M3)',
|
|
430
|
+
parameters: { dir: dirSchema, index: { type: 'number', description: '仅截第 N 页(1 起);缺省全部' }, overview: { type: 'boolean', description: 'true = 截整览 deck.html → shots/overview.png(N 页纵排;>30 页自动截前 30,避免超高)' }, outDir: { type: 'string', description: '输出目录,缺省 preview/shots' } },
|
|
431
|
+
output: markdownResult(),
|
|
432
|
+
async execute({ dir, index, overview, outDir }) {
|
|
433
|
+
const edge = findEdge()
|
|
434
|
+
if (!edge) {
|
|
435
|
+
return '⚠ 未检测到 Edge/Chrome:截图不可用。请在浏览器中打开 preview/*.html 人工查看,视觉评审降级为结构断言(ppt_verify)。'
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const ctx0 = await loadCtx(dir)
|
|
439
|
+
const files = await listPreviewFiles(dir)
|
|
440
|
+
const shotsDir = join(dir, outDir ?? 'preview/shots')
|
|
441
|
+
if (overview) {
|
|
442
|
+
const deckHtml = join(dir, 'preview', 'deck.html')
|
|
443
|
+
if (!existsSync(deckHtml)) return '✗ 未找到 preview/deck.html——请先 ppt_render'
|
|
444
|
+
const n = Math.min(files.html.length, 30) // 超高截图受限;>30 页取前 30(跨页节奏抽样)
|
|
445
|
+
const height = n * (ctx0.size.height + 46) + 40
|
|
446
|
+
const out = join(shotsDir, 'overview.png')
|
|
447
|
+
await shotOne(edge, deckHtml, out, { width: ctx0.size.width, height }, 1)
|
|
448
|
+
return `✓ 整览截图:${out}(${n} 页${files.html.length > 30 ? ` / 共 ${files.html.length} 页(截前 ${n})` : ''}——跨页节奏视觉审阅)`
|
|
449
|
+
}
|
|
450
|
+
if (index !== undefined && index >= 1 && index <= files.html.length) {
|
|
451
|
+
const target = files.html[index - 1]
|
|
452
|
+
const out = join(shotsDir, `${String(index).padStart(2, '0')}.png`)
|
|
453
|
+
await shotOne(edge, target, out, ctx0.size, 1)
|
|
454
|
+
return `✓ 已截图:${out}`
|
|
455
|
+
}
|
|
456
|
+
if (index !== undefined) return `✗ 页索引越界(共 ${files.html.length} 页)`
|
|
457
|
+
await mkdir(shotsDir, { recursive: true })
|
|
458
|
+
const results = []
|
|
459
|
+
for (let i = 0; i < files.html.length; i++) {
|
|
460
|
+
const out = join(shotsDir, `${String(i + 1).padStart(2, '0')}.png`)
|
|
461
|
+
await shotOne(edge, files.html[i], out, ctx0.size, 1)
|
|
462
|
+
results.push(out)
|
|
463
|
+
}
|
|
464
|
+
return `✓ 已截图 ${results.length} 页:${shotsDir}\n${results.map((r) => ` - ${r}`).join('\n')}`
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return `✗ 截图失败:\n${errText(error)}`
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
reg({
|
|
472
|
+
name: 'ppt_crosscheck',
|
|
473
|
+
description: '内容审阅材料包(M3 重写 2026-09-18):把全页正文(阅读顺序,含表格/图表数据/讲稿/作者自述出处)+ 工作区实扫的外部素材清单 + 审阅协议(判定四分类:一致/冲突/无来源支撑/无法核实)端上桌,供独立子代理或模型做"前后一致 / 数据不冲突 / 无编造曲解"审阅。**工具本身不做任何判定、不进门禁**(旧版跨页数字分组与 grounded/unmapped 已废除:数字必须放回整句语境才有含义,source 是作者自述而非证据)',
|
|
474
|
+
parameters: { dir: dirSchema },
|
|
475
|
+
output: markdownResult(),
|
|
476
|
+
async execute({ dir }) {
|
|
477
|
+
try {
|
|
478
|
+
const ctx = await loadCtx(dir)
|
|
479
|
+
return crosscheckReport(ctx)
|
|
480
|
+
} catch (error) {
|
|
481
|
+
return `✗ 核查失败:\n${errText(error)}`
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
reg({
|
|
487
|
+
name: 'ppt_measure',
|
|
488
|
+
description: 'M2 文本实测档:Edge/Chrome headless 加载预览页,采集每个元素真实排版几何(行盒/内容高/溢出/几何)→ preview/measured.json("浏览器实测=终审")。无浏览器自动降级(标注"未实测",估算门禁不变)。用法:ppt_measure → ppt_verify measured=true(实测溢出且估算未报 → 新增错误;估算报但实测通过 → warning 供人工确认)',
|
|
489
|
+
parameters: { dir: dirSchema },
|
|
490
|
+
output: markdownResult(),
|
|
491
|
+
async execute({ dir }) {
|
|
492
|
+
try {
|
|
493
|
+
const r = await measureLayout(dir)
|
|
494
|
+
if (!r.measured) return r.notes.join('\n') || '⚠ 实测不可用'
|
|
495
|
+
const textEls = (r.measured.pages ?? []).flatMap((p) => (p.elements ?? []).filter((e) => e.kind === 'text'))
|
|
496
|
+
const overflows = textEls.filter((e) => (e.overflowY ?? 0) > 1)
|
|
497
|
+
const unmatched = (r.measured.pages ?? []).filter((p) => !p.elements?.length).length
|
|
498
|
+
return [
|
|
499
|
+
`✓ 实测完成:${r.pages} 页 / ${textEls.length} 个文本元素 / ${(r.measured.pages ?? []).reduce((n, p) => n + (p.elements?.length ?? 0), 0)} 个总元素(${r.elapsedMs ?? 0}ms)`,
|
|
500
|
+
` - measured.json:${join(r.outDir, 'measured.json')}`,
|
|
501
|
+
overflows.length ? `⚠ 实测溢出 ${overflows.length} 个:${overflows.slice(0, 5).map((e) => e.id).join(', ')}${overflows.length > 5 ? '…' : ''}` : '✓ 实测无文本溢出',
|
|
502
|
+
...(r.notes ?? []),
|
|
503
|
+
'',
|
|
504
|
+
'下一步:ppt_verify measured=true(D3:实测=终审、估算=预检;实测溢出且估算未报会被捕获为错误)',
|
|
505
|
+
].join('\n')
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return `✗ 实测失败:\n${errText(error)}`
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
reg({
|
|
513
|
+
name: 'ppt_verify',
|
|
514
|
+
description: '数字审阅:基于 preview/layout.json(缺失时先渲染)断言 重叠/出界/文本溢出/对齐/密集区;[✗] 错误清零是页级门禁,[·] 为审美建议(非门禁)。出界分级:超页面边界=永远错误;超安全区=声明制(页面 expectedOutOfSafeArea 声明命中→✓预期出界)。含对比度/孤字等启发式建议。measured=true:交叉 M2 实测档(先跑 ppt_measure)——实测溢出(估算漏报)→ 新增 [✗] error(实测=终审)',
|
|
515
|
+
parameters: {
|
|
516
|
+
dir: dirSchema,
|
|
517
|
+
autoDeclare: { type: 'boolean', description: 'true = 把未声明的警告级重叠(色块衬底/图片标注/箭头跨越等,不含内容互压)一键写入页面 expectedOverlaps 后重验(对应"设计意图声明制"的批量声明,仍会报告剩余不可声明错误)' },
|
|
518
|
+
measured: { type: 'boolean', description: 'true = 交叉实测档(preview/measured.json,需先运行 ppt_measure):实测溢出且估算未报 → 新增 error;估算报但实测通过 → warning(字体差异人工确认)' },
|
|
519
|
+
pages: { type: 'string', description: '仅审阅指定页(如 "2" / "4,10" / "1,3-5",1 起;缺省全部)。局部校验只影响本次报告,不改写 layout.json(防部分校验静默漏检其余页)' },
|
|
520
|
+
},
|
|
521
|
+
output: markdownResult(),
|
|
522
|
+
async execute(args, exec) {
|
|
523
|
+
const dir = args.dir
|
|
524
|
+
const autoDeclare = !!args.autoDeclare
|
|
525
|
+
const withMeasured = !!args.measured
|
|
526
|
+
const pagesSel = typeof args.pages === 'string' ? parsePagesArg(args.pages) : null
|
|
527
|
+
// 质量档 + 诊断(2026-09-18):状态文件损坏导致的回落必须**可见**,不能悄悄按 standard 跑
|
|
528
|
+
const qd = await qualityOfDiag(exec?.agent, dir)
|
|
529
|
+
const degradeNote = qd.degraded
|
|
530
|
+
? `\n\n---\n⚠ **质量档降级**:${qd.reason}\n本轮按 ${qd.quality} 处理——audit 档的额外门禁(禁 autoDeclare / 强制视觉审阅 / 导出回读断言)**未生效**。修好状态文件,或重跑 /ppt quality <档>。`
|
|
531
|
+
: ''
|
|
532
|
+
try {
|
|
533
|
+
if (autoDeclare) {
|
|
534
|
+
if (blockedByAudit(qd.quality)) {
|
|
535
|
+
return '✗ audit 质量档禁用 autoDeclare(防一键声明掩盖真实问题,C2 决定):请切回 standard/quick 档,或逐对手工声明后重验。'
|
|
536
|
+
}
|
|
537
|
+
const ctx0 = await loadCtx(dir)
|
|
538
|
+
const r0 = await renderDeck(ctx0, {})
|
|
539
|
+
const added = await applyAutoDeclare(ctx0, r0.layout)
|
|
540
|
+
// 声明已写入 → 重新加载项目(resolveDeck 是读时快照)并重验
|
|
541
|
+
const ctx1 = await loadCtx(dir)
|
|
542
|
+
const r = await renderDeck(ctx1, {})
|
|
543
|
+
const v = verifyDeck(r.layout)
|
|
544
|
+
const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
|
|
545
|
+
const addedNote = added.length
|
|
546
|
+
? `\n已写入声明:${added.map((a) => `第${a.page}页 +${a.added}对`).join(',')}`
|
|
547
|
+
: '\n未发现可自动声明的警告级重叠(全部已声明/内容互压/豁免)。'
|
|
548
|
+
return `# 审阅(autoDeclare 后)${addedNote}\n${v.text}\n\n---\n门禁:${errors === 0 ? '✓ 通过' : `✗ ${errors} 个错误(若均为 content-collision,请手工调整布局;声明制不支持内容互压豁免)`}`
|
|
549
|
+
}
|
|
550
|
+
let layout = null
|
|
551
|
+
const layoutFile = join(dir, 'preview', 'layout.json')
|
|
552
|
+
if (existsSync(layoutFile)) layout = JSON.parse(await readFile(layoutFile, 'utf8'))
|
|
553
|
+
|
|
554
|
+
// pptx 产物快照(引擎无关):若项目里有导出产物,用 XML 几何交叉校验
|
|
555
|
+
const pptxSnapshotText = await snapshotPptxShapes(dir)
|
|
556
|
+
const ctx0 = layout ? null : await loadCtx(dir)
|
|
557
|
+
if (!layout && ctx0) {
|
|
558
|
+
const r = await renderDeck(ctx0, {})
|
|
559
|
+
layout = r.layout
|
|
560
|
+
}
|
|
561
|
+
let localNote = ''
|
|
562
|
+
if (pagesSel) {
|
|
563
|
+
const before = (layout.pages ?? []).length
|
|
564
|
+
layout = { ...layout, pages: (layout.pages ?? []).filter((p) => pagesSel.has(p.index + 1)) }
|
|
565
|
+
localNote = `\n\n> 局部校验:仅审阅第 [${[...pagesSel].sort((a, b) => a - b).join(', ')}] 页(共 ${before} 页;其余页本次未检查)`
|
|
566
|
+
}
|
|
567
|
+
const v = verifyDeck(layout)
|
|
568
|
+
const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
|
|
569
|
+
// M2:实测交叉(D3:实测=终审、估算=预检)
|
|
570
|
+
let measuredText = ''
|
|
571
|
+
let measuredErrors = 0
|
|
572
|
+
if (withMeasured) {
|
|
573
|
+
const mFile = join(dir, 'preview', 'measured.json')
|
|
574
|
+
if (existsSync(mFile)) {
|
|
575
|
+
const measured = JSON.parse(await readFile(mFile, 'utf8'))
|
|
576
|
+
const x = measuredCrossCheck(layout, measured)
|
|
577
|
+
const ev = x.filter((f) => ['error', 'warning'].includes(f.severity))
|
|
578
|
+
measuredErrors = x.filter((f) => f.severity === 'error').length
|
|
579
|
+
measuredText = ev.length
|
|
580
|
+
? `\n\n---\n**M2 实测交叉(D3:实测=终审/估算=预检)**\n${ev.map((f) => `[${f.severity === 'error' ? '✗' : '⚠'}] ${f.code}|${f.message}`).join('\n')}\n${x.filter((f) => f.severity === 'suggestion').map((f) => `[·] ${f.code}|${f.message}`).join('\n')}`
|
|
581
|
+
: '\n\n---\n**M2 实测交叉**:无实测/估算分歧(✓ 全部一致或实测通过)'
|
|
582
|
+
} else {
|
|
583
|
+
measuredText = '\n\n---\n**M2 实测交叉**:未发现 preview/measured.json——请先运行 ppt_measure(或忽略,保持估算门禁)'
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const gate = errors + measuredErrors
|
|
587
|
+
const head = v.text
|
|
588
|
+
return `# 数字审阅${localNote}\n${head}${measuredText}\n\n---\n门禁:${gate === 0 ? '✓ 通过' : `✗ ${gate} 个错误(必须清零${measuredErrors ? `,含 M2 实测 ${measuredErrors} 个` : ''}`}\n${pptxSnapshotText}${degradeNote}`
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return `✗ 验证失败:\n${errText(error)}`
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
reg({
|
|
596
|
+
name: 'ppt_export',
|
|
597
|
+
description: '导出 .pptx。engine 缺省 auto(=pptd 自研主引擎,图表矢量拼绘;hard 失败时自动回退 python-pptx 并醒目标注降级);python-pptx 仅显式指定(其图表降级为表格)。out 支持绝对路径(原样使用)或文件名(相对 deck 目录)。audit 质量档自动追加回读断言(页数/尺寸/最小字号)',
|
|
598
|
+
parameters: {
|
|
599
|
+
dir: dirSchema,
|
|
600
|
+
engine: { type: 'string', enum: ['auto', 'pptd', 'python-pptx'], description: '缺省 auto(=pptd);python-pptx 需 python 环境' },
|
|
601
|
+
out: { type: 'string', description: '输出文件名(相对 deck 目录)或绝对路径,缺省 out.pptx' },
|
|
602
|
+
},
|
|
603
|
+
output: markdownResult(),
|
|
604
|
+
async execute({ dir, engine, out }, exec) {
|
|
605
|
+
try {
|
|
606
|
+
const ctx0 = await loadCtx(dir)
|
|
607
|
+
const outName = out ?? 'out.pptx'
|
|
608
|
+
const qdE = await qualityOfDiag(exec?.agent, dir)
|
|
609
|
+
const quality = qdE.quality
|
|
610
|
+
// 引擎:工具参数优先;**缺省时采纳会话/项目档位**(`/ppt engine X` 设的就是它)。
|
|
611
|
+
// 历史 bug:旧实现只看工具参数 ⇒ `/ppt engine python-pptx` 设完再用 ppt_export(不带参数)
|
|
612
|
+
// 仍然跑 pptd —— 一个"设了没用"的档位,且报告里的 `引擎` 行还显示用户选的那个。
|
|
613
|
+
const eff = resolveEngine(engine ?? (qdE.engine && qdE.engine !== 'auto' ? qdE.engine : undefined))
|
|
614
|
+
const engineNote = eff.unknown
|
|
615
|
+
? `\n⚠ 引擎取值 "${eff.unknown}" 未被支持——已按 auto(pptd + 硬失败兜底)处理,**没有静默按你的选择跑**。可用:auto | pptd | python-pptx。`
|
|
616
|
+
: ''
|
|
617
|
+
const audit = blockedByAudit(quality)
|
|
618
|
+
const withAudit = async (file, extra = '') => {
|
|
619
|
+
let note = extra
|
|
620
|
+
// 质量档回落必须写进导出报告(否则用户以为自己在 audit 档,而额外门禁全都没跑)
|
|
621
|
+
if (qdE.degraded) {
|
|
622
|
+
note += `\n⚠ **质量档降级**:${qdE.reason}\n本轮按 ${qdE.quality} 处理——audit 档的额外门禁(禁 autoDeclare / 强制视觉审阅 / 导出回读断言)**未生效**。`
|
|
623
|
+
}
|
|
624
|
+
// v0.8.0:audit 档 + 本机有 Office → 导出后自动 Office 真渲染(成品视觉审核,可 read_image 复核)
|
|
625
|
+
if (audit && findPowerPoint()) {
|
|
626
|
+
try {
|
|
627
|
+
const visDir = join(dir, 'preview', 'office-render')
|
|
628
|
+
const r = await renderPptxToPng(file, visDir)
|
|
629
|
+
note += `\nOffice 真渲染审核(audit 档自动):${r.pages} 页 → ${visDir}\n请逐页 read_image 复核成品观感(无 Office 机器自动跳过此步)。`
|
|
630
|
+
} catch (e) {
|
|
631
|
+
note += `\nOffice 真渲染审核失败(已降级为 HTML 预览+结构断言):${e?.message ?? e}`
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (!audit) return note
|
|
635
|
+
return `${note}\n${await auditExportCheck(ctx0, file, ctx0.minFontSize)}`
|
|
636
|
+
}
|
|
637
|
+
if (eff.engine === 'python-pptx' && !eff.allowFallback) {
|
|
638
|
+
const py = findPython()
|
|
639
|
+
if (!py.has) return `⚠ python-pptx 引擎不可用(未检测到 python + python-pptx 环境):${py.cmd ? '请 pip install python-pptx' : '未找到 python 解释器'}。可改用默认 pptd 引擎。`
|
|
640
|
+
const r = await runPythonExport(ctx0, outName)
|
|
641
|
+
return `✓ 已导出(python-pptx 引擎):${r.file}\n图表已降级为表格(引擎 A 才支持矢量拼绘图表)。${await withAudit(r.file)}${engineNote}`
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const r = await exportPptx(ctx0, { out: outName, engine: 'pptd' })
|
|
645
|
+
const fitLines = r.autoFit.map((a) => ` - ${a.id}: ${a.from}pt → ${a.to}pt${a.floorHit ? '(已到字号下限仍溢出:请扩大容器或精简文案后再交付)' : ''}`)
|
|
646
|
+
const floorHits = r.autoFit.filter((a) => a.floorHit).length
|
|
647
|
+
const fit = r.autoFit.length
|
|
648
|
+
? `\n\n⚠ auto-fit 缩放 ${r.autoFit.length} 处文本:\n${fitLines.join('\n')}`
|
|
649
|
+
: ''
|
|
650
|
+
const floorNote = floorHits ? `\n\n✗ ${floorHits} 处达到缩字下限仍溢出(下限 = 用户指令 theme.minFontSize;未设置时为 60% 原字号防荒谬保底)——建议修复后再交付:扩大容器或精简文案。\n 若这些溢出页是导入近似稿的存量问题(位置/换行与原件不一致)而任务只需改某页:考虑 ppt_splice(只替换目标页进原稿,其余页逐字节不动)而非整册重渲。` : ''
|
|
651
|
+
const chartNote = r.chartInfos?.length
|
|
652
|
+
? `\n\n⚠ 图表数据检查(${r.chartInfos.length} 处):\n${r.chartInfos.map((w) => ` - ${w}`).join('\n')}`
|
|
653
|
+
: ''
|
|
654
|
+
const phNote = r.mediaPlaceholders?.length
|
|
655
|
+
? `\n\n⚠ 缺失媒体 ${r.mediaPlaceholders.length} 个(${r.mediaPlaceholders.join('、')})已用 1×1 白色占位导出——请替换真实图片或删除页面引用(可重新 ppt_import 提取或人工补图)`
|
|
656
|
+
: ''
|
|
657
|
+
const parityNote = r.parity
|
|
658
|
+
? (r.parity.ok
|
|
659
|
+
? `\n✅ 元素 parity 回读:表 ${r.parity.tablesExp}/${r.parity.tablesOut} · 图 ${r.parity.imagesExp}/${r.parity.imagesOut} · 线方向 ${r.parity.linesExp ?? 0}/${r.parity.linesOut ?? 0}(逐条从 OOXML 反推端点自证) · 结构合法(无 PowerPoint 弃帧类嵌套 xfrm)`
|
|
660
|
+
: `\n✗ 元素 parity 不一致:表 期望${r.parity.tablesExp}/实际${r.parity.tablesOut} · 图 期望${r.parity.imagesExp}/实际${r.parity.imagesOut} · 线 期望${r.parity.linesExp ?? 0}/实际${r.parity.linesOut ?? 0}/方向错${r.parity.linesWrong ?? 0} · 非法帧 ${r.parity.illegalFrames}——请勿交付,附本输出反馈插件团队`)
|
|
661
|
+
: ''
|
|
662
|
+
// P8(测试反馈):真渲染抽查策略——含复杂元素的页必须覆盖(P1 事故的直接防线)
|
|
663
|
+
const riskyPages = (ctx0.pages ?? []).map((p, i) => {
|
|
664
|
+
const es = p.page.elements ?? []
|
|
665
|
+
return es.some((e) => ['table', 'chart', 'image'].includes(e.elementType) || e.kind === 'custGeom') ? i + 1 : null
|
|
666
|
+
}).filter(Boolean)
|
|
667
|
+
const renderNote = riskyPages.length
|
|
668
|
+
? `\nP8 真渲染抽查:建议优先覆盖含 table/chart/image/custGeom 的页 → 第 ${riskyPages.join('、')} 页(每类至少一页;audit 档导出自动全页真渲染)`
|
|
669
|
+
: ''
|
|
670
|
+
return `✓ 已导出(pptd 引擎,${r.slides} 页):${r.file}${fit}${phNote}${parityNote}${renderNote}${floorNote}${chartNote}${await withAudit(r.file)}${engineNote}`
|
|
671
|
+
} catch (error) {
|
|
672
|
+
// 自动回退链(C1 决定):auto 且 pptd 硬失败 → 有 python-pptx 则兜底并醒目标注降级(绝不静默)
|
|
673
|
+
if (eff.allowFallback) {
|
|
674
|
+
const py = findPython()
|
|
675
|
+
if (py.has) {
|
|
676
|
+
try {
|
|
677
|
+
const r2 = await runPythonExport(ctx0, outName)
|
|
678
|
+
return `⚠ pptd 引擎失败,已自动降级 python-pptx(图表降级为表格):${error?.message ?? error}\n✓ 已导出(python-pptx 兜底):${r2.file}\n建议排查 pptd 失败原因(见上)或改用质量更高的模式。${await withAudit(r2.file)}`
|
|
679
|
+
} catch (e2) {
|
|
680
|
+
return `✗ pptd 失败(${error?.message ?? error})且 python-pptx 兜底也失败:${e2?.message ?? e2}`
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return `✗ pptd 导出失败:${error?.message ?? error}\n(auto 模式:python-pptx 环境不可用,无兜底可选;可 /ppt engine python-pptx 或安装 python-pptx 后重试)`
|
|
684
|
+
}
|
|
685
|
+
throw error
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
return `✗ 导出失败:\n${errText(error)}`
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
reg({
|
|
694
|
+
name: 'ppt_splice',
|
|
695
|
+
description: '保真交付(v0.15.0,反馈二 A3/A9 ★★):把工作区某一页**替换进源 .pptx**——保留源母版/布局(横幅/页脚原样)、保留该页备注关系、媒体合并;**其余页条目逐字节不变(SHA256 自动自证)**。适用"编辑既有精美 PPT 的某一页"场景(整册近似重渲会伤其他页,A4)。输出 = 整册副本(仅目标页被替换)——与手工 zip 手术等价,一次命令',
|
|
696
|
+
parameters: {
|
|
697
|
+
dir: { type: 'string', required: true, description: '工作区目录(含 deck.yaml——被替换页的内容真相)' },
|
|
698
|
+
source: { type: 'string', required: true, description: '源 .pptx 绝对路径(用户原稿)' },
|
|
699
|
+
page: { type: 'number', required: true, description: 'deck 页号(1 基)——用工作区第几页替换' },
|
|
700
|
+
sourcePage: { type: 'number', description: '源 .pptx 中要被替换的页号(缺省 = page)' },
|
|
701
|
+
out: { type: 'string', description: '输出 .pptx(缺省 <源>-spliced.pptx)' },
|
|
702
|
+
},
|
|
703
|
+
output: markdownResult(),
|
|
704
|
+
async execute(args) {
|
|
705
|
+
try {
|
|
706
|
+
const r = await spliceIntoSource({
|
|
707
|
+
deckDir: args.dir, source: args.source,
|
|
708
|
+
page: Number(args.page), sourcePage: args.sourcePage !== undefined ? Number(args.sourcePage) : Number(args.page),
|
|
709
|
+
out: args.out,
|
|
710
|
+
})
|
|
711
|
+
return [
|
|
712
|
+
`✓ 已替换并写出:${r.out}`,
|
|
713
|
+
` - 目标页:源第 ${r.replaced.sourcePage} 页 ← deck 第 ${r.replaced.deckPage} 页(${r.replaced.slide})`,
|
|
714
|
+
` - 其余条目:${r.unchangedCount} 个 SHA256 与源逐字节一致(${r.changed.length ? `变更:${r.changed.join('、')}` : '零非预期变更'})`,
|
|
715
|
+
` - 媒体:新增 ${r.mediaAdded} / 复用源同名 ${r.mediaReused};备注关系:${r.notesKept ? '保留' : '(源该页无备注)'}`,
|
|
716
|
+
` - 源母版/布局/主题:原样保留(横幅/页脚不动);若需"单页独立版"→ 下一步 ppt_slice source=... page=...`,
|
|
717
|
+
'下一步:ppt_visual pages=' + r.replaced.sourcePage + '(抽查该页真渲染观感)。',
|
|
718
|
+
].join('\n')
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return `✗ 替换失败:\n${errText(error)}`
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
reg({
|
|
726
|
+
name: 'ppt_slice',
|
|
727
|
+
description: '保真交付(v0.15.0,反馈二 A3):从 .pptx(建议先 ppt_splice 的产物或用户原稿)修剪出**单页 + 完整母版/布局/主题**的独立文件——sldIdLst 只留 1 条、其余页/备注/关系/Override 删除、布局母版全保留(顶部横幅/页脚随母版原样)',
|
|
728
|
+
parameters: {
|
|
729
|
+
source: { type: 'string', required: true, description: '源 .pptx 绝对路径' },
|
|
730
|
+
page: { type: 'number', required: true, description: '保留的页号(1 基)' },
|
|
731
|
+
out: { type: 'string', description: '输出 .pptx(缺省 <源>-single.pptx)' },
|
|
732
|
+
},
|
|
733
|
+
output: markdownResult(),
|
|
734
|
+
async execute(args) {
|
|
735
|
+
try {
|
|
736
|
+
const r = await sliceSource({ source: args.source, page: Number(args.page), out: args.out })
|
|
737
|
+
return [
|
|
738
|
+
`✓ 单页版已写出:${r.out}(保留第 ${r.page} 页)`,
|
|
739
|
+
` - 母版 ${r.masters} / 布局 ${r.layouts} / 主题:全部保留(横幅/页脚原样)`,
|
|
740
|
+
` - 条目总数(含母版体系):${r.entries}`,
|
|
741
|
+
'下一步:ppt_visual pages=1 抽查(单页版中该页 = 第 1 页)。',
|
|
742
|
+
].join('\n')
|
|
743
|
+
} catch (error) {
|
|
744
|
+
return `✗ 单页化失败:\n${errText(error)}`
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
reg({
|
|
750
|
+
name: 'ppt_import',
|
|
751
|
+
description: '导入已有 .pptx → deck 项目(内容保真、版式参考;补完/修改/总结任务的开端)',
|
|
752
|
+
parameters: {
|
|
753
|
+
pptx: { type: 'string', required: true, description: '源 .pptx 绝对路径' },
|
|
754
|
+
outDir: { type: 'string', required: true, description: '输出项目目录(将写入 deck.yaml/pages/media)' },
|
|
755
|
+
},
|
|
756
|
+
output: markdownResult(),
|
|
757
|
+
async execute({ pptx, outDir }) {
|
|
758
|
+
try {
|
|
759
|
+
const r = await importPptx(pptx, outDir)
|
|
760
|
+
const refLine = r.reference?.previews?.length
|
|
761
|
+
? `\n参考层(参考双轨 v0.9.1——创作前先 read_image 看 reference/previews/*.png 真身再动手,与"按模板做"同一通道):reference/source.pptx + ${r.reference.previews.length} 页真渲染`
|
|
762
|
+
: `\n⚠ 无整页真渲染参考(无 Office/渲染失败):可用 ppt_visual 对 source.pptx 补渲;骨架层仍可用`
|
|
763
|
+
return `✓ 已导入:${r.outDir}(${r.pages} 页,${r.media.length} 个媒体;原始 pptx 已保留为 source.pptx —— 参考双轨真相层)${refLine}\n⚠ ${r.warnings.join(';')}`
|
|
764
|
+
} catch (error) {
|
|
765
|
+
return `✗ 导入失败:\n${errText(error)}`
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
reg({
|
|
771
|
+
name: 'ppt_patch',
|
|
772
|
+
description: '手术模式(v0.10.0,候选 B):以模板 .pptx 为底版做"结构精确贴模板"——模板页的文本/表格槽被工作区内容替换(只改 <a:t>,rPr/几何/渐变/字体/图片全部原样保留),其余页内容逐字节不变(sha256 验证)。**适用**:按模板做的成品要"看起来就是模板原样"(双轨参考只解决风格,手术解决结构保真)。**限制(v1)**:只替换文本形状+表格;组合内文本/图片不动;deck 页数超过模板页数的多出页不注入(警告);表格行数超出模板的警告。与 ppt_export 互不影响——常规导出仍走 PPTD 渲染',
|
|
773
|
+
parameters: {
|
|
774
|
+
template: { type: 'string', required: true, description: '模板 .pptx 绝对路径(优先用工作区 reference/template.pptx——双轨真相层)' },
|
|
775
|
+
dir: { type: 'string', required: true, description: '工作区目录(含 deck.yaml;页面内容为内容真相)' },
|
|
776
|
+
out: { type: 'string', description: '输出 .pptx(缺省 <dir>/out-surgical.pptx)' },
|
|
777
|
+
map: { type: 'string', description: '页映射 JSON(可选):{"1":3,"2":1} 模板页号(1-based)→deck 页号(1-based);缺省=序号对齐' },
|
|
778
|
+
},
|
|
779
|
+
output: markdownResult(),
|
|
780
|
+
async execute({ template, dir, out, map }) {
|
|
781
|
+
try {
|
|
782
|
+
let pageMap = {}
|
|
783
|
+
if (map) {
|
|
784
|
+
pageMap = JSON.parse(map)
|
|
785
|
+
if (!pageMap || typeof pageMap !== 'object' || Array.isArray(pageMap)) return '✗ map 必须是 JSON 对象:{"模板页号": "deck 页号"}'
|
|
786
|
+
}
|
|
787
|
+
const outPath = out ?? join(dir, 'out-surgical.pptx')
|
|
788
|
+
const r = await surgicalPatch({ template, deckDir: dir, out: outPath, map: pageMap })
|
|
789
|
+
const lines = [
|
|
790
|
+
`✓ 手术完成:${r.out}(模板 ${r.templateSlides} 页 / deck ${r.deckPages} 页)`,
|
|
791
|
+
'',
|
|
792
|
+
'页处理:',
|
|
793
|
+
...r.pages.map((p) => ` - 模板第 ${p.index} 页 [${p.action}]${p.fields ? ` 替换字段 ${p.fields}${p.cleared ? `(含清空 ${p.cleared})` : ''}` : ''}${p.tableCells ? ` 表格 cell ${p.tableCells}` : ''}${p.note ? ` ${p.note}` : ''}`),
|
|
794
|
+
`替换统计:文本字段 ${r.fields} / 表格 cell ${r.tableCells}`,
|
|
795
|
+
`完整性验证(未手术条目解包内容 sha256 与模板一致性):${r.verify.identical}/${r.verify.total} ✓${r.verify.mismatched ? ` ✗ 不一致:${r.verify.details.join(', ')}` : ''}`,
|
|
796
|
+
...(r.warnings.length ? ['', '⚠ 警告:', ...r.warnings.map((w) => ` - ${w}`)] : []),
|
|
797
|
+
'',
|
|
798
|
+
'下一步(可选):ppt_visual 对成品 Office 真渲染审核(确认视觉 = 模板原样 + 内容已换)。',
|
|
799
|
+
]
|
|
800
|
+
return lines.join('\n')
|
|
801
|
+
} catch (error) {
|
|
802
|
+
return `✗ 手术失败:\n${errText(error)}`
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
reg({
|
|
808
|
+
name: 'ppt_status',
|
|
809
|
+
description: '查看工作流状态(任务类型/阶段/暂停点/页状态/引擎/质量模式)',
|
|
810
|
+
parameters: { dir: { type: 'string', description: 'deck 项目目录(可选)' } },
|
|
811
|
+
output: markdownResult(),
|
|
812
|
+
async execute({ dir }) {
|
|
813
|
+
if (!dir) return '未指定项目目录(--dir)。'
|
|
814
|
+
const pr = await loadProjectDiag(dir)
|
|
815
|
+
const proj = pr.state
|
|
816
|
+
const lines = [
|
|
817
|
+
`任务类型: ${proj.taskType ?? 'unknown'}`,
|
|
818
|
+
`阶段: ${proj.stage ?? '未开始'}`,
|
|
819
|
+
`质量模式: ${proj.quality}`,
|
|
820
|
+
`暂停点: ${proj.pauseAfter.length ? proj.pauseAfter.join(', ') : '无'}`,
|
|
821
|
+
`页状态: ${Object.keys(proj.pages ?? {}).length ? JSON.stringify(proj.pages) : '无'}`,
|
|
822
|
+
]
|
|
823
|
+
// 状态文件坏了必须说出来(否则"质量模式: standard"看起来是用户设的,其实是回落)
|
|
824
|
+
if (pr.error) lines.push(`⚠ ${pr.error}——以上为默认值,不是你的设置`)
|
|
825
|
+
return lines.join('\n')
|
|
826
|
+
},
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
reg({
|
|
830
|
+
name: 'ppt_media',
|
|
831
|
+
description: '读取图片物理规格(宽高/格式,程序化,无需视觉模型);用于布局计算与素材 manifest',
|
|
832
|
+
parameters: { files: { type: 'array', items: { type: 'string' }, required: true, description: '图片绝对路径列表' } },
|
|
833
|
+
output: markdownResult(),
|
|
834
|
+
async execute({ files }) {
|
|
835
|
+
const out = []
|
|
836
|
+
for (const f of files ?? []) {
|
|
837
|
+
try {
|
|
838
|
+
const data = await readFile(f)
|
|
839
|
+
const info = imageInfo(data)
|
|
840
|
+
out.push(`${f} → ${info.format} ${info.width}×${info.height}px`)
|
|
841
|
+
} catch (error) {
|
|
842
|
+
out.push(`${f} → 读取失败:${error?.message ?? error}`)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return out.join('\n')
|
|
846
|
+
},
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function listPreviewFiles(dir) {
|
|
851
|
+
const files = await readdir(join(dir, 'preview'))
|
|
852
|
+
const html = files.filter((f) => f.endsWith('.html') && f !== 'deck.html').sort().map((f) => join(dir, 'preview', f))
|
|
853
|
+
return { html }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function shotOne(edge, htmlPath, outPng, size, scale) {
|
|
857
|
+
await mkdir(dirname(outPng), { recursive: true })
|
|
858
|
+
const url = pathToFileURL(htmlPath).href
|
|
859
|
+
const profileDir = join(tmpdir(), `dsh-ppt-shot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
860
|
+
await new Promise((resolve, reject) => {
|
|
861
|
+
const child = spawn(edge, [
|
|
862
|
+
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
|
863
|
+
'--hide-scrollbars', '--force-device-scale-factor=' + scale,
|
|
864
|
+
`--user-data-dir=${profileDir}`, '--disk-cache-size=1', '--disable-application-cache',
|
|
865
|
+
`--window-size=${size.width},${size.height}`,
|
|
866
|
+
`--screenshot=${outPng}`,
|
|
867
|
+
url,
|
|
868
|
+
], { stdio: 'ignore', windowsHide: true })
|
|
869
|
+
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Edge 退出码 ${code}`)))
|
|
870
|
+
child.on('error', reject)
|
|
871
|
+
setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Edge 截图超时')) }, 60000)
|
|
872
|
+
})
|
|
873
|
+
return profileDir
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function findEdge() {
|
|
877
|
+
const candidates = [
|
|
878
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
879
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
880
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
881
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
882
|
+
]
|
|
883
|
+
for (const c of candidates) if (existsSync(c)) return c
|
|
884
|
+
return null
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** 数字审阅补充:从已导出的 pptx XML 读取精确几何(引擎无关快照的“产物档”)。 */
|
|
888
|
+
async function snapshotPptxShapes(dir) {
|
|
889
|
+
const out = join(dir, 'out.pptx')
|
|
890
|
+
if (!existsSync(out)) return ''
|
|
891
|
+
try {
|
|
892
|
+
const { zipRead } = await import('./zips.js')
|
|
893
|
+
const { parseXml, first, allText } = await import('./xmljs.js')
|
|
894
|
+
const buf = await readFile(out)
|
|
895
|
+
const files = zipRead(buf)
|
|
896
|
+
let n = 0
|
|
897
|
+
for (let i = 1; files.has(`ppt/slides/slide${i}.xml`); i++) {
|
|
898
|
+
const slide = parseXml(files.get(`ppt/slides/slide${i}.xml`).toString('utf8'))
|
|
899
|
+
const spTree = first(first(slide, 'cSld'), 'spTree')
|
|
900
|
+
n += (spTree?.children ?? []).filter((c) => ['sp', 'pic', 'graphicFrame', 'cxnSp'].includes(c.tag)).length
|
|
901
|
+
}
|
|
902
|
+
return `产物快照(pptx XML):${n} 个顶层形状(参与导出几何与中间层一致性)`
|
|
903
|
+
} catch {
|
|
904
|
+
return ''
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* audit 档导出回读断言(D2 决定,v0.3.2):页数 / 尺寸 / 最小字号 ≥ minFontSize。
|
|
910
|
+
* 产物档(XML 权威坐标)与中间层一致性校验;audit 档自动追加到导出报告。
|
|
911
|
+
*/
|
|
912
|
+
async function auditExportCheck(ctx, file, minFontSize) {
|
|
913
|
+
try {
|
|
914
|
+
const { zipRead } = await import('./zips.js')
|
|
915
|
+
const buf = await readFile(file)
|
|
916
|
+
const parts = zipRead(buf)
|
|
917
|
+
let slideCount = 0
|
|
918
|
+
let minSz = Infinity
|
|
919
|
+
for (let i = 1; parts.has(`ppt/slides/slide${i}.xml`); i++) {
|
|
920
|
+
slideCount++
|
|
921
|
+
const xml = parts.get(`ppt/slides/slide${i}.xml`).toString('utf8')
|
|
922
|
+
for (const m of xml.matchAll(/sz="(\d+)"/g)) {
|
|
923
|
+
const v = Number(m[1])
|
|
924
|
+
if (v < minSz) minSz = v
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const pres = parts.get('ppt/presentation.xml')?.toString('utf8') ?? ''
|
|
928
|
+
const cx = Number(pres.match(/cx="(\d+)"/)?.[1] ?? 0)
|
|
929
|
+
const cy = Number(pres.match(/cy="(\d+)"/)?.[1] ?? 0)
|
|
930
|
+
const sizeOk = cx === ctx.size.width * 12700 && cy === ctx.size.height * 12700
|
|
931
|
+
const fontText = minFontSize == null ? '—' : `${minSz / 100}pt ≥ ${minFontSize}pt`
|
|
932
|
+
const fontNote = minFontSize == null ? '(无强制下限——下限按用户指令)' : ''
|
|
933
|
+
const fontOk = minFontSize == null ? true : minSz / 100 >= minFontSize
|
|
934
|
+
const lines = [
|
|
935
|
+
`audit 回读断言(产物 OOXML):页数 ${slideCount}/${ctx.pages.length} ${slideCount === ctx.pages.length ? '✓' : '✗'};尺寸 ${(cx / 12700).toFixed(0)}×${(cy / 12700).toFixed(0)}pt(期望 ${ctx.size.width}×${ctx.size.height})${sizeOk ? '✓' : '✗'};最小字号 ${fontText}(下限 ${minFontSize != null ? minFontSize + 'pt' : '未设置'})${fontOk ? '✓' : '✗'}${fontNote}`,
|
|
936
|
+
]
|
|
937
|
+
return lines.join('\n')
|
|
938
|
+
} catch (e) {
|
|
939
|
+
return `audit 回读断言失败(无法校验产物):${e?.message ?? e}`
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function errText(error) {
|
|
944
|
+
if (error instanceof PptError) return error.messages.map((m) => ` - ${m}`).join('\n')
|
|
945
|
+
return ` - ${error?.message ?? String(error)}`
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** 统计目录内 PNG 数(模板整页参考计数)。 */
|
|
949
|
+
async function countPng(dir) {
|
|
950
|
+
try {
|
|
951
|
+
return (await readdir(dir)).filter((f) => /\.png$/i.test(f)).length
|
|
952
|
+
} catch {
|
|
953
|
+
return 0
|
|
954
|
+
}
|
|
955
|
+
}
|