@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
package/core/chart.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表元素纯函数:数据归一化 + 元素构建(零 DOM,node:test 直测)。
|
|
3
|
+
*
|
|
4
|
+
* PPTist chart 元素结构(编辑器原生渲染,echarts 已内置于 bundle):
|
|
5
|
+
* { type:'chart', chartType:'bar'|'column'|'line'|'area'|'pie'|'ring'|'radar'|'scatter',
|
|
6
|
+
* data:{ labels, legends, series }, options?{ lineSmooth, stack }, themeColors?, textColor? }
|
|
7
|
+
*
|
|
8
|
+
* normalizeChartData 支持多种「丢数据」格式,统一归一为 PPTist 原生结构:
|
|
9
|
+
* 1. { labels, legends, series } 原生格式直通
|
|
10
|
+
* 2. { labels, values } 单系列
|
|
11
|
+
* 3. { headers, rows } 表格数据:首列=类目,其余列=系列
|
|
12
|
+
* 4. { items: [[name, value]...] | [{name,value}...] } 饼图/环形图
|
|
13
|
+
* 5. { series: [{name, values}...] } series 对象式(labels 可省,自动生成)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CHART_TYPES = ['bar', 'column', 'line', 'area', 'pie', 'ring', 'radar', 'scatter']
|
|
17
|
+
|
|
18
|
+
/** 单系列图:饼/环只取第一个系列,多余系列报错。 */
|
|
19
|
+
export const SINGLE_SERIES_TYPES = ['pie', 'ring']
|
|
20
|
+
|
|
21
|
+
/** 图表缺省调色板(与编辑器模板主题色 #5b9bd5 同族)。 */
|
|
22
|
+
export const DEFAULT_CHART_COLORS = [
|
|
23
|
+
'#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4',
|
|
24
|
+
'#70ad47', '#264478', '#9e480e', '#636363', '#997300',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const isNum = (v) => typeof v === 'number' && isFinite(v)
|
|
28
|
+
const isNumArr = (a) => Array.isArray(a) && a.length > 0 && a.every(isNum)
|
|
29
|
+
|
|
30
|
+
function fail(msg) {
|
|
31
|
+
throw new Error('图表数据格式错误:' + msg)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 归一化 series:数字数组或 {name, values} 对象数组 → [number[]] + legends。 */
|
|
35
|
+
function normalizeSeriesInput(series) {
|
|
36
|
+
if (!Array.isArray(series) || series.length === 0) fail('series 不能为空')
|
|
37
|
+
const legends = []
|
|
38
|
+
const out = []
|
|
39
|
+
for (const s of series) {
|
|
40
|
+
if (isNumArr(s)) {
|
|
41
|
+
out.push(s.slice())
|
|
42
|
+
legends.push(`系列${out.length}`)
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (s && isNumArr(s.values)) {
|
|
46
|
+
out.push(s.values.slice())
|
|
47
|
+
legends.push(String(s.name != null && s.name !== '' ? s.name : `系列${out.length + 1}`))
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
fail('series 每项必须是数字数组或 {name, values}')
|
|
51
|
+
}
|
|
52
|
+
return { legends, series: out }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 生成类目名:类别1..n。 */
|
|
56
|
+
const autoLabels = (n) => Array.from({ length: n }, (_, i) => `类别${i + 1}`)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 归一化图表数据 → { labels, legends, series }。
|
|
60
|
+
* @param {object} raw 上述 5 种格式之一
|
|
61
|
+
* @param {string} chartType 用于 pie/ring 的单系列裁剪
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeChartData(raw, chartType = 'bar') {
|
|
64
|
+
if (!raw || typeof raw !== 'object') fail('data 必须是对象')
|
|
65
|
+
|
|
66
|
+
let labels = []
|
|
67
|
+
let legends = []
|
|
68
|
+
let series = []
|
|
69
|
+
|
|
70
|
+
if (Array.isArray(raw.series)) {
|
|
71
|
+
// 格式 1 / 5:series 数组(数字数组或 {name, values})
|
|
72
|
+
const norm = normalizeSeriesInput(raw.series)
|
|
73
|
+
legends = raw.legends && Array.isArray(raw.legends) && raw.legends.length
|
|
74
|
+
? norm.series.map((_, i) => String(raw.legends[i] != null ? raw.legends[i] : norm.legends[i]))
|
|
75
|
+
: norm.legends
|
|
76
|
+
series = norm.series
|
|
77
|
+
labels = Array.isArray(raw.labels) && raw.labels.length
|
|
78
|
+
? raw.labels.map(String)
|
|
79
|
+
: autoLabels(series[0].length)
|
|
80
|
+
}
|
|
81
|
+
else if (Array.isArray(raw.values)) {
|
|
82
|
+
// 格式 2:{ labels?, values } 单系列
|
|
83
|
+
if (!isNumArr(raw.values)) fail('values 必须是非空数字数组')
|
|
84
|
+
series = [raw.values.slice()]
|
|
85
|
+
legends = [String(raw.name || '值')]
|
|
86
|
+
labels = Array.isArray(raw.labels) && raw.labels.length
|
|
87
|
+
? raw.labels.map(String)
|
|
88
|
+
: autoLabels(series[0].length)
|
|
89
|
+
}
|
|
90
|
+
else if (Array.isArray(raw.rows) || Array.isArray(raw.headers)) {
|
|
91
|
+
// 格式 3:{ headers?, rows } 表格数据:首列=类目
|
|
92
|
+
if (!Array.isArray(raw.rows) || raw.rows.length === 0) fail('rows 不能为空')
|
|
93
|
+
const width = raw.rows[0].length
|
|
94
|
+
if (width < 2) fail('rows 每行至少 2 列(首列类目 + 至少 1 列数值)')
|
|
95
|
+
const heads = Array.isArray(raw.headers) && raw.headers.length
|
|
96
|
+
? raw.headers.map(String)
|
|
97
|
+
: null
|
|
98
|
+
labels = []
|
|
99
|
+
const cols = Array.from({ length: width - 1 }, () => [])
|
|
100
|
+
for (const row of raw.rows) {
|
|
101
|
+
if (!Array.isArray(row) || row.length !== width) fail(`rows 每行长度必须一致(${width})`)
|
|
102
|
+
labels.push(String(row[0]))
|
|
103
|
+
for (let c = 1; c < width; c++) {
|
|
104
|
+
const v = Number(row[c])
|
|
105
|
+
if (!isFinite(v)) fail(`第 ${labels.length} 行第 ${c + 1} 列不是数字:${JSON.stringify(row[c])}`)
|
|
106
|
+
cols[c - 1].push(v)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
legends = cols.map((_, i) => (heads && heads[i + 1]) || `系列${i + 1}`)
|
|
110
|
+
series = cols
|
|
111
|
+
}
|
|
112
|
+
else if (Array.isArray(raw.items)) {
|
|
113
|
+
// 格式 4:{ items: [[name, value]] | [{name, value}] } 饼图式
|
|
114
|
+
labels = []
|
|
115
|
+
const values = []
|
|
116
|
+
for (const it of raw.items) {
|
|
117
|
+
const [name, value] = Array.isArray(it) ? it : [it && it.name, it && it.value]
|
|
118
|
+
if (name == null || !isFinite(Number(value))) fail('items 每项必须是 [name, value] 或 {name, value}')
|
|
119
|
+
labels.push(String(name))
|
|
120
|
+
values.push(Number(value))
|
|
121
|
+
}
|
|
122
|
+
series = [values]
|
|
123
|
+
legends = [String(raw.name || '值')]
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
fail('无法识别的 data 格式(支持 labels+series / labels+values / headers+rows / items)')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (labels.length === 0) fail('labels 不能为空')
|
|
130
|
+
for (const s of series) {
|
|
131
|
+
if (s.length !== labels.length) {
|
|
132
|
+
fail(`系列长度(${s.length})与类目数(${labels.length})不一致`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (SINGLE_SERIES_TYPES.includes(chartType) && series.length > 1) {
|
|
137
|
+
fail(`${chartType} 图只支持 1 个系列(收到 ${series.length} 个),请改用 bar/line 等`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { labels, legends, series }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 图表 options 白名单:只透传编辑器认识的字段。 */
|
|
144
|
+
const CHART_OPTION_KEYS = ['lineSmooth', 'stack', 'horizontalBars']
|
|
145
|
+
|
|
146
|
+
export function normalizeChartOptions(options) {
|
|
147
|
+
if (options == null) return undefined
|
|
148
|
+
if (typeof options !== 'object') fail('options 必须是对象')
|
|
149
|
+
const out = {}
|
|
150
|
+
for (const k of CHART_OPTION_KEYS) {
|
|
151
|
+
if (options[k] != null) out[k] = Boolean(options[k])
|
|
152
|
+
}
|
|
153
|
+
return Object.keys(out).length ? out : undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 画布 1000 × 562.5;图表缺省居中偏上。 */
|
|
157
|
+
function geometry(e) {
|
|
158
|
+
const width = Number(e.width != null ? e.width : 560)
|
|
159
|
+
const height = Number(e.height != null ? e.height : 360)
|
|
160
|
+
return {
|
|
161
|
+
width,
|
|
162
|
+
height,
|
|
163
|
+
left: Number(e.left != null ? e.left : Math.round((1000 - width) / 2)),
|
|
164
|
+
top: Number(e.top != null ? e.top : 110),
|
|
165
|
+
rotate: Number(e.rotate || 0),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 构建图表元素。
|
|
171
|
+
* @param {object} e {chartType, data, options?, colors?|themeColors?, left/top/width/height?, textColor?}
|
|
172
|
+
* @param {string[]} [deckThemeColors] 幻灯片主题色(可选兜底)
|
|
173
|
+
*/
|
|
174
|
+
export function makeChartElement(e, deckThemeColors) {
|
|
175
|
+
if (!e || typeof e !== 'object') fail('图表元素必须是对象')
|
|
176
|
+
const chartType = String(e.chartType || (e.type && e.type !== 'chart' ? e.type : 'bar'))
|
|
177
|
+
if (!CHART_TYPES.includes(chartType)) {
|
|
178
|
+
fail(`不支持的 chartType: ${chartType}(支持 ${CHART_TYPES.join(' / ')})`)
|
|
179
|
+
}
|
|
180
|
+
const data = normalizeChartData(e.data, chartType)
|
|
181
|
+
const colors = Array.isArray(e.colors) && e.colors.length
|
|
182
|
+
? e.colors.map(String)
|
|
183
|
+
: (Array.isArray(e.themeColors) && e.themeColors.length
|
|
184
|
+
? e.themeColors.map(String)
|
|
185
|
+
: (Array.isArray(deckThemeColors) && deckThemeColors.length ? deckThemeColors : DEFAULT_CHART_COLORS))
|
|
186
|
+
const g = geometry(e)
|
|
187
|
+
const el = {
|
|
188
|
+
type: 'chart',
|
|
189
|
+
id: e.id || `chart_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
|
190
|
+
...g,
|
|
191
|
+
chartType,
|
|
192
|
+
data,
|
|
193
|
+
themeColors: colors,
|
|
194
|
+
textColor: String(e.textColor || '#333333'),
|
|
195
|
+
}
|
|
196
|
+
const options = normalizeChartOptions(e.options)
|
|
197
|
+
if (options) el.options = options
|
|
198
|
+
return el
|
|
199
|
+
}
|
package/core/color.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 颜色域:中文色名、颜色解析、亮度与对比色。
|
|
3
|
+
*
|
|
4
|
+
* 这是整个仓库唯一的颜色实现;core/text、core/quick、core/audit 与
|
|
5
|
+
* MCP 的 domains/color 都从这里取,避免多份色表/亮度公式漂移。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const COLORS = {
|
|
9
|
+
蓝: '#5b9bd5', 蓝色: '#5b9bd5', 天蓝: '#87ceeb', 淡蓝: '#add8e6', 浅蓝: '#b3e5fc', 深蓝: '#1e3a8a',
|
|
10
|
+
白: '#ffffff', 白色: '#ffffff', 黑: '#000000', 黑色: '#000000',
|
|
11
|
+
灰: '#cccccc', 灰色: '#cccccc', 深灰: '#333333', 浅灰: '#f2f2f2',
|
|
12
|
+
黄: '#ffe066', 淡黄: '#fffacd', 浅黄: '#fffacd', 米色: '#f5f5dc',
|
|
13
|
+
红: '#d14424', 红色: '#d14424', 绿: '#2e7d32', 绿色: '#2e7d32',
|
|
14
|
+
紫: '#7e57c2', 紫色: '#7e57c2', 橙: '#f0a030', 橙色: '#f0a030',
|
|
15
|
+
粉: '#ffc0cb', 粉色: '#ffc0cb', 青: '#20b2aa', 青色: '#20b2aa',
|
|
16
|
+
棕: '#8d6e63', 棕色: '#8d6e63',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 中文色名 / CSS 颜色 → hex;最长匹配,同长度取更早出现。 */
|
|
20
|
+
export function resolveColor(v) {
|
|
21
|
+
const s = String(v || '').trim()
|
|
22
|
+
if (COLORS[s]) return COLORS[s]
|
|
23
|
+
let best = null
|
|
24
|
+
for (const k of Object.keys(COLORS)) {
|
|
25
|
+
const idx = s.indexOf(k)
|
|
26
|
+
if (idx < 0) continue
|
|
27
|
+
if (!best || k.length > best.k.length || (k.length === best.k.length && idx < best.idx)) {
|
|
28
|
+
best = { k, idx }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return best ? COLORS[best.k] : s
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** #rgb / #rrggbb / rgb() / rgba() → [r,g,b];非法返回 null。 */
|
|
35
|
+
export function parseColor(v) {
|
|
36
|
+
const s = String(v || '').trim()
|
|
37
|
+
let m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s)
|
|
38
|
+
if (m) {
|
|
39
|
+
const h = m[1].length === 3 ? m[1].split('').map(c => c + c).join('') : m[1]
|
|
40
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
|
41
|
+
}
|
|
42
|
+
m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(s)
|
|
43
|
+
if (m) return [+m[1], +m[2], +m[3]]
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 0-255 加权亮度(WCAG 系数)。 */
|
|
48
|
+
export function luminance(rgb) {
|
|
49
|
+
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WCAG 相对亮度(sRGB → 线性)。 */
|
|
53
|
+
function relativeChannel(c) {
|
|
54
|
+
const s = c / 255
|
|
55
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function relativeLuminanceRgb(rgb) {
|
|
59
|
+
return 0.2126 * relativeChannel(rgb[0]) + 0.7152 * relativeChannel(rgb[1]) + 0.0722 * relativeChannel(rgb[2])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 两色 WCAG 对比度(1–21,审计用)。 */
|
|
63
|
+
export function contrastRatio(a, b) {
|
|
64
|
+
const l1 = relativeLuminanceRgb(a)
|
|
65
|
+
const l2 = relativeLuminanceRgb(b)
|
|
66
|
+
const hi = Math.max(l1, l2)
|
|
67
|
+
const lo = Math.min(l1, l2)
|
|
68
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 背景相对亮度(0-1);无法解析时按白色处理。 */
|
|
72
|
+
export function relativeLuminance(bg) {
|
|
73
|
+
const rgb = parseColor(bg)
|
|
74
|
+
return rgb ? luminance(rgb) / 255 : 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 浅底深字 / 深底白字。 */
|
|
78
|
+
export function contrastColor(bg) {
|
|
79
|
+
return relativeLuminance(bg) > 0.62 ? '#1f2d3d' : '#ffffff'
|
|
80
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** 排版引擎(7 版式 × 4 预设):大纲 → 专业排版 slides(纯 deck 域)。 */
|
|
2
|
+
/* ===================== 设计系统与排版引擎(2026-10-21) =====================
|
|
3
|
+
* 目标:一次 ppt_deck 调用产出「结构完整、版式统一、对比度正确」的 deck。
|
|
4
|
+
* 约束内置:60px 网格边距、标题/正文/注释三级字号、要点数自适应、
|
|
5
|
+
* 强调色只用于标题装饰与关键数字。 */
|
|
6
|
+
export const PRESETS = {
|
|
7
|
+
clean: { bg:'#ffffff', fg:'#1f2d3d', muted:'#5b6472', card:'#f3f4f6', accent:'#2563eb', accent2:'#c2410c' },
|
|
8
|
+
business: { bg:'#0f2740', fg:'#ffffff', muted:'#b8c4d0', card:'#16324f', accent:'#e0a33a', accent2:'#5aa9e6' },
|
|
9
|
+
/* warm:muted/accent 经对比度求解固化(2026-11-01 进化轮发现原值对 bg/card
|
|
10
|
+
* 只有 3.86-4.00:1,低于 WCAG 4.5:1,导致 compose 产出被自家 ppt_audit 打 block)。 */
|
|
11
|
+
warm: { bg:'#fbf7f0', fg:'#3b2f2a', muted:'#67594f', card:'#f2e9dd', accent:'#a6431f', accent2:'#2e7d32' },
|
|
12
|
+
tech: { bg:'#0b1020', fg:'#e8efff', muted:'#93a4c4', card:'#141b31', accent:'#22d3ee', accent2:'#a78bfa' },
|
|
13
|
+
}
|
|
14
|
+
const GRID = { W: 1000, H: 562.5, pad: 60, titleTop: 46, bodyTop: 150 }
|
|
15
|
+
const tidy = (s, max) => {
|
|
16
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
17
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t
|
|
18
|
+
}
|
|
19
|
+
const textEl = (id, x, y, w, h, html, opts = {}) => ({
|
|
20
|
+
type: 'text', id, left: x, top: y, width: w, height: h,
|
|
21
|
+
content: `<p style="font-size: ${opts.size || 20}px;${opts.color ? ' color:' + opts.color + ';' : ''}${opts.align ? ' text-align:' + opts.align + ';' : ''}">${opts.bold ? '<strong>' + html + '</strong>' : html}</p>`,
|
|
22
|
+
defaultColor: opts.color || '#333333', defaultFontName: '', fixedRatio: false, rotate: 0,
|
|
23
|
+
})
|
|
24
|
+
const shapeEl = (id, x, y, w, h, fill, rotate = 0) => ({
|
|
25
|
+
type: 'shape', id, left: x, top: y, width: w, height: h,
|
|
26
|
+
path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z', viewBox: [200, 200],
|
|
27
|
+
fill, fixedRatio: false, rotate,
|
|
28
|
+
})
|
|
29
|
+
const bgOf = (P) => ({ type: 'solid', color: P.bg })
|
|
30
|
+
|
|
31
|
+
function layoutCover(P, s, i) {
|
|
32
|
+
const W = GRID.W, pad = GRID.pad
|
|
33
|
+
return {
|
|
34
|
+
id: `lt_cover_${i}`, background: bgOf(P), elements: [
|
|
35
|
+
shapeEl(`lt_cover_bar_${i}`, 0, 0, W, 14, P.accent),
|
|
36
|
+
textEl(`lt_cover_kicker_${i}`, pad, 120, W - pad * 2, 30,
|
|
37
|
+
escapeHtml(tidy(s.kicker || '', 40)), { size: 16, color: P.accent, bold: true }),
|
|
38
|
+
textEl(`lt_cover_title_${i}`, pad, 160, W - pad * 2, 120,
|
|
39
|
+
escapeHtml(tidy(s.title || '', 30)), { size: 50, color: P.fg, bold: true }),
|
|
40
|
+
textEl(`lt_cover_sub_${i}`, pad, 300, W - pad * 2, 70,
|
|
41
|
+
escapeHtml(tidy(s.subtitle || '', 60)), { size: 21, color: P.muted }),
|
|
42
|
+
shapeEl(`lt_cover_line_${i}`, pad, 400, 120, 4, P.accent),
|
|
43
|
+
textEl(`lt_cover_foot_${i}`, pad, 470, W - pad * 2, 30,
|
|
44
|
+
escapeHtml(tidy(s.foot || '', 50)), { size: 13, color: P.muted }),
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function layoutSection(P, s, i) {
|
|
50
|
+
const W = GRID.W, pad = GRID.pad
|
|
51
|
+
const n = String(s.index != null ? s.index : '').padStart(2, '0')
|
|
52
|
+
return {
|
|
53
|
+
id: `lt_section_${i}`, background: bgOf(P), elements: [
|
|
54
|
+
textEl(`lt_sec_num_${i}`, pad, 120, 160, 120, escapeHtml(n || '—'),
|
|
55
|
+
{ size: 72, color: P.accent, bold: true }),
|
|
56
|
+
textEl(`lt_sec_title_${i}`, pad, 250, W - pad * 2, 90,
|
|
57
|
+
escapeHtml(tidy(s.title || '', 30)), { size: 40, color: P.fg, bold: true }),
|
|
58
|
+
shapeEl(`lt_sec_line_${i}`, pad, 350, 100, 4, P.accent),
|
|
59
|
+
textEl(`lt_sec_sub_${i}`, pad, 380, W - pad * 2, 60,
|
|
60
|
+
escapeHtml(tidy(s.subtitle || '', 70)), { size: 18, color: P.muted }),
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function layoutBullets(P, s, i) {
|
|
66
|
+
const W = GRID.W, pad = GRID.pad
|
|
67
|
+
const items = (s.bullets || []).slice(0, 6).map(b =>
|
|
68
|
+
typeof b === 'string' ? { text: b } : b)
|
|
69
|
+
const size = items.length <= 3 ? 22 : items.length === 4 ? 20 : 18
|
|
70
|
+
const els = [
|
|
71
|
+
textEl(`lt_b_title_${i}`, pad, GRID.titleTop, W - pad * 2, 70,
|
|
72
|
+
escapeHtml(tidy(s.title || '', 28)), { size: 32, color: P.fg, bold: true }),
|
|
73
|
+
shapeEl(`lt_b_line_${i}`, pad, 128, 90, 4, P.accent),
|
|
74
|
+
]
|
|
75
|
+
items.forEach((b, k) => {
|
|
76
|
+
const y = GRID.bodyTop + 8 + k * (b.detail ? 74 : 62)
|
|
77
|
+
els.push(shapeEl(`lt_b_dot_${i}_${k}`, pad + 2, y + 9, 8, 8, P.accent))
|
|
78
|
+
/* 单行框(34px)按 22px 字号最多容纳 ~36 个 CJK 字符:上限从 40 收紧到 36,
|
|
79
|
+
* 否则满长度纯中文要点会折行溢出,被 ppt_audit 判 text-overflow(2026-11-01)。 */
|
|
80
|
+
els.push(textEl(`lt_b_txt_${i}_${k}`, pad + 26, y, W - pad * 2 - 26, 34,
|
|
81
|
+
escapeHtml(tidy(b.text || '', 36)), { size, color: P.fg }))
|
|
82
|
+
if (b.detail) {
|
|
83
|
+
els.push(textEl(`lt_b_det_${i}_${k}`, pad + 26, y + 32, W - pad * 2 - 26, 28,
|
|
84
|
+
escapeHtml(tidy(b.detail, 60)), { size: 14, color: P.muted }))
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
return { id: `lt_bullets_${i}`, background: bgOf(P), elements: els }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function layoutTwo(P, s, i) {
|
|
91
|
+
const W = GRID.W, pad = GRID.pad
|
|
92
|
+
const cw = (W - pad * 2 - 24) / 2
|
|
93
|
+
const col = (side, data, x) => {
|
|
94
|
+
const els = [
|
|
95
|
+
shapeEl(`lt_two_card_${i}_${side}`, x, 150, cw, 330, P.card),
|
|
96
|
+
textEl(`lt_two_h_${i}_${side}`, x + 24, 172, cw - 48, 40,
|
|
97
|
+
escapeHtml(tidy((data && data.heading) || '', 20)), { size: 22, color: P.accent, bold: true }),
|
|
98
|
+
]
|
|
99
|
+
;((data && data.bullets) || []).slice(0, 4).forEach((b, k) => {
|
|
100
|
+
els.push(textEl(`lt_two_b_${i}_${side}_${k}`, x + 24, 226 + k * 56, cw - 48, 48,
|
|
101
|
+
escapeHtml(tidy(b, 30)), { size: 17, color: P.fg }))
|
|
102
|
+
})
|
|
103
|
+
return els
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
id: `lt_two_${i}`, background: bgOf(P), elements: [
|
|
107
|
+
textEl(`lt_two_title_${i}`, pad, GRID.titleTop, W - pad * 2, 70,
|
|
108
|
+
escapeHtml(tidy(s.title || '', 26)), { size: 30, color: P.fg, bold: true }),
|
|
109
|
+
...col('l', s.left, pad),
|
|
110
|
+
...col('r', s.right, pad + cw + 24),
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function layoutStat(P, s, i) {
|
|
116
|
+
const W = GRID.W, pad = GRID.pad
|
|
117
|
+
const stats = (s.stats || []).slice(0, 3)
|
|
118
|
+
const n = Math.max(1, stats.length)
|
|
119
|
+
const cw = (W - pad * 2 - (n - 1) * 24) / n
|
|
120
|
+
const els = [
|
|
121
|
+
textEl(`lt_st_title_${i}`, pad, GRID.titleTop, W - pad * 2, 70,
|
|
122
|
+
escapeHtml(tidy(s.title || '', 26)), { size: 30, color: P.fg, bold: true }),
|
|
123
|
+
]
|
|
124
|
+
stats.forEach((st, k) => {
|
|
125
|
+
const x = pad + k * (cw + 24)
|
|
126
|
+
els.push(shapeEl(`lt_st_card_${i}_${k}`, x, 150, cw, 260, P.card))
|
|
127
|
+
els.push(textEl(`lt_st_val_${i}_${k}`, x, 190, cw, 110,
|
|
128
|
+
escapeHtml(tidy(st.value || '', 10)), { size: 60, color: P.accent, bold: true, align: 'center' }))
|
|
129
|
+
els.push(textEl(`lt_st_lab_${i}_${k}`, x, 310, cw, 60,
|
|
130
|
+
escapeHtml(tidy(st.label || '', 20)), { size: 16, color: P.muted, align: 'center' }))
|
|
131
|
+
})
|
|
132
|
+
if (s.note) {
|
|
133
|
+
els.push(textEl(`lt_st_note_${i}`, pad, 450, W - pad * 2, 40,
|
|
134
|
+
escapeHtml(tidy(s.note, 80)), { size: 14, color: P.muted }))
|
|
135
|
+
}
|
|
136
|
+
return { id: `lt_stat_${i}`, background: bgOf(P), elements: els }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function layoutQuote(P, s, i) {
|
|
140
|
+
const W = GRID.W, pad = GRID.pad
|
|
141
|
+
return {
|
|
142
|
+
id: `lt_quote_${i}`, background: bgOf(P), elements: [
|
|
143
|
+
shapeEl(`lt_q_bar_${i}`, pad, 150, 6, 200, P.accent),
|
|
144
|
+
textEl(`lt_q_txt_${i}`, pad + 30, 150, W - pad * 2 - 30, 200,
|
|
145
|
+
escapeHtml(tidy(s.quote || '', 120)), { size: 28, color: P.fg }),
|
|
146
|
+
textEl(`lt_q_author_${i}`, pad + 30, 380, W - pad * 2 - 30, 40,
|
|
147
|
+
escapeHtml(tidy(s.author || '', 30)), { size: 16, color: P.muted }),
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function layoutEnd(P, s, i) {
|
|
153
|
+
const W = GRID.W
|
|
154
|
+
const pad = GRID.pad
|
|
155
|
+
return {
|
|
156
|
+
id: `lt_end_${i}`, background: bgOf(P), elements: [
|
|
157
|
+
shapeEl(`lt_end_bar_${i}`, 0, GRID.H - 14, W, 14, P.accent),
|
|
158
|
+
textEl(`lt_end_title_${i}`, pad, 200, W - pad * 2, 90,
|
|
159
|
+
escapeHtml(tidy(s.title || '谢谢观看', 20)), { size: 44, color: P.fg, bold: true, align: 'center' }),
|
|
160
|
+
textEl(`lt_end_sub_${i}`, pad, 300, W - pad * 2, 60,
|
|
161
|
+
escapeHtml(tidy(s.subtitle || '', 60)), { size: 18, color: P.muted, align: 'center' }),
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildDeck(input) {
|
|
167
|
+
const presetName = PRESETS[input.preset] ? input.preset : 'clean'
|
|
168
|
+
const P = PRESETS[presetName]
|
|
169
|
+
const slides = (input.slides || []).slice(0, 30).map((s, i) => buildSlide(P, s, i))
|
|
170
|
+
if (!slides.length) throw new Error('slides 不能为空')
|
|
171
|
+
return {
|
|
172
|
+
title: tidy(input.title || '未命名演示', 40),
|
|
173
|
+
slides,
|
|
174
|
+
theme: { themeColors: [P.accent, P.accent2, P.fg], fontColor: P.fg, backgroundColor: P.bg },
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 构建单页 slide(ppt_page 用),跳过 deck 级 theme/title 构建。 */
|
|
179
|
+
export function buildSlide(P, spec, index) {
|
|
180
|
+
const presetName = typeof P === 'string' ? (PRESETS[P] ? P : 'clean') : null
|
|
181
|
+
const preset = presetName ? PRESETS[presetName] : P
|
|
182
|
+
switch (spec.layout) {
|
|
183
|
+
case 'cover': return layoutCover(preset, spec, index)
|
|
184
|
+
case 'section': return layoutSection(preset, spec, index)
|
|
185
|
+
case 'two': return layoutTwo(preset, spec, index)
|
|
186
|
+
case 'stat': return layoutStat(preset, spec, index)
|
|
187
|
+
case 'quote': return layoutQuote(preset, spec, index)
|
|
188
|
+
case 'end': return layoutEnd(preset, spec, index)
|
|
189
|
+
default: return layoutBullets(preset, spec, index)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function escapeHtml(s) {
|
|
194
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
195
|
+
}
|
package/core/formula.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 公式(latex 元素)纯函数:hfmath 渲染 + 元素组装。
|
|
3
|
+
*
|
|
4
|
+
* PPTist latex 元素(编辑器以 stroke 渲染,非填充):
|
|
5
|
+
* { type:'latex', latex, path, color, strokeWidth, viewBox:[w,h], fixedRatio:true }
|
|
6
|
+
* path 坐标空间 = viewBox(hfmath 输出的单线笔画,空格分隔多子路径)。
|
|
7
|
+
*
|
|
8
|
+
* 渲染引擎 hfmath 是 PPTist 的 fork(零依赖 Hershey 单线字体),
|
|
9
|
+
* 源码转译在 core/hfmath/*.js,node:test 直测。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { hfmath } from './hfmath/index.js'
|
|
13
|
+
|
|
14
|
+
/** hfmath 默认渲染 padding(path 坐标与 viewBox 同坐标系)。 */
|
|
15
|
+
const HF_PADDING = 16
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 渲染 TeX 公式 → stroke path。
|
|
19
|
+
* @param {string} latex TeX 语法的数学公式(如 E=mc^2、\frac{a}{b}、x_i^2)
|
|
20
|
+
* @returns {{ path: string, viewBox: [number, number], width: number, height: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function renderFormula(latex) {
|
|
23
|
+
const src = String(latex == null ? '' : latex).trim()
|
|
24
|
+
if (!src) throw new Error('公式内容不能为空')
|
|
25
|
+
let f
|
|
26
|
+
try {
|
|
27
|
+
f = new hfmath(src)
|
|
28
|
+
f.pathd()
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error('公式解析失败:' + String((err && err.message) || err) + '(请检查 TeX 语法)')
|
|
31
|
+
}
|
|
32
|
+
const d = f.pathd()
|
|
33
|
+
if (!d || !d.trim()) throw new Error('公式渲染结果为空:请检查公式内容')
|
|
34
|
+
const box = f.box()
|
|
35
|
+
const vw = Math.ceil(box.x + box.w + HF_PADDING)
|
|
36
|
+
const vh = Math.ceil(box.y + box.h + HF_PADDING)
|
|
37
|
+
if (!isFinite(vw) || !isFinite(vh) || vw <= 0 || vh <= 0) {
|
|
38
|
+
throw new Error('公式渲染尺寸异常:' + JSON.stringify(box))
|
|
39
|
+
}
|
|
40
|
+
return { path: d, viewBox: [vw, vh], width: vw, height: vh }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 构建 latex 元素(path 必须已渲染——没有 path 的公式在编辑器里不可见)。
|
|
45
|
+
* @param {object} e { latex, path?, viewBox?, color?, strokeWidth?, width?, height?, left?, top?, rotate? }
|
|
46
|
+
*/
|
|
47
|
+
export function makeFormulaElement(e) {
|
|
48
|
+
if (!e || typeof e !== 'object') throw new Error('公式元素必须是对象')
|
|
49
|
+
const latex = String(e.latex == null ? '' : e.latex).trim()
|
|
50
|
+
if (!latex) throw new Error('公式内容 latex 不能为空')
|
|
51
|
+
let path = e.path
|
|
52
|
+
let viewBox = e.viewBox
|
|
53
|
+
if (!path) {
|
|
54
|
+
const rendered = renderFormula(latex)
|
|
55
|
+
path = rendered.path
|
|
56
|
+
viewBox = rendered.viewBox
|
|
57
|
+
}
|
|
58
|
+
const vw = Number(viewBox && viewBox[0] != null ? viewBox[0] : 200)
|
|
59
|
+
const vh = Number(viewBox && viewBox[1] != null ? viewBox[1] : 200)
|
|
60
|
+
if (!isFinite(vw) || !isFinite(vh) || vw <= 0 || vh <= 0) {
|
|
61
|
+
throw new Error('viewBox 无效:应为 [宽, 高] 正数')
|
|
62
|
+
}
|
|
63
|
+
const width = Number(e.width != null ? e.width : vw)
|
|
64
|
+
const height = Number(e.height != null ? e.height : vh)
|
|
65
|
+
return {
|
|
66
|
+
type: 'latex',
|
|
67
|
+
id: e.id || `formula_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
|
68
|
+
left: Number(e.left != null ? e.left : Math.round((1000 - width) / 2)),
|
|
69
|
+
top: Number(e.top != null ? e.top : 140),
|
|
70
|
+
width,
|
|
71
|
+
height,
|
|
72
|
+
rotate: Number(e.rotate || 0),
|
|
73
|
+
fixedRatio: true,
|
|
74
|
+
latex,
|
|
75
|
+
path: String(path),
|
|
76
|
+
color: e.color ? String(e.color) : '#333333',
|
|
77
|
+
strokeWidth: Number(e.strokeWidth != null ? e.strokeWidth : 2),
|
|
78
|
+
viewBox: [vw, vh],
|
|
79
|
+
}
|
|
80
|
+
}
|