@capyclaw/ppt-workbench 0.0.0-stage → 2.4.1

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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -3
  3. package/THIRD_PARTY_NOTICES.md +21 -0
  4. package/contract/ppt-contract.json +1529 -0
  5. package/cordis.patch.yml +25 -0
  6. package/core/audit.js +500 -0
  7. package/core/chart.js +199 -0
  8. package/core/color.js +80 -0
  9. package/core/deck-layout.js +195 -0
  10. package/core/formula.js +80 -0
  11. package/core/hfmath/hershey.js +1763 -0
  12. package/core/hfmath/index.js +1176 -0
  13. package/core/hfmath/stretch.js +41 -0
  14. package/core/hfmath/symbols.js +408 -0
  15. package/core/ops/browser.js +28 -0
  16. package/core/ops/chart.js +64 -0
  17. package/core/ops/deck.js +21 -0
  18. package/core/ops/dispatcher.js +2 -0
  19. package/core/ops/element.js +55 -0
  20. package/core/ops/history.js +11 -0
  21. package/core/ops/import.js +95 -0
  22. package/core/ops/index.js +39 -0
  23. package/core/ops/playback.js +9 -0
  24. package/core/ops/recolor.js +33 -0
  25. package/core/ops/slide.js +66 -0
  26. package/core/ops/table.js +85 -0
  27. package/core/ops/text.js +35 -0
  28. package/core/ops/view.js +13 -0
  29. package/core/ops.js +12 -0
  30. package/core/quick.js +52 -0
  31. package/core/shape-library.js +122 -0
  32. package/core/table.js +178 -0
  33. package/core/text.js +200 -0
  34. package/editor/app.js +5366 -0
  35. package/editor/heartbeat-worker.js +116 -0
  36. package/editor/index.html +89 -0
  37. package/editor/ppt-full-app.js +376 -0
  38. package/lib/host.mjs +98 -0
  39. package/package.json +53 -6
  40. package/server/audit.mjs +2 -0
  41. package/server/bridge-client.mjs +174 -0
  42. package/server/constants.mjs +20 -0
  43. package/server/contract-snapshots.mjs +62 -0
  44. package/server/contract.mjs +24 -0
  45. package/server/deck-layout.mjs +2 -0
  46. package/server/http-utils.mjs +2 -0
  47. package/server/infra/http.mjs +113 -0
  48. package/server/infra/runtime/context.mjs +128 -0
  49. package/server/infra/store/blob-store.mjs +109 -0
  50. package/server/infra/tuning.mjs +82 -0
  51. package/server/infra/util/fs-lock.mjs +132 -0
  52. package/server/infra/workspace/manager.mjs +657 -0
  53. package/server/mcp/dispatch.mjs +2 -0
  54. package/server/mcp/handler.mjs +2 -0
  55. package/server/mcp/instructions.mjs +40 -0
  56. package/server/mcp-http.mjs +3 -0
  57. package/server/ppt-shim.mjs +156 -0
  58. package/server/relay.mjs +245 -0
  59. package/server/routes/assets.mjs +2 -0
  60. package/server/routes/bridge.mjs +47 -0
  61. package/server/routes/editor.mjs +9 -0
  62. package/server/routes/import.mjs +258 -0
  63. package/server/routes/mcp.mjs +17 -0
  64. package/server/runtime/recovery.mjs +9 -0
  65. package/server/runtime/watch.mjs +17 -0
  66. package/server/shared/SHARED_MANIFEST.json +47 -0
  67. package/server/shared/asset-service.mjs +217 -0
  68. package/server/shared/editor-host.mjs +125 -0
  69. package/server/shared/kit/editor/lease.mjs +139 -0
  70. package/server/shared/kit/service/api.mjs +45 -0
  71. package/server/shared/kit/service/http.mjs +113 -0
  72. package/server/shared/kit/service/mcp-http.mjs +142 -0
  73. package/server/shared/kit/service/single-instance.mjs +72 -0
  74. package/server/shared/kit/tools/validate.mjs +115 -0
  75. package/server/shared/kit/tuning.mjs +82 -0
  76. package/server/shared/kit/util/fs-lock.mjs +132 -0
  77. package/server/shared/service-host.mjs +511 -0
  78. package/server/shared/state-service.mjs +249 -0
  79. package/server/shared/tool-host.mjs +341 -0
  80. package/server/shared/tuning.mjs +63 -0
  81. package/server/single-instance.mjs +2 -0
  82. package/server/store/backends/file-backend.mjs +13 -0
  83. package/server/store/checkpoints.mjs +28 -0
  84. package/server/store/doc.mjs +52 -0
  85. package/server/store/index.mjs +34 -0
  86. package/server/store/journal.mjs +9 -0
  87. package/server/store/lease.mjs +2 -0
  88. package/server/store/sanitize.mjs +32 -0
  89. package/server/store.mjs +2 -0
  90. package/server/tools/capabilities/apply.mjs +33 -0
  91. package/server/tools/capabilities/audit.mjs +24 -0
  92. package/server/tools/capabilities/chart.mjs +83 -0
  93. package/server/tools/capabilities/compose.mjs +97 -0
  94. package/server/tools/capabilities/contract.mjs +34 -0
  95. package/server/tools/capabilities/deck.mjs +27 -0
  96. package/server/tools/capabilities/element.mjs +67 -0
  97. package/server/tools/capabilities/export.mjs +95 -0
  98. package/server/tools/capabilities/formula.mjs +72 -0
  99. package/server/tools/capabilities/history.mjs +29 -0
  100. package/server/tools/capabilities/image.mjs +152 -0
  101. package/server/tools/capabilities/imagesearch.mjs +152 -0
  102. package/server/tools/capabilities/import.mjs +180 -0
  103. package/server/tools/capabilities/index.mjs +58 -0
  104. package/server/tools/capabilities/line.mjs +58 -0
  105. package/server/tools/capabilities/notes.mjs +33 -0
  106. package/server/tools/capabilities/present.mjs +34 -0
  107. package/server/tools/capabilities/recolor.mjs +60 -0
  108. package/server/tools/capabilities/screenshot.mjs +86 -0
  109. package/server/tools/capabilities/shape.mjs +64 -0
  110. package/server/tools/capabilities/slide.mjs +81 -0
  111. package/server/tools/capabilities/style.mjs +48 -0
  112. package/server/tools/capabilities/table.mjs +127 -0
  113. package/server/tools/capabilities/task.mjs +52 -0
  114. package/server/tools/capabilities/text.mjs +82 -0
  115. package/server/tools/domains/color.mjs +4 -0
  116. package/server/tools/domains/image.mjs +49 -0
  117. package/server/tools/domains/index.mjs +7 -0
  118. package/server/tools/domains/pptx-export.mjs +566 -0
  119. package/server/tools/domains/recolor.mjs +25 -0
  120. package/server/tools/domains/remote-image.mjs +291 -0
  121. package/server/tools/domains/slide.mjs +21 -0
  122. package/server/tools/domains/text-style.mjs +11 -0
  123. package/server/tools/domains/tooling.mjs +13 -0
  124. package/server/tools/index.mjs +14 -0
  125. package/server/tools/kernel/define.mjs +5 -0
  126. package/server/tools/kernel/ports.mjs +42 -0
  127. package/server/tools/kernel/registry.mjs +17 -0
  128. package/server/tools.mjs +18 -0
  129. package/server/tuning.mjs +86 -0
  130. 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(/&lt;/g, '<')
84
+ .replace(/&gt;/g, '>')
85
+ .replace(/&quot;/g, '"')
86
+ .replace(/&#39;/g, "'")
87
+ .replace(/&amp;/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
+ }