dsh-ppt 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -14
- package/LICENSE +21 -21
- package/README.en.md +22 -191
- package/README.md +22 -206
- package/cordis.patch.yml +19 -19
- package/docs/CHANGELOG.en.md +29 -0
- package/docs/FEATURE-PLAN-2026-10-01.md +50 -50
- package/docs/LIBREOFFICE-INTEGRATION-2026-10-01.md +69 -69
- package/docs/USAGE.en.md +194 -0
- package/docs/USAGE.md +209 -0
- package/docs/VALIDATION.md +15 -0
- package/docs/validation/0.8.0.md +10 -0
- package/lib/client.js +209 -0
- package/lib/execution.d.ts +1 -0
- package/lib/execution.js +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +15 -1
- package/lib/tools.js +6 -5
- package/lib/types.d.ts +1 -0
- package/lib/workbench.d.ts +32 -0
- package/lib/workbench.js +266 -0
- package/package.json +10 -3
- package/skills/dsh-ppt/SKILL.md +1 -1
- package/skills/dsh-ppt/package.json +11 -11
- package/skills/dsh-ppt/references/advanced.md +1 -1
- package/skills/dsh-ppt/references/copywriting.md +59 -59
- package/skills/dsh-ppt/references/syntax.md +1 -1
- package/skills/dsh-ppt/references/themes.md +61 -61
- package/skills/dsh-ppt/references/troubleshooting.md +1 -1
- package/skills/dsh-ppt/scripts/build-deck.mjs +184 -184
- package/skills/dsh-ppt/scripts/deck-advanced.mjs +17 -4
- package/skills/dsh-ppt/scripts/deck-core.mjs +715 -703
- package/skills/dsh-ppt/scripts/deck-delivery.mjs +104 -0
- package/skills/dsh-ppt/scripts/deck-media.mjs +160 -160
- package/skills/dsh-ppt/scripts/deck-render.mjs +41 -7
package/lib/client.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Browser source, distributed directly. tsc deliberately does not rewrite this file.
|
|
2
|
+
window.__ModuleLoader__.load({ id: 'dsh-ppt', factory: require => {
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect, useRef, useMemo } = React;
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const ROUTE = 'api/dsh-ppt/workbench';
|
|
7
|
+
const fields = [['title', '标题'], ['subtitle', '副标题'], ['kicker', '眉题'], ['text', '正文'], ['bullets', '要点(每行一条)'], ['notes', '演讲备注']];
|
|
8
|
+
const css = `
|
|
9
|
+
.pptw {color:var(--fg,#27354b);font:14px/1.6 system-ui,sans-serif;min-width:0;max-width:1140px;margin:0 auto;padding:8px}
|
|
10
|
+
.pptw *{box-sizing:border-box}.pptw h2{font-size:22px;margin:0}.pptw h3{font-size:16px;margin:12px 0 6px}
|
|
11
|
+
.pptw .muted{color:var(--fg-muted,#66768a);font-size:13px}.pptw .tools{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0;align-items:center}
|
|
12
|
+
.pptw button{font:inherit;border:1px solid var(--border,#ccd8e5);border-radius:9px;background:var(--bg-surface,#fff);color:inherit;padding:7px 12px;cursor:pointer;text-align:left;white-space:normal}
|
|
13
|
+
.pptw button.primary{background:#245ee9;border-color:#245ee9;color:#fff}.pptw button:disabled{opacity:.45;cursor:default}
|
|
14
|
+
.pptw input:not([type=file]),.pptw textarea,.pptw select{font:inherit;color:inherit;width:100%;background:var(--bg,#fff);border:1px solid var(--border,#c9d5e4);border-radius:8px;padding:8px 10px}
|
|
15
|
+
.pptw textarea{resize:vertical}.pptw label{display:block;margin:8px 0 3px}.pptw .layout{display:grid;grid-template-columns:minmax(130px,190px) minmax(0,1fr);gap:14px}
|
|
16
|
+
.pptw .pages{display:flex;flex-direction:column;gap:5px;max-height:480px;overflow:auto}.pptw .pages button[aria-current=true]{background:#245ee9;color:#fff}
|
|
17
|
+
.pptw .preview{border:1px solid var(--border,#ced8e6);border-radius:12px;overflow:hidden;background:#f0f4fa;aspect-ratio:16/9;position:relative}
|
|
18
|
+
.pptw iframe{position:absolute;inset:0;width:100%;height:100%;border:0}.pptw .render-page{width:100%;display:block}
|
|
19
|
+
.pptw .notice{border-radius:9px;padding:10px 12px;margin:10px 0;background:#edf4ff;color:#17408e;overflow-wrap:anywhere}.pptw .error{background:#fff1ef;color:#922d20}
|
|
20
|
+
.pptw .card{border:1px solid var(--border,#d5dfeb);border-radius:12px;padding:12px;margin:10px 0;background:var(--bg-surface,#fff)}
|
|
21
|
+
.pptw .project-list{display:flex;flex-direction:column;gap:8px}.pptw .project-list button{display:block;width:100%}
|
|
22
|
+
.pptw .file{font-size:12px;overflow-wrap:anywhere}.pptw .image-preview{width:100%;max-height:160px;object-fit:contain;background:#edf2f8;border-radius:8px}
|
|
23
|
+
.pptw :focus-visible{outline:3px solid #78a8ff;outline-offset:2px}.pptw .dirty{color:#986114;font-size:13px}
|
|
24
|
+
[role=dialog]:has(.pptw){width:min(1200px,calc(100vw - 48px))!important;max-width:1200px!important}
|
|
25
|
+
@media(max-width:700px){.pptw .layout{grid-template-columns:1fr}.pptw .pages{max-height:180px}.pptw .preview{min-height:190px}.pptw{padding:3px}
|
|
26
|
+
[role=dialog]:has(.pptw){width:100%!important;max-width:100%!important;flex-direction:column!important}
|
|
27
|
+
[role=dialog]:has(.pptw)>nav+div{min-width:0;width:100%}
|
|
28
|
+
[role=dialog]:has(.pptw)>nav+div>div:has(.pptw){padding:12px!important;min-width:0}
|
|
29
|
+
[role=dialog]:has(.pptw) nav{width:100%!important;flex-basis:auto!important;border-right:0;max-height:170px;overflow:auto}
|
|
30
|
+
[role=dialog]:has(.pptw) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;flex-shrink:0;gap:4px}
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
async function action(body, signal) {
|
|
34
|
+
const response = await fetch(new URL(ROUTE, document.baseURI), { method: 'POST', credentials: 'same-origin', signal,
|
|
35
|
+
headers: { 'content-type': 'application/json', 'x-dsh-ppt-action': '1' }, body: JSON.stringify(body) });
|
|
36
|
+
let value;
|
|
37
|
+
try { value = await response.json(); } catch { throw new Error('无法读取服务响应,请检查插件是否加载') }
|
|
38
|
+
if (!response.ok || value.ok === false) { const error = new Error((value.message || '操作未完成') + (value.installationHint ? '\n' + value.installationHint : '')); error.status = response.status; throw error; }
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function draftFor(slide) {
|
|
42
|
+
return { title: slide.title || '', subtitle: slide.subtitle || '', kicker: slide.kicker || '', text: slide.text || '',
|
|
43
|
+
bullets: (slide.bullets || []).join('\n'), notes: slide.notes || '' };
|
|
44
|
+
}
|
|
45
|
+
function editorHtml(html, nonce) {
|
|
46
|
+
const bridge = `<style>#hud,#delivery-bar,#review-panel,#progress,#notes-panel,#help-panel,#toast,#blank{display:none!important}.slide-frame{pointer-events:none!important}.slide-frame .slide{pointer-events:auto!important}</style><script>(function(){const nonce=${JSON.stringify(nonce)};
|
|
47
|
+
function tell(field){parent.postMessage({type:'dsh-ppt-select',nonce:nonce,field:field},'*')}
|
|
48
|
+
document.addEventListener('keydown',function(e){e.stopImmediatePropagation();if(e.key!=='Tab')e.preventDefault()},true);
|
|
49
|
+
document.addEventListener('touchstart',function(e){e.stopImmediatePropagation()},true);
|
|
50
|
+
document.addEventListener('touchend',function(e){e.stopImmediatePropagation()},true);
|
|
51
|
+
document.addEventListener('wheel',function(e){e.stopImmediatePropagation()},{capture:true,passive:true});
|
|
52
|
+
document.addEventListener('click',function(e){const n=e.target.closest('h1,h2,.subtitle,.kicker,.statement-title,.quote-text,.quote-attr,li');if(!n)return;
|
|
53
|
+
e.preventDefault();e.stopImmediatePropagation();let field=n.matches('li')?'bullets':n.matches('.subtitle,.quote-attr')?'subtitle':n.matches('.kicker')?'kicker':'title';tell(field)},true);
|
|
54
|
+
window.addEventListener('message',function(e){if(e.source!==parent||e.data?.nonce!==nonce||e.data.type!=='dsh-ppt-jump')return;window.__dshPpt?.jump(e.data.page)});
|
|
55
|
+
})();<\/script>`;
|
|
56
|
+
const policy = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; connect-src 'none'; form-action 'none'">`;
|
|
57
|
+
// Presenter code contains a quoted </body>; only append at the document end.
|
|
58
|
+
return html.replace(/<head>/i, '<head>' + policy).replace(/<\/body>(\s*<\/html>\s*)$/i, bridge + '</body>$1');
|
|
59
|
+
}
|
|
60
|
+
function Workbench() {
|
|
61
|
+
const [projects, setProjects] = useState([]), [project, setProject] = useState(null), [selected, setSelected] = useState('');
|
|
62
|
+
const [draft, setDraft] = useState({}), [image, setImage] = useState(null), [dirty, setDirty] = useState(false);
|
|
63
|
+
const [busy, setBusy] = useState(false), [job, setJob] = useState(''), [message, setMessage] = useState(''), [error, setError] = useState('');
|
|
64
|
+
const [mode, setMode] = useState('html'), [raster, setRaster] = useState('');
|
|
65
|
+
const [conflict, setConflict] = useState(false), [overlaps, setOverlaps] = useState([]);
|
|
66
|
+
const frame = useRef(null), previewBox = useRef(null), fieldRefs = useRef({}), alive = useRef(true), operations = useRef(new Set());
|
|
67
|
+
const nonce = useRef(crypto.randomUUID());
|
|
68
|
+
const previewDocument = useMemo(() => project?.html ? editorHtml(project.html, nonce.current) : '', [project?.html]);
|
|
69
|
+
const index = project ? Math.max(0, project.slides.findIndex(s => s.id === selected)) : 0;
|
|
70
|
+
const slide = project?.slides[index];
|
|
71
|
+
function adopt(next, preferred) {
|
|
72
|
+
setProject(next);
|
|
73
|
+
const row = next.slides.find(s => s.id === preferred) || next.slides[0];
|
|
74
|
+
setSelected(row.id); setDraft(draftFor(row)); setImage(null); setDirty(false); setRaster(''); setOverlaps([]);
|
|
75
|
+
if (!next.render) setMode('html');
|
|
76
|
+
}
|
|
77
|
+
async function perform(work, success, label = '正在处理,请稍候…') {
|
|
78
|
+
setBusy(true); setJob(label); setError(''); setMessage(''); setConflict(false);
|
|
79
|
+
const controller = new AbortController(); operations.current.add(controller);
|
|
80
|
+
try { const result = await work(controller.signal); if (alive.current && success) success(result); }
|
|
81
|
+
catch (e) { if (alive.current) { setError(e.message || '操作失败'); setConflict(e.status === 409); } }
|
|
82
|
+
finally { operations.current.delete(controller); if (alive.current) setBusy(false); }
|
|
83
|
+
}
|
|
84
|
+
function refreshList() { return perform(signal => action({ operation: 'list' }, signal), value => setProjects(value.projects)); }
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
alive.current = true; const abort = new AbortController();
|
|
87
|
+
action({ operation: 'list' }, abort.signal).then(value => { if (alive.current) setProjects(value.projects) }).catch(e => { if (!abort.signal.aborted) setError(e.message) });
|
|
88
|
+
return () => { alive.current = false; abort.abort(); for (const operation of operations.current) operation.abort(); };
|
|
89
|
+
}, []);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
function receive(event) {
|
|
92
|
+
if (event.source !== frame.current?.contentWindow || event.data?.nonce !== nonce.current || event.data.type !== 'dsh-ppt-select' || mode !== 'html') return;
|
|
93
|
+
const key = event.data.field;
|
|
94
|
+
const field = fields.find(([field]) => field === key);
|
|
95
|
+
if (field) { fieldRefs.current[key]?.focus(); fieldRefs.current[key]?.scrollIntoView({ block: 'nearest' }); setMessage('已定位到' + field[1] + ',在字段中修改后保存。'); }
|
|
96
|
+
}
|
|
97
|
+
window.addEventListener('message', receive); return () => window.removeEventListener('message', receive);
|
|
98
|
+
}, [mode]);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (mode !== 'html' || !project || !previewBox.current) return;
|
|
101
|
+
// Align iframe edges/dimensions when the host uses fractional text metrics.
|
|
102
|
+
const box = previewBox.current;
|
|
103
|
+
function align() {
|
|
104
|
+
const target = frame.current; if (!target) return;
|
|
105
|
+
const r = box.getBoundingClientRect(), x = r.x + box.clientLeft, y = r.y + box.clientTop;
|
|
106
|
+
target.style.width = box.clientWidth + 'px'; target.style.height = box.clientHeight + 'px';
|
|
107
|
+
target.style.transform = 'translate(' + (Math.ceil(x) - x) + 'px,' + (Math.ceil(y) - y) + 'px)';
|
|
108
|
+
}
|
|
109
|
+
align(); const observer = new ResizeObserver(align); observer.observe(box);
|
|
110
|
+
window.addEventListener('scroll', align, true);
|
|
111
|
+
return () => { observer.disconnect(); window.removeEventListener('scroll', align, true); };
|
|
112
|
+
}, [mode, !!project]);
|
|
113
|
+
function jump() { frame.current?.contentWindow?.postMessage({ type: 'dsh-ppt-jump', nonce: nonce.current, page: index }, '*'); }
|
|
114
|
+
useEffect(() => { jump(); }, [index, project?.revision, mode]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (mode !== 'pptx' || !project?.render) return;
|
|
117
|
+
const abort = new AbortController(); setRaster('');
|
|
118
|
+
action({ operation: 'page', id: project.id, page: index + 1 }, abort.signal).then(value => setRaster(value.data)).catch(e => { if (!abort.signal.aborted) setError(e.message) });
|
|
119
|
+
return () => abort.abort();
|
|
120
|
+
}, [mode, index, project?.revision]);
|
|
121
|
+
function open(id) { return perform(signal => action({ operation: 'open', id }, signal), value => { adopt(value.project); setMessage('点击预览里的文字,或在下方修改字段。'); }); }
|
|
122
|
+
function keepInputsAndReload() {
|
|
123
|
+
const original = draftFor(slide), keys = fields.filter(([key]) => draft[key] !== original[key]).map(([key]) => key), pending = { ...draft }, pendingImage = image;
|
|
124
|
+
return perform(signal => action({ operation: 'open', id: project.id }, signal), value => {
|
|
125
|
+
const row = value.project.slides.find(s => s.id === selected);
|
|
126
|
+
if (!row) { setError('当前页已被删除,输入仍保留,请复制需要的内容后取消修改。'); return; }
|
|
127
|
+
const latest = draftFor(row), merged = { ...latest }, shared = [];
|
|
128
|
+
for (const key of keys) { merged[key] = pending[key]; if (latest[key] !== original[key] && latest[key] !== pending[key]) shared.push({ key, latest: latest[key], yours: pending[key] }); }
|
|
129
|
+
adopt(value.project, selected); setDraft(merged); setImage(pendingImage); setDirty(keys.length > 0 || !!pendingImage); setOverlaps(shared);
|
|
130
|
+
setMessage('已载入最新版本并保留你的修改,请核对后再保存。');
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function choose(row) { if (dirty || busy) return; setSelected(row.id); setDraft(draftFor(row)); setImage(null); }
|
|
134
|
+
function save() {
|
|
135
|
+
const original = draftFor(slide), patch = {};
|
|
136
|
+
for (const [key] of fields) if (draft[key] !== original[key]) patch[key] = key === 'bullets' ? draft[key].split('\n').map(v => v.trim()).filter(Boolean) : draft[key];
|
|
137
|
+
if (image) patch.image = { src: image.src, alt: image.alt, fit: image.fit, caption: image.caption };
|
|
138
|
+
return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, slide: slide.id, patch }, signal), value => {
|
|
139
|
+
adopt(value.project, slide.id); setMessage(value.changed === false ? '内容没有变化。' : '已保存,HTML、PPTX 和工程文件已同步更新。');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function move(delta) {
|
|
143
|
+
const order = project.slides.map(s => s.id); [order[index], order[index + delta]] = [order[index + delta], order[index]];
|
|
144
|
+
return perform(signal => action({ operation: 'edit', id: project.id, expectedRevision: project.revision, order }, signal), value => { adopt(value.project, slide.id); setMessage('页面顺序已更新,可撤销。'); });
|
|
145
|
+
}
|
|
146
|
+
function undo() { return perform(signal => action({ operation: 'undo', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMessage('已撤销上次修改。'); }); }
|
|
147
|
+
function render() {
|
|
148
|
+
return perform(signal => action({ operation: 'render', id: project.id, expectedRevision: project.revision }, signal), value => { adopt(value.project, selected); setMode('pptx'); setMessage('当前 PPTX 已生成逐页图片,请查看画面。'); }, '正在渲染最终 PPTX,较大文件可能需要几分钟…');
|
|
149
|
+
}
|
|
150
|
+
function download(kind) {
|
|
151
|
+
const url = new URL('api/dsh-ppt/download', document.baseURI);
|
|
152
|
+
url.searchParams.set('id', project.id); url.searchParams.set('kind', kind); url.searchParams.set('revision', String(project.revision));
|
|
153
|
+
// An authenticated attachment response keeps the click's user gesture and
|
|
154
|
+
// works in WebViews whose native download handler cannot read blob URLs.
|
|
155
|
+
const link = document.createElement('a'); link.href = url.href; link.download = '';
|
|
156
|
+
link.hidden = true; document.body.append(link); link.click(); link.remove();
|
|
157
|
+
setMessage('已请求下载,请查看浏览器的下载列表。');
|
|
158
|
+
}
|
|
159
|
+
function upload(event) {
|
|
160
|
+
const file = event.target.files?.[0]; event.target.value = ''; if (!file) return;
|
|
161
|
+
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 8 * 1024 * 1024) { setError('请选择 8 MiB 以内的 PNG 或 JPEG 图片。'); return; }
|
|
162
|
+
const reader = new FileReader();
|
|
163
|
+
reader.onload = () => { if (!alive.current) return; setImage({ src: String(reader.result), alt: slide.image?.alt || file.name.replace(/\.[^.]+$/, ''), caption: slide.image?.caption || '', fit: slide.image?.fit || 'contain', name: file.name }); setDirty(true); setError(''); };
|
|
164
|
+
reader.onerror = () => setError('无法读取图片,请重试。'); reader.readAsDataURL(file);
|
|
165
|
+
}
|
|
166
|
+
const currentImage = image?.src || (slide?.image?.assetId && project.assets[slide.image.assetId]?.data);
|
|
167
|
+
return h('div', { className: 'pptw' }, h('style', null, css),
|
|
168
|
+
h('h2', null, '演示文稿编辑'), h('p', { className: 'muted' }, '修改本插件生成的成品,保存时同步更新 HTML、可编辑 PPTX 和工程文件。'),
|
|
169
|
+
error && h('div', { role: 'alert', className: 'notice error' }, error),
|
|
170
|
+
conflict && project && h('button', { disabled: busy, onClick: keepInputsAndReload }, '载入最新版本,保留我的输入'),
|
|
171
|
+
message && h('div', { role: 'status', className: 'notice' }, message),
|
|
172
|
+
busy && h('div', { role: 'status', className: 'muted' }, job),
|
|
173
|
+
!project ? h(React.Fragment, null,
|
|
174
|
+
h('div', { className: 'tools' }, h('button', { onClick: refreshList, disabled: busy }, '刷新项目')),
|
|
175
|
+
projects.length ? h('div', { className: 'project-list' }, projects.map(row => h('button', { key: row.id, onClick: () => open(row.id), disabled: busy }, h('strong', null, row.title), h('div', { className: 'file muted' }, row.path)))) :
|
|
176
|
+
h('div', { className: 'card' }, h('h3', null, '还没有可以编辑的项目'), h('p', null, '先在对话中让助手生成演示文稿。已有项目可以让助手调用 ppt_check 检查一次,它会出现在这里。'), h('p', { className: 'muted' }, '示例:请把这份材料制作成可直接交付的演示文稿,生成 HTML、PPTX 和工程文件。'))
|
|
177
|
+
) : h(React.Fragment, null,
|
|
178
|
+
h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => { setProject(null); refreshList(); } }, '返回项目'), h('strong', null, project.title), h('span', { className: 'muted' }, project.slides.length + ' 页 · 修订 ' + project.revision)),
|
|
179
|
+
h('div', { className: 'layout' },
|
|
180
|
+
h('div', null, h('h3', null, '页面'), h('div', { className: 'pages' }, project.slides.map((row, i) => h('button', { key: row.id, 'aria-current': row.id === selected ? 'true' : undefined, disabled: busy || dirty, onClick: () => choose(row) }, (i + 1) + '. ' + (row.title || '未命名页')))),
|
|
181
|
+
h('div', { className: 'tools' }, h('button', { disabled: busy || dirty || index === 0, onClick: () => move(-1) }, '上移'), h('button', { disabled: busy || dirty || index === project.slides.length - 1, onClick: () => move(1) }, '下移'))),
|
|
182
|
+
h('div', null,
|
|
183
|
+
h('div', { className: 'tools' }, h('button', { disabled: busy, 'aria-pressed': mode === 'html', onClick: () => setMode('html') }, 'HTML 预览'),
|
|
184
|
+
h('button', { disabled: busy || !project.render, 'aria-pressed': mode === 'pptx', onClick: () => setMode('pptx') }, '最终 PPTX 预览'),
|
|
185
|
+
h('button', { disabled: busy || dirty, onClick: render }, '渲染当前 PPTX')),
|
|
186
|
+
h('div', { className: 'preview', ref: previewBox }, mode === 'html' ? (project.html ? h('iframe', { ref: frame, title: '演示文稿 HTML 预览', sandbox: 'allow-scripts', srcDoc: previewDocument, onLoad: jump }) : h('p', null, '此项目尚无 HTML,请保存一次生成预览。')) : raster ? h('img', { className: 'render-page', src: raster, alt: '最终 PPTX 第 ' + (index + 1) + ' 页渲染结果' }) : h('p', null, '正在载入当前页渲染…')),
|
|
187
|
+
h('p', { className: 'muted' }, mode === 'html' ? '这是 HTML 放映预览。最终 PPTX 的字体和排版请用上方渲染检查。点击文字可定位编辑字段。' : '这是当前 PPTX 文件的实际渲染图片,仍需检查每页画面;PowerPoint / WPS 可能因字体环境而不同。'),
|
|
188
|
+
project.render?.missingFonts?.length ? h('div', { role: 'alert', className: 'notice error' }, '渲染引擎报告缺失字体:' + project.render.missingFonts.join('、') + '。请替换为目标电脑可用字体后重新渲染。') : null,
|
|
189
|
+
h('div', { className: 'card' }, h('h3', null, '编辑第 ' + (index + 1) + ' 页'),
|
|
190
|
+
dirty && h('p', { className: 'dirty' }, '有未保存修改,请先保存或取消,再切换页面。'),
|
|
191
|
+
overlaps.length ? h('div', { className: 'notice' }, h('strong', null, '这些字段也被修改过,请比较后决定是否保存:'), overlaps.map(row => h('div', { key: row.key }, h('strong', null, fields.find(([key]) => key === row.key)[1]), h('p', null, '最新版本:' + row.latest), h('p', null, '你的修改:' + row.yours)))) : null,
|
|
192
|
+
fields.filter(([key]) => key === 'title' || key === 'notes' || key === 'bullets' && (slide.layout === 'bullets' || slide.bullets?.length > 0) || key !== 'bullets' && (slide[key] || key === 'subtitle' && ['cover','section','closing','quote'].includes(slide.layout) || key === 'kicker' && ['cover','section','closing'].includes(slide.layout))).map(([key, label]) => h('div', { key }, h('label', { htmlFor: 'pptw-' + key }, label), h(key === 'title' || key === 'kicker' ? 'input' : 'textarea', { id: 'pptw-' + key, ref: el => { fieldRefs.current[key] = el }, disabled: busy, value: draft[key] || '', rows: key === 'bullets' ? 4 : 2, maxLength: 8000, onChange: e => { setDraft({ ...draft, [key]: e.target.value }); setDirty(true); } }))),
|
|
193
|
+
h('label', { htmlFor: 'pptw-image' }, slide.image ? '替换图片(PNG / JPEG)' : '添加图片(会改为图文布局)'),
|
|
194
|
+
h('input', { id: 'pptw-image', type: 'file', accept: 'image/png,image/jpeg', disabled: busy, onChange: upload }),
|
|
195
|
+
currentImage && h('img', { className: 'image-preview', src: currentImage, alt: image?.alt || slide.image?.alt || '当前图片' }),
|
|
196
|
+
image && h(React.Fragment, null, h('p', { className: 'muted' }, '已选择:' + image.name), h('label', { htmlFor: 'pptw-alt' }, '图片说明'), h('input', { id: 'pptw-alt', value: image.alt, disabled: busy, onChange: e => setImage({ ...image, alt: e.target.value }) }), h('label', { htmlFor: 'pptw-fit' }, '图片适应方式'), h('select', { id: 'pptw-fit', value: image.fit, disabled: busy, onChange: e => setImage({ ...image, fit: e.target.value }) }, h('option', { value: 'contain' }, '完整显示'), h('option', { value: 'cover' }, '填满并裁切'))),
|
|
197
|
+
h('div', { className: 'tools' }, h('button', { className: 'primary', disabled: busy || !dirty, onClick: save }, '保存此页'), h('button', { disabled: busy || !dirty, onClick: () => { setDraft(draftFor(slide)); setImage(null); setDirty(false); setError(''); setConflict(false); setOverlaps([]); } }, '取消修改'), h('button', { disabled: busy || dirty || !project.undoAvailable, onClick: undo }, '撤销上次修改'))),
|
|
198
|
+
h('details', { className: 'card' }, h('summary', null, '质量提醒(' + project.quality.warningCount + ')'), (project.quality.issues || []).map((issue, i) => h('p', { key: i }, '第 ' + issue.slide + ' 页:' + issue.message + ';' + issue.suggestion))),
|
|
199
|
+
h('div', { className: 'tools' }, h('button', { disabled: busy || dirty, onClick: () => download('pptx') }, '下载最新 PPTX'), h('button', { disabled: busy || dirty, onClick: () => download('html') }, '下载 HTML'), h('button', { disabled: busy || dirty, onClick: () => download('json') }, '备份工程'))
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
function apply(ctx) {
|
|
206
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-ppt', order: 58, label: () => '演示文稿', inject: () => ({}) }, Workbench));
|
|
207
|
+
}
|
|
208
|
+
return { inject: ['slots'], apply, __internals: { draftFor, editorHtml } };
|
|
209
|
+
} });
|
package/lib/execution.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface PptExecution {
|
|
|
11
11
|
};
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
export declare function getEngine(): Promise<DeckEngine>;
|
|
14
15
|
/** Build executors with a shared engine loader; resolve paths separately for each call. */
|
|
15
16
|
export declare function createPptExecutors(config: ResolvedPptConfig, loadEngine?: () => Promise<DeckEngine>): {
|
|
16
17
|
themes(rawArgs: unknown, exec?: PptExecution): Promise<PptThemesResult>;
|
package/lib/execution.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
let enginePromise = null;
|
|
3
|
-
function getEngine() {
|
|
3
|
+
export function getEngine() {
|
|
4
4
|
if (enginePromise === null) {
|
|
5
5
|
const engineUrl = new URL('../skills/dsh-ppt/scripts/deck-advanced.mjs', import.meta.url);
|
|
6
6
|
enginePromise = import(engineUrl.href).then((module) => module)
|
package/lib/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface PptPluginContext {
|
|
|
16
16
|
warn?(message: string): void;
|
|
17
17
|
};
|
|
18
18
|
on?(event: string, listener: () => void): () => void;
|
|
19
|
+
inject?(services: string[], callback: (ctx: any) => void): unknown;
|
|
19
20
|
}
|
|
20
21
|
export declare function apply(ctx: PptPluginContext, config?: Config): void;
|
|
21
22
|
export { resolvePptConfig, PPT_OUTPUT_DIR_ENV, DEFAULT_MAX_SLIDES, clampInt } from './config.js';
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { resolvePptConfig } from './config.js';
|
|
19
19
|
import { registerPptSkill } from './skill.js';
|
|
20
20
|
import { buildPptTools } from './tools.js';
|
|
21
|
+
import { PptWorkbench, installPptWorkbench } from './workbench.js';
|
|
21
22
|
/** cordis 服务注入:apply 里要使用 ctx.tools 与 ctx.skills。 */
|
|
22
23
|
export const inject = ['tools', 'skills'];
|
|
23
24
|
export const name = 'dsh-ppt';
|
|
@@ -25,6 +26,9 @@ export function apply(ctx, config = {}) {
|
|
|
25
26
|
const resolved = resolvePptConfig(config);
|
|
26
27
|
const warn = (message) => { ctx.logger?.warn?.(message); };
|
|
27
28
|
const disposers = [];
|
|
29
|
+
const workbench = typeof ctx.inject === 'function' ? new PptWorkbench() : undefined;
|
|
30
|
+
if (workbench)
|
|
31
|
+
installPptWorkbench(ctx, workbench);
|
|
28
32
|
// 技能注册:单个技能文件缺失只告警,不弄崩宿主启动。
|
|
29
33
|
try {
|
|
30
34
|
disposers.push(registerPptSkill(ctx));
|
|
@@ -33,7 +37,17 @@ export function apply(ctx, config = {}) {
|
|
|
33
37
|
warn('[dsh-ppt] 技能加载失败:' + (error instanceof Error ? error.message : String(error)));
|
|
34
38
|
}
|
|
35
39
|
for (const definition of buildPptTools(resolved)) {
|
|
36
|
-
|
|
40
|
+
const execute = definition.execute;
|
|
41
|
+
disposers.push(ctx.tools.register({ ...definition, async execute(args, exec) {
|
|
42
|
+
const result = await execute(args, exec);
|
|
43
|
+
try {
|
|
44
|
+
await workbench?.remember(result, args, exec);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
warn('[dsh-ppt] 项目索引未更新:' + (error instanceof Error ? error.message : String(error)));
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
} }));
|
|
37
51
|
}
|
|
38
52
|
if (typeof ctx.on === 'function') {
|
|
39
53
|
ctx.on('dispose', () => {
|
package/lib/tools.js
CHANGED
|
@@ -109,10 +109,10 @@ function renderThemes(value) {
|
|
|
109
109
|
}
|
|
110
110
|
function renderCreate(value) {
|
|
111
111
|
return oneText('dsh-ppt 已生成 ' + value.slideCount + ' 页' + (value.deliveryStatus === 'draft' ? '草稿(还有待填写内容,不能作为成品交付)' : '演示文稿') + '(主题 ' + value.theme + ',语言 ' + value.language + '):\n' +
|
|
112
|
-
'
|
|
113
|
-
'PPTX
|
|
114
|
-
'
|
|
115
|
-
'HTML
|
|
112
|
+
'打开演示文稿:' + value.htmlPath + '\n' +
|
|
113
|
+
'可直接修改的 PPTX:' + value.pptxPath + '\n' +
|
|
114
|
+
'可继续编辑的项目:' + value.jsonPath + '\n' +
|
|
115
|
+
'HTML 双击即可放映;可点击上一页/下一页、输入页码,以及“检查与导出”定位问题或下载 PPTX。PPTX 可用 PowerPoint / WPS / Keynote 直接编辑。' +
|
|
116
116
|
(value.revision !== undefined ? '\n修订号:' + value.revision + ';可撤销修改:' + value.undoAvailable + '。用 ppt_edit 修改指定页,ppt_undo 撤销。' : '') +
|
|
117
117
|
(value.quality ? '\n\n' + qualityText(value.quality) : ''));
|
|
118
118
|
}
|
|
@@ -215,6 +215,7 @@ export function buildPptTools(config) {
|
|
|
215
215
|
description: 'Modify selected slides in an existing dsh-ppt JSON project, preserving other pages and stable IDs. Each edit uses a 1-based slide number or ID and a patch of changed fields. Regenerates the HTML/PPTX/JSON trio atomically, embeds new images, stores undo history. Use ppt_check first and expectedRevision. 中文:按页码或 ID 修改指定页,其他页不变,支持撤销;可同步调整品牌。',
|
|
216
216
|
parameters: compileParameters({ ...projectParameters,
|
|
217
217
|
edits: { type: 'array', items: { type: 'object', properties: { slide: { type: ['integer', 'string'], description: '1-based page number or stable slide ID' }, patch: { type: 'object', properties: slideProperties, additionalProperties: false } }, required: ['slide', 'patch'], additionalProperties: false } },
|
|
218
|
+
order: { type: 'array', items: { type: 'string' }, description: 'Reorder pages using every stable slide ID exactly once. May be used alone or together with edits; one undo restores the previous content and order.' },
|
|
218
219
|
brand: { type: ['object', 'null'], properties: brandProperties, additionalProperties: false, description: 'Merge shared branding. null clears all branding. Empty footer clears footer; logo:null clears logo.' },
|
|
219
220
|
}),
|
|
220
221
|
output: { schema: createResultSchema, render: (_args, value) => renderCreate(value) },
|
|
@@ -238,7 +239,7 @@ export function buildPptTools(config) {
|
|
|
238
239
|
},
|
|
239
240
|
{
|
|
240
241
|
name: 'ppt_render',
|
|
241
|
-
description: 'Read the final PPTX and render all pages to PNG, PDF, or both using optional official @deepseek-ai/libreoffice-kit@0.1.
|
|
242
|
+
description: 'Read the final PPTX and render all pages to PNG, PDF, or both using optional official @deepseek-ai/libreoffice-kit@0.1.5. Returns unavailable plus installation guidance when absent; ordinary deck creation remains usable. Retains original editable PPTX and creates a separate unique output folder and integrity receipt. Reports missing fonts. Inspect actual pages before delivery. 中文:把最终 PPTX 渲染为逐页图片和 PDF,报告缺失字体;需安装可选官方引擎,渲染成功仍需检查画面。',
|
|
242
243
|
parameters: compileParameters({
|
|
243
244
|
pptxPath: { type: 'string', required: true, description: 'Final .pptx path, absolute or relative to session cwd.' },
|
|
244
245
|
outputDir: { type: 'string', description: 'Base output folder. Existing folders are preserved; creates a unique sibling when needed. Default <deck>-render.' },
|
package/lib/types.d.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type PptExecution } from './execution.js';
|
|
2
|
+
import type { DeckEngine } from './types.js';
|
|
3
|
+
export declare const PPT_WORKBENCH_ROUTE = "/api/dsh-ppt/workbench";
|
|
4
|
+
export declare const PPT_DOWNLOAD_ROUTE = "/api/dsh-ppt/download";
|
|
5
|
+
type WorkbenchEngine = DeckEngine & {
|
|
6
|
+
readDeckProject(options: Record<string, unknown>): {
|
|
7
|
+
manifest: any;
|
|
8
|
+
path: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
type Options = {
|
|
12
|
+
catalogPath?: string;
|
|
13
|
+
loadEngine?: () => Promise<WorkbenchEngine>;
|
|
14
|
+
};
|
|
15
|
+
export declare class PptWorkbench {
|
|
16
|
+
readonly catalogPath: string;
|
|
17
|
+
private readonly loadEngine;
|
|
18
|
+
private readonly renders;
|
|
19
|
+
constructor(options?: Options);
|
|
20
|
+
private catalog;
|
|
21
|
+
/** Recording a completed tool never turns a successful generation into an error. */
|
|
22
|
+
remember(result: any, args: unknown, exec?: PptExecution): Promise<void>;
|
|
23
|
+
private project;
|
|
24
|
+
private view;
|
|
25
|
+
private revision;
|
|
26
|
+
private patch;
|
|
27
|
+
action(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
|
|
28
|
+
fetchDownload(request: Request): Promise<Response>;
|
|
29
|
+
fetch(request: Request): Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
export declare function installPptWorkbench(ctx: any, workbench: PptWorkbench): void;
|
|
32
|
+
export {};
|
package/lib/workbench.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/** Operator-only workbench. Paths come from successful tools, never browser input. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { getEngine } from './execution.js';
|
|
7
|
+
export const PPT_WORKBENCH_ROUTE = '/api/dsh-ppt/workbench';
|
|
8
|
+
export const PPT_DOWNLOAD_ROUTE = '/api/dsh-ppt/download';
|
|
9
|
+
const REQUEST_LIMIT = 12 * 1024 * 1024;
|
|
10
|
+
const FILE_LIMIT = 64 * 1024 * 1024;
|
|
11
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
12
|
+
function ordinaryFile(path) {
|
|
13
|
+
const stat = lstatSync(path);
|
|
14
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > FILE_LIMIT)
|
|
15
|
+
throw new Error('文件不是普通文件或超过 64 MiB');
|
|
16
|
+
return readFileSync(path);
|
|
17
|
+
}
|
|
18
|
+
function pathsFor(path) { const base = path.slice(0, -5); return { json: path, html: base + '.html', pptx: base + '.pptx' }; }
|
|
19
|
+
function json(status, value) {
|
|
20
|
+
return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
21
|
+
}
|
|
22
|
+
function fail(status, message) { return json(status, { ok: false, message }); }
|
|
23
|
+
function originVerdict(request) {
|
|
24
|
+
const site = request.headers.get('sec-fetch-site'), origin = request.headers.get('origin');
|
|
25
|
+
if (site && !['same-origin', 'none'].includes(site))
|
|
26
|
+
return fail(403, '拒绝跨站操作');
|
|
27
|
+
if (origin && /^https?:/i.test(origin)) {
|
|
28
|
+
try {
|
|
29
|
+
if (new URL(origin).host !== (request.headers.get('host') || new URL(request.url).host))
|
|
30
|
+
return fail(403, '拒绝跨源操作');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return fail(403, '无效来源');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class PptWorkbench {
|
|
38
|
+
catalogPath;
|
|
39
|
+
loadEngine;
|
|
40
|
+
renders = new Map();
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.catalogPath = options.catalogPath ?? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'plugins', 'dsh-ppt', 'projects.jsonl');
|
|
43
|
+
this.loadEngine = options.loadEngine ?? getEngine;
|
|
44
|
+
}
|
|
45
|
+
catalog() {
|
|
46
|
+
if (!existsSync(this.catalogPath))
|
|
47
|
+
return [];
|
|
48
|
+
if (lstatSync(this.catalogPath).size > 8 * 1024 * 1024)
|
|
49
|
+
throw new Error('项目索引超过 8 MiB,请备份后整理 projects.jsonl');
|
|
50
|
+
const rows = new Map();
|
|
51
|
+
for (const line of readFileSync(this.catalogPath, 'utf8').split('\n')) {
|
|
52
|
+
try {
|
|
53
|
+
const row = JSON.parse(line);
|
|
54
|
+
if (typeof row.path === 'string' && row.path.endsWith('.json') && row.id === digest(row.path) && typeof row.title === 'string' && typeof row.updatedAt === 'string') {
|
|
55
|
+
rows.delete(row.id);
|
|
56
|
+
rows.set(row.id, row);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* A crash may leave an incomplete last append; keep older rows. */ }
|
|
60
|
+
}
|
|
61
|
+
return [...rows.values()].reverse().slice(0, 100);
|
|
62
|
+
}
|
|
63
|
+
/** Recording a completed tool never turns a successful generation into an error. */
|
|
64
|
+
async remember(result, args, exec) {
|
|
65
|
+
const file = result?.jsonPath ?? result?.deckPath;
|
|
66
|
+
if (typeof file !== 'string')
|
|
67
|
+
return;
|
|
68
|
+
exec?.signal?.throwIfAborted();
|
|
69
|
+
const path = realpathSync(resolve(exec?.agent?.session.header.cwd ?? process.cwd(), file));
|
|
70
|
+
ordinaryFile(path);
|
|
71
|
+
const engine = await this.loadEngine();
|
|
72
|
+
const { manifest } = engine.readDeckProject({ deckPath: path });
|
|
73
|
+
// Versioned generator projects only; arbitrary JSON and externally edited PPTX cannot enter.
|
|
74
|
+
if (manifest.schemaVersion !== 1 || typeof manifest.deckId !== 'string' || manifest.slides.some((s) => typeof s.id !== 'string'))
|
|
75
|
+
return;
|
|
76
|
+
const row = { id: digest(path), path, title: manifest.title, updatedAt: new Date().toISOString() };
|
|
77
|
+
mkdirSync(dirname(this.catalogPath), { recursive: true });
|
|
78
|
+
if (existsSync(this.catalogPath) && lstatSync(this.catalogPath).size >= 8 * 1024 * 1024)
|
|
79
|
+
throw new Error('项目索引已满,请备份后整理 projects.jsonl');
|
|
80
|
+
appendFileSync(this.catalogPath, JSON.stringify(row) + '\n');
|
|
81
|
+
}
|
|
82
|
+
async project(id) {
|
|
83
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{64}$/.test(id))
|
|
84
|
+
throw new Error('请选择已有项目');
|
|
85
|
+
const entry = this.catalog().find(row => row.id === id);
|
|
86
|
+
if (!entry)
|
|
87
|
+
throw new Error('项目不在本机索引中,请先在对话中创建或检查演示文稿');
|
|
88
|
+
if (realpathSync(entry.path) !== entry.path)
|
|
89
|
+
throw new Error('项目位置已改变,请在对话中重新检查');
|
|
90
|
+
ordinaryFile(entry.path);
|
|
91
|
+
const engine = await this.loadEngine();
|
|
92
|
+
const { manifest } = engine.readDeckProject({ deckPath: entry.path });
|
|
93
|
+
return { entry, engine, manifest, files: pathsFor(entry.path) };
|
|
94
|
+
}
|
|
95
|
+
async view(id) {
|
|
96
|
+
const { entry, engine, manifest, files } = await this.project(id);
|
|
97
|
+
const check = engine.checkDeck({ deckPath: entry.path });
|
|
98
|
+
let html = '';
|
|
99
|
+
try {
|
|
100
|
+
html = ordinaryFile(files.html).toString('utf8');
|
|
101
|
+
}
|
|
102
|
+
catch { /* A check can register an older JSON-only project. */ }
|
|
103
|
+
const render = this.renders.get(entry.id);
|
|
104
|
+
const currentRender = render && render.revision === manifest.revision && digest(ordinaryFile(files.pptx)) === render.sourceSha256 ? render : undefined;
|
|
105
|
+
return { ok: true, project: { ...entry, deckId: manifest.deckId, revision: manifest.revision, slides: manifest.slides,
|
|
106
|
+
undoAvailable: manifest.history.length, quality: check, html,
|
|
107
|
+
assets: Object.fromEntries(Object.entries(manifest.assets ?? {}).map(([key, value]) => [key, { data: value.data, mime: value.mime }])),
|
|
108
|
+
render: currentRender ? { pageCount: currentRender.pngPaths.length, missingFonts: currentRender.missingFonts ?? [] } : null } };
|
|
109
|
+
}
|
|
110
|
+
revision(body, manifest) {
|
|
111
|
+
if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision !== manifest.revision)
|
|
112
|
+
throw new Error('项目已被修改,请重新打开后再编辑;当前输入保留');
|
|
113
|
+
}
|
|
114
|
+
patch(value) {
|
|
115
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
116
|
+
throw new Error('修改内容无效');
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const [key, item] of Object.entries(value)) {
|
|
119
|
+
if (['title', 'subtitle', 'kicker', 'text', 'notes'].includes(key)) {
|
|
120
|
+
if (typeof item !== 'string' || item.length > 8000)
|
|
121
|
+
throw new Error('文字字段过长或无效');
|
|
122
|
+
}
|
|
123
|
+
else if (key === 'bullets') {
|
|
124
|
+
if (!Array.isArray(item) || item.length > 50 || item.some(v => typeof v !== 'string' || v.length > 2000))
|
|
125
|
+
throw new Error('要点内容无效');
|
|
126
|
+
}
|
|
127
|
+
else if (key === 'image') {
|
|
128
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
129
|
+
throw new Error('请选择 PNG 或 JPEG 图片');
|
|
130
|
+
const image = item;
|
|
131
|
+
if (Object.keys(image).some(k => !['src', 'alt', 'fit', 'caption'].includes(k)) || typeof image.src !== 'string' || !/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/.test(image.src))
|
|
132
|
+
throw new Error('图片必须从面板上传,不能读取其他本地文件');
|
|
133
|
+
if (['alt', 'caption'].some(k => image[k] !== undefined && (typeof image[k] !== 'string' || image[k].length > 2000)) || (image.fit !== undefined && !['contain', 'cover'].includes(String(image.fit))))
|
|
134
|
+
throw new Error('图片说明或适应方式无效');
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
throw new Error('面板不支持修改此字段:' + key);
|
|
138
|
+
result[key] = item;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
async action(body, signal) {
|
|
143
|
+
signal?.throwIfAborted();
|
|
144
|
+
if (body.operation === 'list')
|
|
145
|
+
return { ok: true, projects: this.catalog() };
|
|
146
|
+
if (body.operation === 'open')
|
|
147
|
+
return this.view(body.id);
|
|
148
|
+
const { entry, engine, manifest, files } = await this.project(body.id);
|
|
149
|
+
signal?.throwIfAborted();
|
|
150
|
+
if (body.operation === 'download') {
|
|
151
|
+
if (!['html', 'pptx', 'json'].includes(String(body.kind)))
|
|
152
|
+
throw new Error('不支持此文件类型');
|
|
153
|
+
const path = files[body.kind];
|
|
154
|
+
return { ok: true, name: basename(path), data: ordinaryFile(path).toString('base64') };
|
|
155
|
+
}
|
|
156
|
+
if (body.operation === 'page') {
|
|
157
|
+
const render = this.renders.get(entry.id);
|
|
158
|
+
if (!render || render.revision !== manifest.revision || digest(ordinaryFile(files.pptx)) !== render.sourceSha256)
|
|
159
|
+
throw new Error('请先渲染当前版本的 PPTX');
|
|
160
|
+
if (!Number.isSafeInteger(body.page) || Number(body.page) < 1 || Number(body.page) > render.pngPaths.length)
|
|
161
|
+
throw new Error('页码无效');
|
|
162
|
+
const bytes = ordinaryFile(render.pngPaths[Number(body.page) - 1]);
|
|
163
|
+
if (digest(bytes) !== render.pngHashes[Number(body.page) - 1])
|
|
164
|
+
throw new Error('渲染图片已变化,请重新渲染当前 PPTX');
|
|
165
|
+
return { ok: true, data: 'data:image/png;base64,' + bytes.toString('base64') };
|
|
166
|
+
}
|
|
167
|
+
this.revision(body, manifest);
|
|
168
|
+
if (body.operation === 'render') {
|
|
169
|
+
const result = await engine.renderDeck({ pptxPath: files.pptx, format: 'png', signal });
|
|
170
|
+
if (result.ok)
|
|
171
|
+
this.renders.set(entry.id, { revision: manifest.revision, pngPaths: result.pngPaths ?? [], pngHashes: (result.pngPaths ?? []).map(path => digest(ordinaryFile(path))), missingFonts: result.missingFonts, sourceSha256: result.sourceSha256 });
|
|
172
|
+
const view = await this.view(entry.id);
|
|
173
|
+
if (result.ok && !view.project.render)
|
|
174
|
+
throw new Error('项目已被修改,请重新渲染');
|
|
175
|
+
return { ...result, project: view.project };
|
|
176
|
+
}
|
|
177
|
+
if (body.operation === 'edit') {
|
|
178
|
+
if (body.slide !== undefined && (typeof body.slide !== 'string' || !manifest.slides.some((s) => s.id === body.slide)))
|
|
179
|
+
throw new Error('页面不存在');
|
|
180
|
+
const args = { deckPath: entry.path, expectedRevision: body.expectedRevision, signal,
|
|
181
|
+
...(body.slide === undefined ? {} : { edits: [{ slide: body.slide, patch: this.patch(body.patch) }] }),
|
|
182
|
+
...(body.order === undefined ? {} : { order: body.order }) };
|
|
183
|
+
const result = await engine.editDeck(args);
|
|
184
|
+
await this.remember(result, args);
|
|
185
|
+
return { ...await this.view(entry.id), changed: result.changed };
|
|
186
|
+
}
|
|
187
|
+
if (body.operation === 'undo') {
|
|
188
|
+
await engine.undoDeck({ deckPath: entry.path, expectedRevision: body.expectedRevision, signal });
|
|
189
|
+
return this.view(entry.id);
|
|
190
|
+
}
|
|
191
|
+
throw new Error('不支持此操作');
|
|
192
|
+
}
|
|
193
|
+
async fetchDownload(request) {
|
|
194
|
+
if (request.method !== 'GET')
|
|
195
|
+
return fail(405, '请使用 GET');
|
|
196
|
+
const denied = originVerdict(request);
|
|
197
|
+
if (denied)
|
|
198
|
+
return denied;
|
|
199
|
+
try {
|
|
200
|
+
const params = new URL(request.url).searchParams, kind = params.get('kind');
|
|
201
|
+
if (!['html', 'pptx', 'json'].includes(String(kind)))
|
|
202
|
+
return fail(400, '不支持此文件类型');
|
|
203
|
+
const { entry, manifest, files } = await this.project(params.get('id'));
|
|
204
|
+
if (String(manifest.revision) !== params.get('revision'))
|
|
205
|
+
return fail(409, '项目已被修改,请重新打开后下载');
|
|
206
|
+
request.signal.throwIfAborted();
|
|
207
|
+
const path = files[kind], bytes = ordinaryFile(path);
|
|
208
|
+
if ((await this.project(entry.id)).manifest.revision !== manifest.revision)
|
|
209
|
+
return fail(409, '项目已被修改,请重新打开后下载');
|
|
210
|
+
const type = kind === 'pptx' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' : kind === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8';
|
|
211
|
+
return new Response(new Uint8Array(bytes), { headers: {
|
|
212
|
+
'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
|
|
213
|
+
'content-disposition': `attachment; filename="presentation.${kind}"; filename*=UTF-8''${encodeURIComponent(basename(path))}`,
|
|
214
|
+
} });
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return fail(400, error instanceof Error ? error.message : String(error));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async fetch(request) {
|
|
221
|
+
if (request.method !== 'POST')
|
|
222
|
+
return fail(405, '请使用 POST');
|
|
223
|
+
if (request.headers.get('x-dsh-ppt-action') !== '1' || request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json')
|
|
224
|
+
return fail(403, '请从演示文稿面板操作');
|
|
225
|
+
const denied = originVerdict(request);
|
|
226
|
+
if (denied)
|
|
227
|
+
return denied;
|
|
228
|
+
try {
|
|
229
|
+
const reader = request.body?.getReader();
|
|
230
|
+
if (!reader)
|
|
231
|
+
return fail(400, '缺少请求内容');
|
|
232
|
+
let size = 0;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
try {
|
|
235
|
+
for (;;) {
|
|
236
|
+
const { value, done } = await reader.read();
|
|
237
|
+
if (done)
|
|
238
|
+
break;
|
|
239
|
+
size += value.byteLength;
|
|
240
|
+
if (size > REQUEST_LIMIT) {
|
|
241
|
+
await reader.cancel();
|
|
242
|
+
return fail(413, '图片或请求超过 12 MiB,请压缩后重试');
|
|
243
|
+
}
|
|
244
|
+
chunks.push(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
reader.releaseLock();
|
|
249
|
+
}
|
|
250
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
251
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
252
|
+
return fail(400, '请求必须是对象');
|
|
253
|
+
return json(200, await this.action(body, request.signal));
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
257
|
+
return fail(/修订号冲突|已被修改|正在被另一次|其他操作修改/.test(message) ? 409 : 400, message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function installPptWorkbench(ctx, workbench) {
|
|
262
|
+
ctx.inject?.(['connection'], (host) => {
|
|
263
|
+
host.connection?.fetch?.register({ path: PPT_WORKBENCH_ROUTE, methods: ['POST'], requestBody: 'buffered', fetch: (request) => workbench.fetch(request) });
|
|
264
|
+
host.connection?.fetch?.register({ path: PPT_DOWNLOAD_ROUTE, methods: ['GET'], requestBody: 'buffered', fetch: (request) => workbench.fetchDownload(request) });
|
|
265
|
+
});
|
|
266
|
+
}
|