@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.
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 +51 -4
  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
package/core/table.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 表格元素纯函数:数据归一化 + 元素构建(零 DOM,node:test 直测)。
3
+ *
4
+ * PPTist table 元素结构(编辑器原生渲染):
5
+ * { type:'table', data:[[cell...]], colWidths:[比例...], rowHeights?, cellMinHeight,
6
+ * theme:{ color, rowHeader, rowFooter, colHeader, colFooter },
7
+ * outline:{ style, width, color } }
8
+ * cell = { id, colspan, rowspan, text, style?{ bold, color, backcolor, align, fontsize } }
9
+ *
10
+ * buildTableData 支持直接丢二维数组(字符串/数字/对象混合)。
11
+ */
12
+
13
+ import { parseColor } from './color.js'
14
+
15
+ /** 表格缺省主题色(与编辑器模板主题色同族)。 */
16
+ export const DEFAULT_TABLE_COLOR = '#5b9bd5'
17
+
18
+ const isNum = (v) => typeof v === 'number' && isFinite(v)
19
+
20
+ /** 生成 cell id。 */
21
+ const cellId = (r, c) => `c${r + 1}_${c + 1}`
22
+
23
+ /**
24
+ * 颜色加白(tint):把主题色向白色混合,用于表头底色。
25
+ * @param {string} color 任意可解析颜色
26
+ * @param {number} ratio 0~1,越大越浅
27
+ */
28
+ export function tint(color, ratio = 0.85) {
29
+ const rgb = parseColor(String(color || DEFAULT_TABLE_COLOR))
30
+ if (!rgb) return '#f2f6fc'
31
+ const mix = (ch) => Math.round(ch + (255 - ch) * ratio)
32
+ const to2 = (n) => n.toString(16).padStart(2, '0')
33
+ return `#${to2(mix(rgb[0]))}${to2(mix(rgb[1]))}${to2(mix(rgb[2]))}`
34
+ }
35
+
36
+ /** 单元格样式白名单。 */
37
+ const CELL_STYLE_KEYS = ['bold', 'italic', 'underline', 'strikethrough', 'color', 'backcolor', 'align', 'fontsize', 'fontname']
38
+
39
+ /** 归一化单元格:字符串/数字 → 纯文本 cell;对象 → 文本 + 样式白名单。 */
40
+ export function normalizeCell(input, r, c) {
41
+ const base = { id: input && input.id ? String(input.id) : cellId(r, c), colspan: 1, rowspan: 1, text: '' }
42
+ if (input == null) return base
43
+ if (typeof input === 'string' || isNum(input)) {
44
+ base.text = String(input)
45
+ return base
46
+ }
47
+ if (typeof input !== 'object') throw new Error(`表格单元格格式错误(第 ${r + 1} 行第 ${c + 1} 列)`)
48
+ base.text = String(input.text != null ? input.text : '')
49
+ if (Number(input.colspan) > 1) base.colspan = Math.floor(Number(input.colspan))
50
+ if (Number(input.rowspan) > 1) base.rowspan = Math.floor(Number(input.rowspan))
51
+ const style = {}
52
+ for (const k of CELL_STYLE_KEYS) {
53
+ if (input[k] != null) style[k] = input[k]
54
+ }
55
+ if (Object.keys(style).length) base.style = style
56
+ return base
57
+ }
58
+
59
+ /**
60
+ * 构建表格 data。
61
+ * @param {Array<Array>} rows 二维数组(必填)
62
+ * @param {object} opts { header?: boolean 首行表头, themeColor?: string }
63
+ */
64
+ export function buildTableData(rows, opts = {}) {
65
+ if (!Array.isArray(rows) || rows.length === 0) throw new Error('表格数据 rows 不能为空')
66
+ if (!Array.isArray(rows[0]) || rows[0].length === 0) throw new Error('表格 rows 必须是二维数组')
67
+
68
+ const width = rows[0].length
69
+ const themeColor = opts.themeColor ? String(opts.themeColor) : DEFAULT_TABLE_COLOR
70
+ const header = Boolean(opts.header)
71
+
72
+ return rows.map((row, r) => {
73
+ if (!Array.isArray(row) || row.length !== width) {
74
+ throw new Error(`表格第 ${r + 1} 行列数(${Array.isArray(row) ? row.length : 0})与首行(${width})不一致`)
75
+ }
76
+ return row.map((input, c) => {
77
+ const cell = normalizeCell(input, r, c)
78
+ if (opts.header && r === 0) {
79
+ cell.style = {
80
+ bold: true,
81
+ backcolor: tint(themeColor, 0.88),
82
+ ...(cell.style && typeof cell.style === 'object' ? cell.style : {}),
83
+ }
84
+ }
85
+ return cell
86
+ })
87
+ })
88
+ }
89
+
90
+ /**
91
+ * 归一化列宽:比例(和≈1)直通;像素值(和>1.5)自动归一为比例。
92
+ */
93
+ export function normalizeColWidths(colWidths, cols) {
94
+ if (!Array.isArray(colWidths) || colWidths.length === 0) {
95
+ return Array.from({ length: cols }, () => 1 / cols)
96
+ }
97
+ if (colWidths.length !== cols) {
98
+ throw new Error(`colWidths 长度(${colWidths.length})与列数(${cols})不一致`)
99
+ }
100
+ const nums = colWidths.map(Number)
101
+ if (nums.some((n) => !isFinite(n) || n <= 0)) throw new Error('colWidths 必须是正数')
102
+ const sum = nums.reduce((a, b) => a + b, 0)
103
+ if (sum <= 1.5) {
104
+ // 已是比例:按总和归一兜底
105
+ return nums.map((n) => n / sum)
106
+ }
107
+ return nums.map((n) => n / sum)
108
+ }
109
+
110
+ /**
111
+ * 构建表格元素。
112
+ * @param {object} e { rows, header?, themeColor?, left/top/width/height?, colWidths?, cellMinHeight?, outline? }
113
+ */
114
+ export function makeTableElement(e) {
115
+ if (!e || typeof e !== 'object') throw new Error('表格元素必须是对象')
116
+ const rows = e.rows
117
+ const header = Boolean(e.header)
118
+ const data = buildTableData(rows, { header, themeColor: e.themeColor })
119
+
120
+ const cols = data[0].length
121
+ const nRows = data.length
122
+ const themeColor = e.themeColor ? String(e.themeColor) : DEFAULT_TABLE_COLOR
123
+ const cellMinHeight = Number(e.cellMinHeight != null ? e.cellMinHeight : 36)
124
+ const width = Number(e.width != null ? e.width : 640)
125
+ const height = Number(e.height != null ? e.height : nRows * cellMinHeight)
126
+
127
+ const el = {
128
+ type: 'table',
129
+ id: e.id || `table_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
130
+ left: Number(e.left != null ? e.left : Math.round((1000 - width) / 2)),
131
+ top: Number(e.top != null ? e.top : 110),
132
+ width,
133
+ height,
134
+ rotate: Number(e.rotate || 0),
135
+ outline: e.outline && typeof e.outline === 'object'
136
+ ? {
137
+ style: String(e.outline.style || 'solid'),
138
+ width: Number(e.outline.width != null ? e.outline.width : 1),
139
+ color: String(e.outline.color || '#d9d9d9'),
140
+ }
141
+ : { style: 'solid', width: 1, color: '#d9d9d9' },
142
+ theme: {
143
+ color: themeColor,
144
+ rowHeader: header,
145
+ rowFooter: false,
146
+ colHeader: Boolean(e.colHeader),
147
+ colFooter: false,
148
+ },
149
+ colWidths: normalizeColWidths(e.colWidths, cols),
150
+ cellMinHeight,
151
+ data,
152
+ }
153
+ if (Array.isArray(e.rowHeights) && e.rowHeights.length === nRows) {
154
+ el.rowHeights = e.rowHeights.map(Number)
155
+ }
156
+ return el
157
+ }
158
+
159
+ /**
160
+ * 归一化单元格补丁(set-table-cells 用):
161
+ * [{row, col, text?, bold?, color?, backcolor?, align?, fontsize?}]
162
+ */
163
+ export function normalizeCellPatches(cells) {
164
+ if (!Array.isArray(cells) || cells.length === 0) throw new Error('cells 不能为空')
165
+ return cells.map((c, i) => {
166
+ if (!c || typeof c !== 'object' || !isNum(Number(c.row)) || !isNum(Number(c.col))) {
167
+ throw new Error(`cells[${i}] 需要 row / col`)
168
+ }
169
+ const patch = { row: Math.floor(Number(c.row)), col: Math.floor(Number(c.col)) }
170
+ if (c.text != null) patch.text = String(c.text)
171
+ const style = {}
172
+ for (const k of CELL_STYLE_KEYS) {
173
+ if (c[k] != null) style[k] = c[k]
174
+ }
175
+ if (Object.keys(style).length) patch.style = style
176
+ return patch
177
+ })
178
+ }
package/core/text.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * PPT Bridge 中间件 —— 纯文本/样式工具(DOM-free,可在 node:test 直接测)。
3
+ * 2026-10-22 结构批:从 keystudio.works 抽取,行为保持不变。
4
+ * 2026-10-30 解耦批:颜色解析/对比色收敛到 core/color.js。
5
+ */
6
+ import { parseColor, contrastColor } from './color.js'
7
+ import { makeChartElement } from './chart.js'
8
+ import { makeTableElement } from './table.js'
9
+ import { makeFormulaElement } from './formula.js'
10
+
11
+ export { parseColor } from './color.js'
12
+
13
+ export const escapeHtml = (s) => String(s == null ? '' : s)
14
+ .replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
15
+
16
+ export const fontSizeOf = (html) => {
17
+ const m = /font-size:\s*(\d+(?:\.\d+)?)px/.exec(String(html || ''))
18
+ return m ? Number(m[1]) : 0
19
+ }
20
+
21
+ /** 文本载体:text 元素本身,或带 text.content 的 shape(卡片标题)。 */
22
+ export function textHolder(el) {
23
+ if (!el) return null
24
+ if (el.type === 'text') return el
25
+ if (el.type === 'shape' && el.text && typeof el.text.content === 'string') return el.text
26
+ return null
27
+ }
28
+
29
+ export const htmlOf = (el) => {
30
+ const h = textHolder(el)
31
+ return h ? String(h.content || '') : ''
32
+ }
33
+
34
+ /**
35
+ * 保留完整标签树(含 span 的 font-size/颜色/背景),只替换文本节点。
36
+ * 原字号住在 <span style="font-size:54px"> 里;DOM 版依赖 DOMParser,
37
+ * 这里用正则遍历文本节点实现同样语义,便于单测。
38
+ */
39
+ export function setPlainText(current, text) {
40
+ const src = String(current || '')
41
+ const body = escapeHtml(text == null ? '' : text)
42
+ let first = true
43
+ const out = src.replace(/>([^<>]*)</g, (m, txt) => {
44
+ if (!String(txt).trim()) return m
45
+ if (first) { first = false; return '>' + body + '<' }
46
+ return '><'
47
+ })
48
+ if (first) return '<p>' + body + '</p>'
49
+ return out
50
+ }
51
+
52
+ /** 文本样式改写:缺省保留原文(去标签取纯文本),样式从 props 合并。 */
53
+ export function textHtml(current, props, st) {
54
+ let inner = props.html
55
+ if (inner == null) {
56
+ if (props.text != null) inner = escapeHtml(String(props.text))
57
+ else inner = String(current || '').replace(/<p[^>]*>/i, '').replace(/<\/p>\s*$/i, '')
58
+ }
59
+ let body = inner
60
+ if (st && st.bold) body = '<strong>' + body + '</strong>'
61
+ const size = st && st.fontSize != null ? Number(st.fontSize) : 28
62
+ let style = 'font-size: ' + size + 'px;'
63
+ if (st && st.color) style += ' color: ' + String(st.color) + ';'
64
+ if (st && st.align) style += ' text-align: ' + String(st.align) + ';'
65
+ return '<p style="' + style + '">' + body + '</p>'
66
+ }
67
+
68
+ /** 元素属性补丁:几何 / 填充 / 文本样式(content 重写保留正文)。 */
69
+ export function patchElement(el, props) {
70
+ ;['left', 'top', 'width', 'height', 'rotate', 'opacity'].forEach(k => {
71
+ if (props[k] != null && isFinite(Number(props[k]))) el[k] = Number(props[k])
72
+ })
73
+ if (props.fill != null) el.fill = String(props.fill)
74
+ if (el.type === 'image') {
75
+ if (props.src != null) el.src = String(props.src)
76
+ if (props.clip != null) el.clip = props.clip
77
+ return
78
+ }
79
+ /* 公式元素:path/latex/viewBox 由渲染器产出,样式走 stroke。 */
80
+ if (el.type === 'latex') {
81
+ if (props.path != null) el.path = String(props.path)
82
+ if (props.latex != null) el.latex = String(props.latex)
83
+ if (Array.isArray(props.viewBox) && props.viewBox.length === 2 &&
84
+ props.viewBox.every(n => isFinite(Number(n)) && Number(n) > 0)) {
85
+ el.viewBox = [Number(props.viewBox[0]), Number(props.viewBox[1])]
86
+ }
87
+ if (props.color != null) el.color = String(props.color)
88
+ if (props.strokeWidth != null && isFinite(Number(props.strokeWidth))) {
89
+ el.strokeWidth = Number(props.strokeWidth)
90
+ }
91
+ return
92
+ }
93
+ const holder = textHolder(el)
94
+ if (!holder) return
95
+ /* 只改颜色:直接替换 inline color,保留字号/加粗(否则 textHtml 会回落到 28px) */
96
+ if (props.color != null && props.text == null && props.html == null &&
97
+ props.fontSize == null && props.bold == null && props.align == null) {
98
+ holder.content = String(holder.content || '').replace(/color:\s*[^;"']+/g, 'color: ' + String(props.color))
99
+ if (el.type === 'text') el.defaultColor = String(props.color)
100
+ return
101
+ }
102
+ if (props.defaultColor != null && el.type === 'text') el.defaultColor = String(props.defaultColor)
103
+ const hasText = props.text != null || props.html != null
104
+ const hasStyle = props.fontSize != null || props.color != null ||
105
+ props.bold != null || props.align != null
106
+ if (hasText && !hasStyle) {
107
+ /* 槽位填充:只换文字,保留原字号/颜色/加粗(版式不破) */
108
+ holder.content = setPlainText(holder.content, props.text)
109
+ }
110
+ else if (hasText || hasStyle) {
111
+ holder.content = textHtml(holder.content, props, hasStyle ? props : null)
112
+ }
113
+ }
114
+
115
+ export function makeElement(e) {
116
+ const type = String(e.type || 'text')
117
+ if (type !== 'text' && type !== 'shape' && type !== 'image' &&
118
+ type !== 'chart' && type !== 'table' && type !== 'line' && type !== 'latex') {
119
+ throw new Error('不支持的 element.type: ' + type +
120
+ '(只支持 text / shape / image / chart / table / line / latex)')
121
+ }
122
+ /* 线条:start/end 用画布绝对坐标表达,落库转为元素相对坐标;
123
+ PPTist line 元素不存包围盒宽高——width 字段是线宽。 */
124
+ if (type === 'line') {
125
+ const sx = e.start ? Number(e.start[0]) : Number(e.x1 != null ? e.x1 : 0)
126
+ const sy = e.start ? Number(e.start[1]) : Number(e.y1 != null ? e.y1 : 0)
127
+ const ex = e.end ? Number(e.end[0]) : Number(e.x2 != null ? e.x2 : 200)
128
+ const ey = e.end ? Number(e.end[1]) : Number(e.y2 != null ? e.y2 : 0)
129
+ if (![sx, sy, ex, ey].every(isFinite)) throw new Error('line 需要 start/end(画布坐标)或 x1/y1/x2/y2')
130
+ const left = Math.min(sx, ex)
131
+ const top = Math.min(sy, ey)
132
+ const pointOf = (v) => (v && v !== 'none' ? String(v) : '')
133
+ const el = {
134
+ id: e.id || ('line_' + Date.now() + '_' + Math.floor(Math.random() * 1000)),
135
+ type,
136
+ left,
137
+ top,
138
+ start: [sx - left, sy - top],
139
+ end: [ex - left, ey - top],
140
+ points: Array.isArray(e.points)
141
+ ? e.points.map(String)
142
+ : [pointOf(e.start_point), pointOf(e.end_point)],
143
+ color: e.color || '#5b9bd5',
144
+ style: String(e.style || 'solid'),
145
+ width: Number(e.width || e.lineWidth || 2),
146
+ }
147
+ if (Array.isArray(e.broken) && e.broken.length === 2) {
148
+ el.broken = [Number(e.broken[0]) - left, Number(e.broken[1]) - top]
149
+ }
150
+ return el
151
+ }
152
+ /* 图表 / 表格 / 公式:委托给各自的构建器(数据归一化 + 校验)。 */
153
+ if (type === 'chart') return makeChartElement(e)
154
+ if (type === 'table') return makeTableElement(e)
155
+ if (type === 'latex') return makeFormulaElement(e)
156
+ const id = e.id || ('el_' + Date.now() + '_' + Math.floor(Math.random() * 1000))
157
+ const base = {
158
+ id, type,
159
+ left: Number(e.left != null ? e.left : 60), top: Number(e.top != null ? e.top : 120),
160
+ width: Number(e.width != null ? e.width : 400), height: Number(e.height != null ? e.height : 60),
161
+ rotate: Number(e.rotate || 0), fixedRatio: false,
162
+ }
163
+ if (type === 'shape') {
164
+ return Object.assign(base, {
165
+ path: e.path || 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
166
+ viewBox: e.viewBox || [200, 200],
167
+ fill: e.fill || 'rgb(209, 68, 36)',
168
+ })
169
+ }
170
+ if (type === 'image') {
171
+ const src = String(e.src || '')
172
+ if (!src) throw new Error('image 元素需要 src(请用 ppt_image,支持 attachment_id)')
173
+ if (/^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('file:')) {
174
+ throw new Error('本机路径不能直接插入图片;请用 ppt_image(会自动转 data URL)')
175
+ }
176
+ return Object.assign(base, {
177
+ src,
178
+ clip: e.clip || null,
179
+ ...(e.opacity != null ? { opacity: Number(e.opacity) } : {}),
180
+ })
181
+ }
182
+ return Object.assign(base, {
183
+ content: e.content || textHtml('', { text: e.text || '' },
184
+ { fontSize: e.fontSize || 28, color: e.color || '#333333' }),
185
+ defaultColor: e.color || '#333333', defaultFontName: '',
186
+ })
187
+ }
188
+
189
+ /** 背景明暗 → 文本自动对比度:浅底深字 / 深底白字。 */
190
+ export function contrast(slide, bg) {
191
+ if (!parseColor(bg)) return
192
+ const fg = contrastColor(bg)
193
+ ;(slide.elements || []).forEach(el => {
194
+ if (el.type !== 'text') return
195
+ el.defaultColor = fg
196
+ if (typeof el.content === 'string') {
197
+ el.content = el.content.replace(/color:\s*[^;"']+/g, 'color: ' + fg)
198
+ }
199
+ })
200
+ }