@capyclaw/ppt-workbench 0.0.0-stage → 2.4.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 +126 -3
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/contract/ppt-contract.json +1529 -0
- package/cordis.patch.yml +25 -0
- package/core/audit.js +500 -0
- package/core/chart.js +199 -0
- package/core/color.js +80 -0
- package/core/deck-layout.js +195 -0
- package/core/formula.js +80 -0
- package/core/hfmath/hershey.js +1763 -0
- package/core/hfmath/index.js +1176 -0
- package/core/hfmath/stretch.js +41 -0
- package/core/hfmath/symbols.js +408 -0
- package/core/ops/browser.js +28 -0
- package/core/ops/chart.js +64 -0
- package/core/ops/deck.js +21 -0
- package/core/ops/dispatcher.js +2 -0
- package/core/ops/element.js +55 -0
- package/core/ops/history.js +11 -0
- package/core/ops/import.js +95 -0
- package/core/ops/index.js +39 -0
- package/core/ops/playback.js +9 -0
- package/core/ops/recolor.js +33 -0
- package/core/ops/slide.js +66 -0
- package/core/ops/table.js +85 -0
- package/core/ops/text.js +35 -0
- package/core/ops/view.js +13 -0
- package/core/ops.js +12 -0
- package/core/quick.js +52 -0
- package/core/shape-library.js +122 -0
- package/core/table.js +178 -0
- package/core/text.js +200 -0
- package/editor/app.js +5366 -0
- package/editor/heartbeat-worker.js +116 -0
- package/editor/index.html +89 -0
- package/editor/ppt-full-app.js +376 -0
- package/lib/host.mjs +98 -0
- package/package.json +51 -4
- package/server/audit.mjs +2 -0
- package/server/bridge-client.mjs +174 -0
- package/server/constants.mjs +20 -0
- package/server/contract-snapshots.mjs +62 -0
- package/server/contract.mjs +24 -0
- package/server/deck-layout.mjs +2 -0
- package/server/http-utils.mjs +2 -0
- package/server/infra/http.mjs +113 -0
- package/server/infra/runtime/context.mjs +128 -0
- package/server/infra/store/blob-store.mjs +109 -0
- package/server/infra/tuning.mjs +82 -0
- package/server/infra/util/fs-lock.mjs +132 -0
- package/server/infra/workspace/manager.mjs +657 -0
- package/server/mcp/dispatch.mjs +2 -0
- package/server/mcp/handler.mjs +2 -0
- package/server/mcp/instructions.mjs +40 -0
- package/server/mcp-http.mjs +3 -0
- package/server/ppt-shim.mjs +156 -0
- package/server/relay.mjs +245 -0
- package/server/routes/assets.mjs +2 -0
- package/server/routes/bridge.mjs +47 -0
- package/server/routes/editor.mjs +9 -0
- package/server/routes/import.mjs +258 -0
- package/server/routes/mcp.mjs +17 -0
- package/server/runtime/recovery.mjs +9 -0
- package/server/runtime/watch.mjs +17 -0
- package/server/shared/SHARED_MANIFEST.json +47 -0
- package/server/shared/asset-service.mjs +217 -0
- package/server/shared/editor-host.mjs +125 -0
- package/server/shared/kit/editor/lease.mjs +139 -0
- package/server/shared/kit/service/api.mjs +45 -0
- package/server/shared/kit/service/http.mjs +113 -0
- package/server/shared/kit/service/mcp-http.mjs +142 -0
- package/server/shared/kit/service/single-instance.mjs +72 -0
- package/server/shared/kit/tools/validate.mjs +115 -0
- package/server/shared/kit/tuning.mjs +82 -0
- package/server/shared/kit/util/fs-lock.mjs +132 -0
- package/server/shared/service-host.mjs +511 -0
- package/server/shared/state-service.mjs +249 -0
- package/server/shared/tool-host.mjs +341 -0
- package/server/shared/tuning.mjs +63 -0
- package/server/single-instance.mjs +2 -0
- package/server/store/backends/file-backend.mjs +13 -0
- package/server/store/checkpoints.mjs +28 -0
- package/server/store/doc.mjs +52 -0
- package/server/store/index.mjs +34 -0
- package/server/store/journal.mjs +9 -0
- package/server/store/lease.mjs +2 -0
- package/server/store/sanitize.mjs +32 -0
- package/server/store.mjs +2 -0
- package/server/tools/capabilities/apply.mjs +33 -0
- package/server/tools/capabilities/audit.mjs +24 -0
- package/server/tools/capabilities/chart.mjs +83 -0
- package/server/tools/capabilities/compose.mjs +97 -0
- package/server/tools/capabilities/contract.mjs +34 -0
- package/server/tools/capabilities/deck.mjs +27 -0
- package/server/tools/capabilities/element.mjs +67 -0
- package/server/tools/capabilities/export.mjs +95 -0
- package/server/tools/capabilities/formula.mjs +72 -0
- package/server/tools/capabilities/history.mjs +29 -0
- package/server/tools/capabilities/image.mjs +152 -0
- package/server/tools/capabilities/imagesearch.mjs +152 -0
- package/server/tools/capabilities/import.mjs +180 -0
- package/server/tools/capabilities/index.mjs +58 -0
- package/server/tools/capabilities/line.mjs +58 -0
- package/server/tools/capabilities/notes.mjs +33 -0
- package/server/tools/capabilities/present.mjs +34 -0
- package/server/tools/capabilities/recolor.mjs +60 -0
- package/server/tools/capabilities/screenshot.mjs +86 -0
- package/server/tools/capabilities/shape.mjs +64 -0
- package/server/tools/capabilities/slide.mjs +81 -0
- package/server/tools/capabilities/style.mjs +48 -0
- package/server/tools/capabilities/table.mjs +127 -0
- package/server/tools/capabilities/task.mjs +52 -0
- package/server/tools/capabilities/text.mjs +82 -0
- package/server/tools/domains/color.mjs +4 -0
- package/server/tools/domains/image.mjs +49 -0
- package/server/tools/domains/index.mjs +7 -0
- package/server/tools/domains/pptx-export.mjs +566 -0
- package/server/tools/domains/recolor.mjs +25 -0
- package/server/tools/domains/remote-image.mjs +291 -0
- package/server/tools/domains/slide.mjs +21 -0
- package/server/tools/domains/text-style.mjs +11 -0
- package/server/tools/domains/tooling.mjs +13 -0
- package/server/tools/index.mjs +14 -0
- package/server/tools/kernel/define.mjs +5 -0
- package/server/tools/kernel/ports.mjs +42 -0
- package/server/tools/kernel/registry.mjs +17 -0
- package/server/tools.mjs +18 -0
- package/server/tuning.mjs +86 -0
- package/server/validate.mjs +2 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deck → PPTX 导出(Node 侧,pptxgenjs)。
|
|
3
|
+
*
|
|
4
|
+
* 只处理 PPTist deck 模型里可稳定映射的元素:文字 / 形状 / 图片 / 线条 /
|
|
5
|
+
* 表格 / 图表 / 背景 / 备注。SVG 自定义路径用矩形近似,LaTeX/音视频跳过,
|
|
6
|
+
* 所有近似与跳过项都记入 stats.warnings,由工具返回给调用方。
|
|
7
|
+
*
|
|
8
|
+
* 浏览器端不要 import 本文件:pptxgenjs 和 node:fetch 只用于 MCP 侧。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/* 延迟加载:pptxgenjs 缺失时只让导出工具失败,不拖垮整个工具注册表。 */
|
|
12
|
+
let pptxgenPromise = null
|
|
13
|
+
async function loadPptxGen() {
|
|
14
|
+
if (!pptxgenPromise) {
|
|
15
|
+
pptxgenPromise = import('pptxgenjs').then((mod) => mod.default || mod).catch((error) => {
|
|
16
|
+
pptxgenPromise = null
|
|
17
|
+
throw new Error('PPTX 导出依赖 pptxgenjs 未安装:请在 workbench 包目录执行 npm install pptxgenjs(原始错误:' + String(error?.message || error) + ')')
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
return pptxgenPromise
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_VIEWPORT_SIZE = 1000
|
|
24
|
+
const DEFAULT_VIEWPORT_RATIO = 0.5625
|
|
25
|
+
const DEFAULT_FONT = 'Microsoft YaHei'
|
|
26
|
+
|
|
27
|
+
const NAMED_COLORS = new Map(Object.entries({
|
|
28
|
+
black: '#000000', white: '#ffffff', red: '#ff0000', green: '#008000',
|
|
29
|
+
blue: '#0000ff', yellow: '#ffff00', gray: '#808080', grey: '#808080',
|
|
30
|
+
orange: '#ffa500', purple: '#800080', transparent: '#ffffff',
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const DASH = {
|
|
34
|
+
solid: 'solid',
|
|
35
|
+
dashed: 'dash',
|
|
36
|
+
dotted: 'sysDot',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseColor(input) {
|
|
40
|
+
const raw = String(input == null ? '' : input).trim().toLowerCase()
|
|
41
|
+
if (!raw) return { hex: '#000000', alpha: 1 }
|
|
42
|
+
if (raw === 'transparent') return { hex: '#ffffff', alpha: 0 }
|
|
43
|
+
if (NAMED_COLORS.has(raw)) return { hex: NAMED_COLORS.get(raw), alpha: 1 }
|
|
44
|
+
|
|
45
|
+
const rgba = raw.match(/^rgba?\(([^)]+)\)$/)
|
|
46
|
+
if (rgba) {
|
|
47
|
+
const parts = rgba[1].split(',').map((part) => part.trim())
|
|
48
|
+
const channel = (value) => {
|
|
49
|
+
const number = parseFloat(value)
|
|
50
|
+
return Number.isFinite(number) ? Math.max(0, Math.min(255, Math.round(number))) : 0
|
|
51
|
+
}
|
|
52
|
+
const r = channel(parts[0])
|
|
53
|
+
const g = channel(parts[1])
|
|
54
|
+
const b = channel(parts[2])
|
|
55
|
+
const alpha = parts[3] == null ? 1 : Math.max(0, Math.min(1, parseFloat(parts[3])))
|
|
56
|
+
return { hex: `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`, alpha: Number.isFinite(alpha) ? alpha : 1 }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hex = raw.match(/^#([0-9a-f]{3,8})$/)
|
|
60
|
+
if (hex) {
|
|
61
|
+
let value = hex[1]
|
|
62
|
+
if (value.length === 3 || value.length === 4) {
|
|
63
|
+
value = value.split('').map((char) => char + char).join('')
|
|
64
|
+
}
|
|
65
|
+
if (value.length === 6) return { hex: `#${value}`, alpha: 1 }
|
|
66
|
+
if (value.length === 8) {
|
|
67
|
+
const alpha = parseInt(value.slice(6, 8), 16) / 255
|
|
68
|
+
return { hex: `#${value.slice(0, 6)}`, alpha: Number.isFinite(alpha) ? alpha : 1 }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { hex: '#000000', alpha: 1 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transparency(color) {
|
|
76
|
+
const { alpha } = parseColor(color)
|
|
77
|
+
return Math.round((1 - alpha) * 100)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decodeEntities(value) {
|
|
81
|
+
return String(value || '')
|
|
82
|
+
.replace(/ /g, ' ')
|
|
83
|
+
.replace(/</g, '<')
|
|
84
|
+
.replace(/>/g, '>')
|
|
85
|
+
.replace(/"/g, '"')
|
|
86
|
+
.replace(/'/g, "'")
|
|
87
|
+
.replace(/&/g, '&')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function plainText(html) {
|
|
91
|
+
return decodeEntities(String(html || '')
|
|
92
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
93
|
+
.replace(/<\/p>/gi, '\n')
|
|
94
|
+
.replace(/<[^>]+>/g, ''))
|
|
95
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
96
|
+
.trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function styleOf(styleAttr) {
|
|
100
|
+
const out = {}
|
|
101
|
+
for (const item of String(styleAttr || '').split(';')) {
|
|
102
|
+
const match = item.match(/^\s*([^:]+)\s*:\s*(.+?)\s*$/)
|
|
103
|
+
if (match) out[match[1].trim().toLowerCase()] = match[2].trim()
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pxToPt(value, ratioPx2Pt, fallback) {
|
|
109
|
+
const number = parseFloat(String(value || '').replace(/px$/i, ''))
|
|
110
|
+
if (!Number.isFinite(number)) return fallback
|
|
111
|
+
return number / ratioPx2Pt
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function htmlToTextProps(html, { ratioPx2Pt, defaultFont, defaultColor, defaultSize }) {
|
|
115
|
+
const source = String(html || '')
|
|
116
|
+
if (!source.trim()) return { props: [{ text: '', options: {} }] }
|
|
117
|
+
|
|
118
|
+
const runs = []
|
|
119
|
+
const stack = [{}]
|
|
120
|
+
const listStack = []
|
|
121
|
+
let bullet = null
|
|
122
|
+
|
|
123
|
+
const currentStyle = () => stack[stack.length - 1] || {}
|
|
124
|
+
const pushBreak = () => {
|
|
125
|
+
if (!runs.length) return
|
|
126
|
+
const last = runs[runs.length - 1]
|
|
127
|
+
last.options = { ...(last.options || {}), breakLine: true }
|
|
128
|
+
}
|
|
129
|
+
const makeOptions = (style) => {
|
|
130
|
+
const options = {}
|
|
131
|
+
if (style['font-size']) options.fontSize = pxToPt(style['font-size'], ratioPx2Pt, defaultSize)
|
|
132
|
+
if (style.color) options.color = parseColor(style.color).hex
|
|
133
|
+
if (style['background-color']) options.highlight = parseColor(style['background-color']).hex
|
|
134
|
+
if (style['font-family']) options.fontFace = String(style['font-family']).replace(/["']/g, '').split(',')[0].trim()
|
|
135
|
+
if (style['font-weight']) options.bold = /bold|[6-9]00/.test(style['font-weight'])
|
|
136
|
+
if (style['font-style'] === 'italic') options.italic = true
|
|
137
|
+
if (style['text-decoration'] || style['text-decoration-line']) {
|
|
138
|
+
const decoration = String(style['text-decoration'] || style['text-decoration-line'])
|
|
139
|
+
if (decoration.includes('underline')) options.underline = { color: options.color || defaultColor, style: 'sng' }
|
|
140
|
+
if (decoration.includes('line-through')) options.strike = 'sngStrike'
|
|
141
|
+
}
|
|
142
|
+
if (style['text-align']) options.align = style['text-align']
|
|
143
|
+
if (style['vertical-align'] === 'super') options.superscript = true
|
|
144
|
+
if (style['vertical-align'] === 'sub') options.subscript = true
|
|
145
|
+
if (style.href) options.hyperlink = { url: style.href }
|
|
146
|
+
return options
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tokenRe = /<!--[\s\S]*?-->|<\/?([a-zA-Z][\w:-]*)([^>]*?)\/?>|([^<]+)/g
|
|
150
|
+
let match
|
|
151
|
+
while ((match = tokenRe.exec(source))) {
|
|
152
|
+
const [token, tagNameRaw, attrsRaw, textRaw] = match
|
|
153
|
+
if (textRaw !== undefined) {
|
|
154
|
+
const text = decodeEntities(textRaw).replace(/\n+/g, ' ')
|
|
155
|
+
if (!text) continue
|
|
156
|
+
const style = currentStyle()
|
|
157
|
+
const options = makeOptions(style)
|
|
158
|
+
if (bullet) {
|
|
159
|
+
options.bullet = bullet === 'ol' ? { type: 'number' } : { indent: (options.fontSize || defaultSize) * 1.1 }
|
|
160
|
+
bullet = null
|
|
161
|
+
}
|
|
162
|
+
runs.push({ text, options })
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (!tagNameRaw) continue
|
|
166
|
+
|
|
167
|
+
const tagName = tagNameRaw.toLowerCase()
|
|
168
|
+
const isClose = token.startsWith('</')
|
|
169
|
+
if (isClose) {
|
|
170
|
+
if (['p', 'div', 'li'].includes(tagName)) pushBreak()
|
|
171
|
+
if (tagName === 'ul' || tagName === 'ol') listStack.pop()
|
|
172
|
+
if (stack.length > 1) stack.pop()
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (tagName === 'br') {
|
|
177
|
+
runs.push({ text: '', options: { breakLine: true } })
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (['p', 'div', 'li'].includes(tagName) && runs.length) pushBreak()
|
|
181
|
+
if (tagName === 'ul' || tagName === 'ol') listStack.push(tagName)
|
|
182
|
+
if (tagName === 'li') bullet = listStack[listStack.length - 1] || 'ul'
|
|
183
|
+
|
|
184
|
+
const attrs = String(attrsRaw || '')
|
|
185
|
+
const style = { ...currentStyle() }
|
|
186
|
+
const styleMatch = attrs.match(/\bstyle\s*=\s*"([^"]*)"/i) || attrs.match(/\bstyle\s*=\s*'([^']*)'/i)
|
|
187
|
+
if (styleMatch) Object.assign(style, styleOf(styleMatch[1]))
|
|
188
|
+
const hrefMatch = attrs.match(/\bhref\s*=\s*["']([^"']*)["']/i)
|
|
189
|
+
if (hrefMatch) style.href = hrefMatch[1]
|
|
190
|
+
|
|
191
|
+
if (tagName === 'strong' || tagName === 'b') style['font-weight'] = 'bold'
|
|
192
|
+
if (tagName === 'em' || tagName === 'i') style['font-style'] = 'italic'
|
|
193
|
+
if (tagName === 'u') style['text-decoration'] = 'underline'
|
|
194
|
+
if (tagName === 's' || tagName === 'strike' || tagName === 'del') style['text-decoration'] = 'line-through'
|
|
195
|
+
if (tagName === 'sup') style['vertical-align'] = 'super'
|
|
196
|
+
if (tagName === 'sub') style['vertical-align'] = 'sub'
|
|
197
|
+
|
|
198
|
+
if (!['br', 'img', 'hr'].includes(tagName)) stack.push(style)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!runs.length) {
|
|
202
|
+
return { props: [{ text: plainText(source), options: { fontFace: defaultFont, fontSize: defaultSize, color: defaultColor } }] }
|
|
203
|
+
}
|
|
204
|
+
const last = runs[runs.length - 1]
|
|
205
|
+
if (last.options) delete last.options.breakLine
|
|
206
|
+
for (const run of runs) {
|
|
207
|
+
run.options = { fontFace: defaultFont, fontSize: defaultSize, color: defaultColor, ...(run.options || {}) }
|
|
208
|
+
}
|
|
209
|
+
return { props: runs }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function shapeTypeOf(pptx, element) {
|
|
213
|
+
const name = String(element.shapType || element.shapeType || '').toLowerCase()
|
|
214
|
+
if (name.includes('roundrect') || name.includes('round_rect')) return pptx.ShapeType.roundRect
|
|
215
|
+
if (name.includes('ellipse') || name.includes('oval') || name.includes('circle')) return pptx.ShapeType.ellipse
|
|
216
|
+
if (name.includes('triangle')) return pptx.ShapeType.triangle
|
|
217
|
+
if (name.includes('diamond')) return pptx.ShapeType.diamond
|
|
218
|
+
if (name.includes('hexagon')) return pptx.ShapeType.hexagon
|
|
219
|
+
if (name.includes('star')) return pptx.ShapeType.star5
|
|
220
|
+
return pptx.ShapeType.rect
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function fillProps(value) {
|
|
224
|
+
if (value && typeof value === 'object') {
|
|
225
|
+
const colors = Array.isArray(value.colors) ? value.colors : []
|
|
226
|
+
const first = colors[0] && (colors[0].color || colors[0])
|
|
227
|
+
if (first) return { color: parseColor(first).hex, transparency: transparency(first) }
|
|
228
|
+
if (value.type === 'solid' && value.value) return { color: parseColor(value.value).hex, transparency: transparency(value.value) }
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
if (typeof value === 'string' && value) {
|
|
232
|
+
return { color: parseColor(value).hex, transparency: transparency(value) }
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function lineProps(outline, ratioPx2Pt) {
|
|
238
|
+
if (!outline) return undefined
|
|
239
|
+
return {
|
|
240
|
+
color: parseColor(outline.color || '#000000').hex,
|
|
241
|
+
width: Math.max(0.25, pxToPt(outline.width || 1, ratioPx2Pt, 1)),
|
|
242
|
+
dashType: DASH[outline.style] || 'solid',
|
|
243
|
+
transparency: transparency(outline.color || '#000000'),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function loadImage(src, assetBase) {
|
|
248
|
+
const value = String(src || '').trim()
|
|
249
|
+
if (!value) throw new Error('图片地址为空')
|
|
250
|
+
if (value.startsWith('data:')) return { data: value }
|
|
251
|
+
|
|
252
|
+
let url = value
|
|
253
|
+
if (value.startsWith('/') && assetBase) {
|
|
254
|
+
url = new URL(value.replace(/^\/+/, ''), assetBase.replace(/\/?$/, '/')).href
|
|
255
|
+
}
|
|
256
|
+
if (/^https?:\/\//i.test(url)) {
|
|
257
|
+
const response = await fetch(url)
|
|
258
|
+
if (!response.ok) throw new Error(`图片下载失败:HTTP ${response.status}`)
|
|
259
|
+
const type = response.headers.get('content-type') || 'image/png'
|
|
260
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
261
|
+
return { data: `data:${type};base64,${buffer.toString('base64')}` }
|
|
262
|
+
}
|
|
263
|
+
if (value.startsWith('file://')) return { path: decodeURIComponent(new URL(value).pathname) }
|
|
264
|
+
return { path: value }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function cellOptions(cell, ratioPx2Pt) {
|
|
268
|
+
const style = cell && typeof cell === 'object' && cell.style ? cell.style : {}
|
|
269
|
+
const options = { valign: 'middle' }
|
|
270
|
+
if (style.bold != null) options.bold = !!style.bold
|
|
271
|
+
if (style.italic != null) options.italic = !!style.italic
|
|
272
|
+
if (style.color) options.color = parseColor(style.color).hex
|
|
273
|
+
if (style.backcolor) options.fill = { color: parseColor(style.backcolor).hex }
|
|
274
|
+
if (style.align) options.align = style.align
|
|
275
|
+
if (style.fontsize != null) options.fontSize = pxToPt(style.fontsize, ratioPx2Pt, 10)
|
|
276
|
+
if (style.fontname) options.fontFace = style.fontname
|
|
277
|
+
return options
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function chartSpec(pptx, element) {
|
|
281
|
+
const type = String(element.chartType || 'column')
|
|
282
|
+
const data = element.data || {}
|
|
283
|
+
const labels = Array.isArray(data.labels) ? data.labels.map(String) : []
|
|
284
|
+
const series = Array.isArray(data.series) ? data.series : []
|
|
285
|
+
const legends = Array.isArray(data.legends) ? data.legends.map(String) : series.map((_, i) => `系列${i + 1}`)
|
|
286
|
+
if (!labels.length || !series.length) return null
|
|
287
|
+
|
|
288
|
+
const seriesRows = series.map((values, index) => ({
|
|
289
|
+
name: legends[index] || `系列${index + 1}`,
|
|
290
|
+
labels,
|
|
291
|
+
values: values.map(Number),
|
|
292
|
+
}))
|
|
293
|
+
|
|
294
|
+
let chartType = pptx.ChartType.bar
|
|
295
|
+
let barDir = 'col'
|
|
296
|
+
if (type === 'bar') { chartType = pptx.ChartType.bar; barDir = 'bar' }
|
|
297
|
+
else if (type === 'line') chartType = pptx.ChartType.line
|
|
298
|
+
else if (type === 'area') chartType = pptx.ChartType.area
|
|
299
|
+
else if (type === 'pie') chartType = pptx.ChartType.pie
|
|
300
|
+
else if (type === 'ring') chartType = pptx.ChartType.doughnut
|
|
301
|
+
else if (type === 'radar') chartType = pptx.ChartType.radar
|
|
302
|
+
else if (type === 'scatter') chartType = pptx.ChartType.scatter
|
|
303
|
+
else if (type !== 'column') return null
|
|
304
|
+
|
|
305
|
+
const colors = Array.isArray(element.themeColors) && element.themeColors.length
|
|
306
|
+
? element.themeColors.map((color) => parseColor(color).hex)
|
|
307
|
+
: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4', '#70ad47']
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
type: chartType,
|
|
311
|
+
data: seriesRows,
|
|
312
|
+
options: {
|
|
313
|
+
barDir,
|
|
314
|
+
showLegend: seriesRows.length > 1,
|
|
315
|
+
legendPos: 'b',
|
|
316
|
+
showTitle: false,
|
|
317
|
+
showValue: false,
|
|
318
|
+
showPercent: type === 'pie' || type === 'ring',
|
|
319
|
+
chartColors: colors,
|
|
320
|
+
catAxisLabelFontSize: 10,
|
|
321
|
+
valAxisLabelFontSize: 10,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 把 deck 渲染成 PPTX Buffer。
|
|
328
|
+
* @param {object} deck PPTist deck
|
|
329
|
+
* @param {{ assetBase?: string, includeNotes?: boolean }} options
|
|
330
|
+
* @returns {Promise<{ buffer: Buffer, stats: object }>}
|
|
331
|
+
*/
|
|
332
|
+
export async function renderDeckToPptx(deck, options = {}) {
|
|
333
|
+
const slides = Array.isArray(deck?.slides) ? deck.slides : []
|
|
334
|
+
if (!slides.length) throw new Error('deck 没有幻灯片,无法导出 PPTX')
|
|
335
|
+
|
|
336
|
+
const pptxgen = await loadPptxGen()
|
|
337
|
+
const viewportSize = Number(deck.viewportSize) || DEFAULT_VIEWPORT_SIZE
|
|
338
|
+
const viewportRatio = Number(deck.viewportRatio) || DEFAULT_VIEWPORT_RATIO
|
|
339
|
+
const ratioPx2Inch = 96 * (viewportSize / 960)
|
|
340
|
+
const ratioPx2Pt = (96 / 72) * (viewportSize / 960)
|
|
341
|
+
const toIn = (value) => (Number(value) || 0) / ratioPx2Inch
|
|
342
|
+
const toPt = (value) => (Number(value) || 0) / ratioPx2Pt
|
|
343
|
+
|
|
344
|
+
const pptx = new pptxgen()
|
|
345
|
+
if (viewportRatio === 0.625) pptx.layout = 'LAYOUT_16x10'
|
|
346
|
+
else if (viewportRatio === 0.75) pptx.layout = 'LAYOUT_4x3'
|
|
347
|
+
else {
|
|
348
|
+
pptx.defineLayout({
|
|
349
|
+
name: 'PPTIST_CUSTOM_LAYOUT',
|
|
350
|
+
width: viewportSize / ratioPx2Inch,
|
|
351
|
+
height: viewportSize * viewportRatio / ratioPx2Inch,
|
|
352
|
+
})
|
|
353
|
+
pptx.layout = 'PPTIST_CUSTOM_LAYOUT'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const stats = {
|
|
357
|
+
slides: slides.length,
|
|
358
|
+
elements: 0,
|
|
359
|
+
rendered: 0,
|
|
360
|
+
skipped: 0,
|
|
361
|
+
approximated: 0,
|
|
362
|
+
unsupportedTypes: {},
|
|
363
|
+
warnings: [],
|
|
364
|
+
}
|
|
365
|
+
const warn = (message) => {
|
|
366
|
+
if (stats.warnings.length < 40) stats.warnings.push(message)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const [slideIndex, sourceSlide] of slides.entries()) {
|
|
370
|
+
const slide = pptx.addSlide()
|
|
371
|
+
const background = sourceSlide.background
|
|
372
|
+
if (background) {
|
|
373
|
+
if (background.type === 'solid' && background.color) {
|
|
374
|
+
const color = parseColor(background.color)
|
|
375
|
+
slide.background = { color: color.hex, transparency: Math.round((1 - color.alpha) * 100) }
|
|
376
|
+
} else if (background.type === 'image' && background.image?.src) {
|
|
377
|
+
try {
|
|
378
|
+
const source = await loadImage(background.image.src, options.assetBase)
|
|
379
|
+
slide.background = source.data ? { data: source.data } : { path: source.path }
|
|
380
|
+
} catch (error) {
|
|
381
|
+
warn(`第 ${slideIndex + 1} 页背景图加载失败:${String(error.message || error)}`)
|
|
382
|
+
}
|
|
383
|
+
} else if (background.type === 'gradient' && background.gradient) {
|
|
384
|
+
const first = background.gradient.colors?.[0]?.color
|
|
385
|
+
if (first) {
|
|
386
|
+
const color = parseColor(first)
|
|
387
|
+
slide.background = { color: color.hex, transparency: Math.round((1 - color.alpha) * 100) }
|
|
388
|
+
}
|
|
389
|
+
stats.approximated++
|
|
390
|
+
warn(`第 ${slideIndex + 1} 页渐变背景以首个色标近似`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const elements = Array.isArray(sourceSlide.elements) ? sourceSlide.elements : []
|
|
395
|
+
stats.elements += elements.length
|
|
396
|
+
|
|
397
|
+
for (const element of elements) {
|
|
398
|
+
try {
|
|
399
|
+
if (element.type === 'text') {
|
|
400
|
+
const { props } = htmlToTextProps(element.content, {
|
|
401
|
+
ratioPx2Pt,
|
|
402
|
+
defaultFont: element.defaultFontName || deck.theme?.fontName || DEFAULT_FONT,
|
|
403
|
+
defaultColor: element.defaultColor || deck.theme?.fontColor || '#000000',
|
|
404
|
+
defaultSize: 18,
|
|
405
|
+
})
|
|
406
|
+
const inset = Array.isArray(element.inset) ? element.inset : [10, 10, 10, 10]
|
|
407
|
+
const textOptions = {
|
|
408
|
+
x: toIn(element.left),
|
|
409
|
+
y: toIn(element.top),
|
|
410
|
+
w: toIn(element.width),
|
|
411
|
+
h: toIn(element.height),
|
|
412
|
+
valign: element.vAlign || 'top',
|
|
413
|
+
margin: inset.map((value) => toPt(value)),
|
|
414
|
+
lineSpacingMultiple: element.lineHeight ? element.lineHeight / 1.25 : 1.2,
|
|
415
|
+
fit: element.fixedHeight ? 'none' : 'resize',
|
|
416
|
+
}
|
|
417
|
+
if (element.rotate) textOptions.rotate = element.rotate
|
|
418
|
+
if (element.wordSpace) textOptions.charSpacing = toPt(element.wordSpace)
|
|
419
|
+
if (element.opacity != null) textOptions.transparency = Math.round((1 - Number(element.opacity)) * 100)
|
|
420
|
+
const fill = fillProps(element.fill)
|
|
421
|
+
if (fill) textOptions.fill = fill
|
|
422
|
+
const line = lineProps(element.outline, ratioPx2Pt)
|
|
423
|
+
if (line) textOptions.line = line
|
|
424
|
+
slide.addText(props, textOptions)
|
|
425
|
+
stats.rendered++
|
|
426
|
+
} else if (element.type === 'shape') {
|
|
427
|
+
const shapeOptions = {
|
|
428
|
+
x: toIn(element.left),
|
|
429
|
+
y: toIn(element.top),
|
|
430
|
+
w: toIn(element.width),
|
|
431
|
+
h: toIn(element.height),
|
|
432
|
+
}
|
|
433
|
+
const fill = fillProps(element.fill)
|
|
434
|
+
if (fill) shapeOptions.fill = fill
|
|
435
|
+
const line = lineProps(element.outline, ratioPx2Pt)
|
|
436
|
+
if (line) shapeOptions.line = line
|
|
437
|
+
if (element.rotate) shapeOptions.rotate = element.rotate
|
|
438
|
+
if (element.opacity != null) shapeOptions.transparency = Math.round((1 - Number(element.opacity)) * 100)
|
|
439
|
+
slide.addShape(shapeTypeOf(pptx, element), shapeOptions)
|
|
440
|
+
if (element.content && plainText(element.content)) {
|
|
441
|
+
const { props } = htmlToTextProps(element.content, {
|
|
442
|
+
ratioPx2Pt,
|
|
443
|
+
defaultFont: element.defaultFontName || deck.theme?.fontName || DEFAULT_FONT,
|
|
444
|
+
defaultColor: element.defaultColor || deck.theme?.fontColor || '#000000',
|
|
445
|
+
defaultSize: 16,
|
|
446
|
+
})
|
|
447
|
+
slide.addText(props, {
|
|
448
|
+
x: toIn(element.left),
|
|
449
|
+
y: toIn(element.top),
|
|
450
|
+
w: toIn(element.width),
|
|
451
|
+
h: toIn(element.height),
|
|
452
|
+
valign: element.vAlign || 'middle',
|
|
453
|
+
margin: [toPt(8), toPt(8), toPt(8), toPt(8)],
|
|
454
|
+
fit: 'resize',
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
if (element.path && /[CQA]/i.test(String(element.path))) {
|
|
458
|
+
stats.approximated++
|
|
459
|
+
warn(`第 ${slideIndex + 1} 页形状 ${element.id || ''} 的自定义路径以矩形近似`)
|
|
460
|
+
}
|
|
461
|
+
stats.rendered++
|
|
462
|
+
} else if (element.type === 'image') {
|
|
463
|
+
const source = await loadImage(element.src, options.assetBase)
|
|
464
|
+
const imageOptions = {
|
|
465
|
+
x: toIn(element.left),
|
|
466
|
+
y: toIn(element.top),
|
|
467
|
+
w: toIn(element.width),
|
|
468
|
+
h: toIn(element.height),
|
|
469
|
+
...source,
|
|
470
|
+
}
|
|
471
|
+
if (element.rotate) imageOptions.rotate = element.rotate
|
|
472
|
+
if (element.flipH) imageOptions.flipH = true
|
|
473
|
+
if (element.flipV) imageOptions.flipV = true
|
|
474
|
+
slide.addImage(imageOptions)
|
|
475
|
+
stats.rendered++
|
|
476
|
+
} else if (element.type === 'line') {
|
|
477
|
+
const start = Array.isArray(element.start) ? element.start : [0, 0]
|
|
478
|
+
const end = Array.isArray(element.end) ? element.end : [element.width || 0, element.height || 0]
|
|
479
|
+
/* 线条元素里 element.width 是描边宽度(PPTist SVG 的 stroke-width),
|
|
480
|
+
* 包围盒要从 start/end 算,不能拿 element.width 当盒宽。 */
|
|
481
|
+
const lineOptions = {
|
|
482
|
+
x: toIn(element.left),
|
|
483
|
+
y: toIn(element.top),
|
|
484
|
+
w: Math.max(0.01, toIn(Math.abs(end[0] - start[0]))),
|
|
485
|
+
h: Math.max(0.01, toIn(Math.abs(end[1] - start[1]))),
|
|
486
|
+
flipV: end[1] < start[1],
|
|
487
|
+
line: {
|
|
488
|
+
color: parseColor(element.color || '#000000').hex,
|
|
489
|
+
width: Math.max(0.25, toPt(element.width || 1)),
|
|
490
|
+
dashType: DASH[element.style] || 'solid',
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
slide.addShape(pptx.ShapeType.line, lineOptions)
|
|
494
|
+
if (element.broken || element.curve || element.cubic || (Array.isArray(element.points) && element.points.length > 2)) {
|
|
495
|
+
stats.approximated++
|
|
496
|
+
warn(`第 ${slideIndex + 1} 页折线/曲线 ${element.id || ''} 以直线近似`)
|
|
497
|
+
}
|
|
498
|
+
stats.rendered++
|
|
499
|
+
} else if (element.type === 'table') {
|
|
500
|
+
const rows = (element.data || []).map((row) => row.map((cell) => ({
|
|
501
|
+
text: cell && typeof cell === 'object' ? String(cell.text ?? '') : String(cell ?? ''),
|
|
502
|
+
options: cellOptions(cell, ratioPx2Pt),
|
|
503
|
+
})))
|
|
504
|
+
if (!rows.length) { stats.skipped++; continue }
|
|
505
|
+
const colWidths = Array.isArray(element.colWidths) && element.colWidths.length
|
|
506
|
+
? element.colWidths.map((width) => toIn(Number(width) * (element.width || 0)))
|
|
507
|
+
: undefined
|
|
508
|
+
const rowHeights = Array.isArray(element.rowHeights) && element.rowHeights.length
|
|
509
|
+
? element.rowHeights.map((height) => toIn(height))
|
|
510
|
+
: undefined
|
|
511
|
+
const tableOptions = {
|
|
512
|
+
x: toIn(element.left),
|
|
513
|
+
y: toIn(element.top),
|
|
514
|
+
w: toIn(element.width),
|
|
515
|
+
h: toIn(element.height),
|
|
516
|
+
border: {
|
|
517
|
+
type: 'solid',
|
|
518
|
+
pt: Math.max(0.25, toPt(element.outline?.width || 1)),
|
|
519
|
+
color: parseColor(element.outline?.color || '#d9d9d9').hex,
|
|
520
|
+
},
|
|
521
|
+
autoPage: false,
|
|
522
|
+
}
|
|
523
|
+
if (colWidths) tableOptions.colW = colWidths
|
|
524
|
+
if (rowHeights) tableOptions.rowH = rowHeights
|
|
525
|
+
slide.addTable(rows, tableOptions)
|
|
526
|
+
stats.rendered++
|
|
527
|
+
} else if (element.type === 'chart') {
|
|
528
|
+
const spec = chartSpec(pptx, element)
|
|
529
|
+
if (!spec) {
|
|
530
|
+
stats.skipped++
|
|
531
|
+
warn(`第 ${slideIndex + 1} 页图表 ${element.chartType || 'unknown'} 暂不支持导出`)
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
slide.addChart(spec.type, spec.data, {
|
|
535
|
+
x: toIn(element.left),
|
|
536
|
+
y: toIn(element.top),
|
|
537
|
+
w: toIn(element.width),
|
|
538
|
+
h: toIn(element.height),
|
|
539
|
+
...spec.options,
|
|
540
|
+
})
|
|
541
|
+
stats.rendered++
|
|
542
|
+
} else {
|
|
543
|
+
stats.skipped++
|
|
544
|
+
stats.unsupportedTypes[element.type] = (stats.unsupportedTypes[element.type] || 0) + 1
|
|
545
|
+
warn(`第 ${slideIndex + 1} 页包含暂不支持的 ${element.type} 元素`)
|
|
546
|
+
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
stats.skipped++
|
|
549
|
+
warn(`第 ${slideIndex + 1} 页元素 ${element.id || element.type} 导出失败:${String(error.message || error)}`)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (options.includeNotes !== false && sourceSlide.remark) {
|
|
554
|
+
const notes = plainText(sourceSlide.remark)
|
|
555
|
+
if (notes) slide.addNotes(notes)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const buffer = await pptx.write({ outputType: 'nodebuffer' })
|
|
560
|
+
return { buffer, stats }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function safeFileName(value, fallback = 'presentation') {
|
|
564
|
+
const name = String(value || fallback).replace(/[\\/:*?"<>|]/g, '_').trim()
|
|
565
|
+
return name || fallback
|
|
566
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** 换色规划:新编辑器走单条 recolor;旧编辑器回退为批量 set-background + set-element。 */
|
|
2
|
+
import { contrastColor } from './color.mjs'
|
|
3
|
+
|
|
4
|
+
export const resolveForeground = (bg, text) => text || contrastColor(bg)
|
|
5
|
+
|
|
6
|
+
/** 纯函数:把一次整体换色展开为 set-background + set-element 队列。
|
|
7
|
+
* 每批 ≤90 条由调用方分组推送(包络上限 100)。 */
|
|
8
|
+
export function expandRecolorOps(deck, { bg, fg, sf, targets }) {
|
|
9
|
+
const ops = []
|
|
10
|
+
const list = (deck && deck.slides) || []
|
|
11
|
+
list.forEach((s, i) => {
|
|
12
|
+
if (targets && targets.indexOf(i) < 0) return
|
|
13
|
+
ops.push({ op: 'set-background', color: bg, indices: [i], contrast: true })
|
|
14
|
+
;(s.elements || []).forEach(el => {
|
|
15
|
+
if (el.type === 'text') return
|
|
16
|
+
const props = {}
|
|
17
|
+
if (sf && el.fill != null) props.fill = sf
|
|
18
|
+
if (el.text && typeof el.text.content === 'string') props.color = fg
|
|
19
|
+
if (Object.keys(props).length) {
|
|
20
|
+
ops.push({ op: 'set-element', slide: i, elementId: el.id, props })
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
return ops
|
|
25
|
+
}
|