@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.
- 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 +53 -6
- 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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dsh-ppt-workbench patch:服务解耦标准下只保留 dsh 侧的 MCP 接入行。
|
|
2
|
+
#
|
|
3
|
+
# 本包主体(relay:桥端点 + /mcp + 编辑器静态页)是**独立端口服务**,
|
|
4
|
+
# 不再 insert 进 dsh 进程——由 dsh-service-broker 按清单以独立进程拉起
|
|
5
|
+
# (standalone 入口:node server/relay.mjs [--port 39311] [--dir <hostDir>],
|
|
6
|
+
# dsh-service-host 骨架:/healthz 带 pid、单实例锁、优雅关闭)。
|
|
7
|
+
# 服务规格(broker services.json 片段):
|
|
8
|
+
# { "id": "ppt-workbench",
|
|
9
|
+
# "cmd": ["node", "<本包>/server/relay.mjs", "--port", "${PORT}"],
|
|
10
|
+
# "ports": { "main": 39311 },
|
|
11
|
+
# "url": "http://127.0.0.1:${PORT}" }
|
|
12
|
+
#
|
|
13
|
+
# mcp-ppt:官方 mcp-client 以 streamable-http 连本机 relay 的 /mcp。
|
|
14
|
+
# 纯静态 URL,无路径、无 !!js——装到任何机器都是同一份 patch。
|
|
15
|
+
# failOnStartupError:false:relay 由中间件拉起,就绪前连接失败只降级为
|
|
16
|
+
# 工具暂不可用(reconnect 会追上),不阻塞启动。
|
|
17
|
+
- insert:
|
|
18
|
+
- id: mcp-ppt
|
|
19
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
20
|
+
config:
|
|
21
|
+
serverName: ppt
|
|
22
|
+
transport: streamable-http
|
|
23
|
+
url: 'http://127.0.0.1:39311/mcp'
|
|
24
|
+
failOnStartupError: false
|
|
25
|
+
toolCallTimeoutMs: 120000
|
package/core/audit.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 确定性 deck 审计:不依赖浏览器/LLM,检查越界、文字溢出、文字重叠、
|
|
3
|
+
* 对比度、文字遮挡、重复文字、重复版式、配色漂移、重复图片与页面密度。
|
|
4
|
+
*
|
|
5
|
+
* 设计原则:
|
|
6
|
+
* - 文字载体统一走 textHolder():text 元素与「shape + text」都算文字;
|
|
7
|
+
* - 对比度按文字实际所处的底色计算(自身高亮 → 元素 fill → 下方形状 → 页面背景);
|
|
8
|
+
* - 宁可少报装饰形状的出血,也不让真正的文字硬伤被 warning 淹没。
|
|
9
|
+
*/
|
|
10
|
+
import { parseColor, luminance, contrastRatio } from './color.js'
|
|
11
|
+
import { textHolder, htmlOf } from './text.js'
|
|
12
|
+
|
|
13
|
+
const DEFAULT_FONT = 20
|
|
14
|
+
const CANVAS_W = 1000
|
|
15
|
+
const CANVAS_H = 562.5
|
|
16
|
+
|
|
17
|
+
/** 审计阈值调优钩子:进化搜索(bench/evolution)与生产覆盖共用。
|
|
18
|
+
* 通过 globalThis.PPT_AUDIT_TUNING 注入(保持 core 零依赖,可在浏览器端运行);
|
|
19
|
+
* 非法/缺失时回退默认值。默认值即 2026-11-01 进化固化的基线。 */
|
|
20
|
+
function auditTuning() {
|
|
21
|
+
const raw = globalThis.PPT_AUDIT_TUNING
|
|
22
|
+
return raw && typeof raw === 'object' ? raw : {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n))
|
|
26
|
+
|
|
27
|
+
function isNeutral(rgb) {
|
|
28
|
+
const diff = Number(auditTuning().neutralDiff) || 20
|
|
29
|
+
return Math.max(...rgb) - Math.min(...rgb) < diff || luminance(rgb) > 245 || luminance(rgb) < 10
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripHtml(html) {
|
|
33
|
+
return String(html || '')
|
|
34
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
35
|
+
.replace(/<\/p>/gi, ' ')
|
|
36
|
+
.replace(/<[^>]+>/g, '')
|
|
37
|
+
.replace(/ /g, ' ')
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** #rgb/#rrggbb/#rrggbbaa/rgb()/rgba() → {rgb, alpha};未知返回 null。 */
|
|
46
|
+
function parseRgba(value) {
|
|
47
|
+
const s = String(value || '').trim()
|
|
48
|
+
if (!s || /^(none|transparent)$/i.test(s)) return { rgb: null, alpha: 0 }
|
|
49
|
+
|
|
50
|
+
let m = /^#([0-9a-f]{8})$/i.exec(s)
|
|
51
|
+
if (m) {
|
|
52
|
+
const h = m[1]
|
|
53
|
+
return {
|
|
54
|
+
rgb: [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)],
|
|
55
|
+
alpha: parseInt(h.slice(6, 8), 16) / 255,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
m = /^#([0-9a-f]{4})$/i.exec(s)
|
|
59
|
+
if (m) {
|
|
60
|
+
const h = m[1].split('').map((c) => c + c).join('')
|
|
61
|
+
return {
|
|
62
|
+
rgb: [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)],
|
|
63
|
+
alpha: parseInt(h.slice(6, 8), 16) / 255,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
m = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(s)
|
|
67
|
+
if (m) {
|
|
68
|
+
return {
|
|
69
|
+
rgb: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
70
|
+
alpha: m[4] != null ? clamp(Number(m[4]), 0, 1) : 1,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const rgb = parseColor(s)
|
|
74
|
+
return rgb ? { rgb, alpha: 1 } : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const blendOver = (fg, bg, alpha) => fg.map((c, i) => Math.round(c * alpha + bg[i] * (1 - alpha)))
|
|
78
|
+
|
|
79
|
+
const boxOf = (element) => {
|
|
80
|
+
const left = Number(element?.left ?? 0)
|
|
81
|
+
const top = Number(element?.top ?? 0)
|
|
82
|
+
const width = Number(element?.width ?? 0)
|
|
83
|
+
const height = Number(element?.height ?? 0)
|
|
84
|
+
return { left, top, width, height }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const areaOf = (box) => Math.max(0, box.width) * Math.max(0, box.height)
|
|
88
|
+
|
|
89
|
+
function overlapArea(a, b) {
|
|
90
|
+
const x = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left))
|
|
91
|
+
const y = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top))
|
|
92
|
+
return x * y
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 取 CSS 声明值;负向后顾避免把 background-color 当成 color。 */
|
|
96
|
+
function cssValue(html, prop) {
|
|
97
|
+
const source = String(html || '')
|
|
98
|
+
const re = new RegExp(`(?<![-\\w])${prop}\\s*:\\s*([^;"'}]+)`, 'i')
|
|
99
|
+
const match = re.exec(source)
|
|
100
|
+
return match ? match[1].trim() : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fontSizeOf(element) {
|
|
104
|
+
const html = htmlOf(element)
|
|
105
|
+
let size = 0
|
|
106
|
+
for (const match of String(html || '').matchAll(/font-size\s*:\s*(\d+(?:\.\d+)?)\s*px/gi)) {
|
|
107
|
+
size = Math.max(size, Number(match[1]))
|
|
108
|
+
}
|
|
109
|
+
if (!size) {
|
|
110
|
+
size = Number(
|
|
111
|
+
element?.fontSize
|
|
112
|
+
?? element?.style?.fontSize
|
|
113
|
+
?? element?.text?.fontSize
|
|
114
|
+
?? DEFAULT_FONT,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return size || DEFAULT_FONT
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function textStyleOf(element) {
|
|
121
|
+
const html = htmlOf(element)
|
|
122
|
+
const holder = textHolder(element)
|
|
123
|
+
const colorRaw = cssValue(html, 'color')
|
|
124
|
+
|| element?.color
|
|
125
|
+
|| element?.defaultColor
|
|
126
|
+
|| holder?.defaultColor
|
|
127
|
+
|| null
|
|
128
|
+
const highlightRaw = cssValue(html, 'background-color') || null
|
|
129
|
+
const align = (cssValue(html, 'text-align') || element?.align || 'left').toLowerCase()
|
|
130
|
+
return { colorRaw, highlightRaw, align }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 估算文字实际占据的矩形,避免用过宽的空文本框去匹配背景色块。 */
|
|
134
|
+
function glyphBoxOf(item) {
|
|
135
|
+
const { box, text, fontSize, align } = item
|
|
136
|
+
if (!text) return box
|
|
137
|
+
let fullWidth = 0
|
|
138
|
+
for (const char of text) {
|
|
139
|
+
fullWidth += /[\u3400-\u9fff\uff00-\uffef]/.test(char) ? fontSize : fontSize * 0.55
|
|
140
|
+
}
|
|
141
|
+
const lineWidth = Math.min(box.width, Math.max(fontSize, fullWidth))
|
|
142
|
+
const lines = Math.max(1, Math.ceil(fullWidth / Math.max(1, box.width)))
|
|
143
|
+
const height = Math.min(box.height, lines * fontSize * 1.35)
|
|
144
|
+
let left = box.left
|
|
145
|
+
if (align === 'center') left += (box.width - lineWidth) / 2
|
|
146
|
+
else if (align === 'right') left += box.width - lineWidth
|
|
147
|
+
const top = box.top + Math.max(0, (box.height - height) / 2)
|
|
148
|
+
return { left, top, width: lineWidth, height }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function slideBackground(slide) {
|
|
152
|
+
const bg = slide?.background
|
|
153
|
+
if (!bg) return [255, 255, 255]
|
|
154
|
+
if (bg.type && bg.type !== 'solid') return null
|
|
155
|
+
const parsed = parseRgba(bg.color)
|
|
156
|
+
if (!parsed || !parsed.rgb) return [255, 255, 255]
|
|
157
|
+
return parsed.alpha >= 0.999 ? parsed.rgb : blendOver(parsed.rgb, [255, 255, 255], parsed.alpha)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 只有矩形形状的 bbox 才近似等于实际覆盖区域;其他形状用采样判断。 */
|
|
161
|
+
function isRectShape(element) {
|
|
162
|
+
return element?.type === 'shape'
|
|
163
|
+
&& /^M\s*0\s+0\s+L\s*200\s+0\s+L\s*200\s+200\s+L\s*0\s+200\s*Z/i.test(String(element.path || '').trim())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function polygonPoints(element) {
|
|
167
|
+
const nums = String(element?.path || '').match(/-?\d+(?:\.\d+)?/g)
|
|
168
|
+
if (!nums || nums.length < 6) return null
|
|
169
|
+
const points = []
|
|
170
|
+
for (let i = 0; i + 1 < nums.length; i += 2) points.push([Number(nums[i]), Number(nums[i + 1])])
|
|
171
|
+
return points
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pointInPolygon(x, y, points) {
|
|
175
|
+
let inside = false
|
|
176
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
177
|
+
const [xi, yi] = points[i]
|
|
178
|
+
const [xj, yj] = points[j]
|
|
179
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside
|
|
180
|
+
}
|
|
181
|
+
return inside
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 形状对文字矩形的覆盖比例:矩形走面积,多边形先 bbox 快筛再 4×4 采样。 */
|
|
185
|
+
function shapeCoverage(glyph, element) {
|
|
186
|
+
if (!element || !glyph.width || !glyph.height) return 0
|
|
187
|
+
const ebox = boxOf(element)
|
|
188
|
+
if (element.type === 'image') return overlapArea(glyph, ebox) / Math.max(1, areaOf(glyph))
|
|
189
|
+
if (element.type !== 'shape') return 0
|
|
190
|
+
/* bbox 快筛:无交集直接返回 0,跳过多边形解析和采样 */
|
|
191
|
+
if (!overlapArea(glyph, ebox)) return 0
|
|
192
|
+
if (isRectShape(element) || !element.path) {
|
|
193
|
+
return overlapArea(glyph, ebox) / Math.max(1, areaOf(glyph))
|
|
194
|
+
}
|
|
195
|
+
const points = polygonPoints(element)
|
|
196
|
+
if (!points) return 0
|
|
197
|
+
const viewBox = Array.isArray(element.viewBox) ? element.viewBox : [200, 200]
|
|
198
|
+
let inside = 0
|
|
199
|
+
let total = 0
|
|
200
|
+
for (let gx = 1; gx <= 4; gx++) {
|
|
201
|
+
for (let gy = 1; gy <= 4; gy++) {
|
|
202
|
+
const x = glyph.left + (glyph.width * gx) / 5
|
|
203
|
+
const y = glyph.top + (glyph.height * gy) / 5
|
|
204
|
+
if (x < ebox.left || x > ebox.left + ebox.width || y < ebox.top || y > ebox.top + ebox.height) continue
|
|
205
|
+
total++
|
|
206
|
+
const px = ((x - ebox.left) / Math.max(1, ebox.width)) * viewBox[0]
|
|
207
|
+
const py = ((y - ebox.top) / Math.max(1, ebox.height)) * viewBox[1]
|
|
208
|
+
if (pointInPolygon(px, py, points)) inside++
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return total ? inside / total : 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 文字实际所处的底色:自身高亮 → 自身 fill → 下方形状(按 alpha 混合)→ 页面背景。 */
|
|
215
|
+
function effectiveBackground(item, slide, elements) {
|
|
216
|
+
let base = slideBackground(slide)
|
|
217
|
+
if (!base) return null
|
|
218
|
+
|
|
219
|
+
const layers = []
|
|
220
|
+
for (let i = 0; i < item.index; i++) {
|
|
221
|
+
const lower = elements[i]
|
|
222
|
+
if (lower.type !== 'shape' && lower.type !== 'text' && lower.type !== 'image') continue
|
|
223
|
+
const coverage = shapeCoverage(item.glyph, lower)
|
|
224
|
+
if (coverage < 0.5) continue
|
|
225
|
+
|
|
226
|
+
if (lower.type === 'image') {
|
|
227
|
+
layers.push({ unknown: true })
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (lower.type !== 'shape' && lower.type !== 'text') continue
|
|
231
|
+
|
|
232
|
+
const fill = parseRgba(lower.fill)
|
|
233
|
+
if (fill && fill.rgb && fill.alpha > 0) {
|
|
234
|
+
if (fill.alpha >= 0.999) layers.length = 0
|
|
235
|
+
layers.push(fill)
|
|
236
|
+
} else if (lower.type === 'shape' && lower.fill && !/^(none|transparent)$/i.test(String(lower.fill))) {
|
|
237
|
+
layers.push({ unknown: true })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const layer of layers) {
|
|
242
|
+
if (layer.unknown) return null
|
|
243
|
+
base = blendOver(layer.rgb, base, layer.alpha)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const ownFill = parseRgba(item.element?.fill)
|
|
247
|
+
if (ownFill && ownFill.rgb && ownFill.alpha > 0) {
|
|
248
|
+
base = blendOver(ownFill.rgb, base, ownFill.alpha)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (item.highlightRaw) {
|
|
252
|
+
const highlight = parseRgba(item.highlightRaw)
|
|
253
|
+
if (highlight && highlight.rgb && highlight.alpha > 0) {
|
|
254
|
+
base = blendOver(highlight.rgb, base, highlight.alpha)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return base
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 版式指纹:忽略文字内容,只看非文字元素的结构与几何。 */
|
|
261
|
+
function layoutFingerprint(slide) {
|
|
262
|
+
const parts = (slide?.elements || [])
|
|
263
|
+
.filter((element) => element.type !== 'text')
|
|
264
|
+
.map((element) => {
|
|
265
|
+
const box = boxOf(element)
|
|
266
|
+
return [
|
|
267
|
+
element.type,
|
|
268
|
+
element.path || element.src || '',
|
|
269
|
+
Math.round(box.left), Math.round(box.top),
|
|
270
|
+
Math.round(box.width), Math.round(box.height),
|
|
271
|
+
String(element.fill || ''),
|
|
272
|
+
].join(':')
|
|
273
|
+
})
|
|
274
|
+
.sort()
|
|
275
|
+
return parts.join('|')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 只有大块装饰版面重复才算设计问题;clean 的小线条/圆点属于统一版式。 */
|
|
279
|
+
function hasHeavyDecoration(slide) {
|
|
280
|
+
return (slide?.elements || []).some((element) => {
|
|
281
|
+
if (element.type !== 'shape') return false
|
|
282
|
+
return areaOf(boxOf(element)) > CANVAS_W * CANVAS_H * 0.18
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {object} deck
|
|
288
|
+
* @param {{maxCharsPerSlide?: number, paletteMax?: number, repeatedLayoutMin?: number}} [options]
|
|
289
|
+
* @returns {{score:number, pass:boolean, issues:Array, blocking:Array, summary:object}}
|
|
290
|
+
*/
|
|
291
|
+
export function auditDeck(deck, options = {}) {
|
|
292
|
+
const maxCharsPerSlide = options.maxCharsPerSlide ?? 320
|
|
293
|
+
const paletteMax = options.paletteMax ?? 3
|
|
294
|
+
const repeatedLayoutMin = options.repeatedLayoutMin ?? 3
|
|
295
|
+
const issues = []
|
|
296
|
+
const palette = new Set()
|
|
297
|
+
/* 主题声明的色板是设计意图的一部分:预先登记,不计入“配色漂移”。
|
|
298
|
+
* (进化轮发现:buildDeck 生成稿的 bg/accent/accent2 会触发假 palette-drift。) */
|
|
299
|
+
const themeColors = new Set(
|
|
300
|
+
(Array.isArray(deck?.theme?.themeColors) ? deck.theme.themeColors : [])
|
|
301
|
+
.map((color) => String(color || '').toLowerCase()),
|
|
302
|
+
)
|
|
303
|
+
const images = new Map()
|
|
304
|
+
const slides = Array.isArray(deck?.slides) ? deck.slides : []
|
|
305
|
+
|
|
306
|
+
const push = (severity, page, code, message, element = '') => {
|
|
307
|
+
issues.push({ severity, page, element, code, message })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
slides.forEach((slide, pageIndex) => {
|
|
311
|
+
const page = pageIndex + 1
|
|
312
|
+
const elements = Array.isArray(slide?.elements) ? slide.elements : []
|
|
313
|
+
if (!elements.length) push('warning', page, 'empty-slide', '页面没有元素')
|
|
314
|
+
|
|
315
|
+
const bg = parseColor(slide?.background?.color)
|
|
316
|
+
if (bg && !isNeutral(bg)) palette.add(String(slide.background.color).toLowerCase())
|
|
317
|
+
|
|
318
|
+
let textChars = 0
|
|
319
|
+
const textItems = []
|
|
320
|
+
|
|
321
|
+
elements.forEach((element, index) => {
|
|
322
|
+
const holder = textHolder(element)
|
|
323
|
+
if (element.type === 'shape') {
|
|
324
|
+
const fill = parseColor(element.fill)
|
|
325
|
+
if (fill && !isNeutral(fill)) palette.add(String(element.fill).toLowerCase())
|
|
326
|
+
}
|
|
327
|
+
if (element.type === 'image' && element.src) {
|
|
328
|
+
const key = String(element.src).slice(0, 96)
|
|
329
|
+
images.set(key, (images.get(key) || 0) + 1)
|
|
330
|
+
}
|
|
331
|
+
if (!holder) return
|
|
332
|
+
|
|
333
|
+
const box = boxOf(element)
|
|
334
|
+
const text = stripHtml(htmlOf(element))
|
|
335
|
+
const fontSize = fontSizeOf(element)
|
|
336
|
+
const style = textStyleOf(element)
|
|
337
|
+
const highlightRgba = parseRgba(style.highlightRaw)
|
|
338
|
+
const item = {
|
|
339
|
+
element, index, holder, text, box, fontSize,
|
|
340
|
+
color: parseColor(style.colorRaw),
|
|
341
|
+
highlight: highlightRgba && highlightRgba.alpha > 0 ? parseColor(style.highlightRaw) : null,
|
|
342
|
+
highlightRaw: style.highlightRaw,
|
|
343
|
+
align: style.align,
|
|
344
|
+
kind: element.type === 'shape' ? 'shape' : 'text',
|
|
345
|
+
}
|
|
346
|
+
item.glyph = glyphBoxOf(item)
|
|
347
|
+
textItems.push(item)
|
|
348
|
+
textChars += text.length
|
|
349
|
+
|
|
350
|
+
if (box.left < -1 || box.top < -1 || box.left + box.width > CANVAS_W + 1 || box.top + box.height > CANVAS_H + 1) {
|
|
351
|
+
push('error', page, 'bounds',
|
|
352
|
+
`文字越界:(${Math.round(box.left)},${Math.round(box.top)}) ${Math.round(box.width)}×${Math.round(box.height)}`,
|
|
353
|
+
element.id)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const cjk = /[\u3400-\u9fff]/.test(text)
|
|
357
|
+
const charsPerLine = Math.max(1, Math.floor(box.width / (fontSize * (cjk ? 1 : 0.55))))
|
|
358
|
+
const lines = Math.max(1, Math.ceil(text.length / charsPerLine))
|
|
359
|
+
const needed = lines * fontSize * 1.35
|
|
360
|
+
const tuning = auditTuning()
|
|
361
|
+
const overflowWarnK = Number(tuning.overflowWarnK) || 1.2
|
|
362
|
+
const overflowErrK = Number(tuning.overflowErrK) || 1.5
|
|
363
|
+
if (needed > box.height * overflowWarnK + 4) {
|
|
364
|
+
push(needed > box.height * overflowErrK + 8 ? 'error' : 'warning', page, 'text-overflow',
|
|
365
|
+
`文字可能溢出:约 ${lines} 行,需要 ${Math.round(needed)}px,框高 ${Math.round(box.height)}px`, element.id)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (item.color) {
|
|
369
|
+
const effective = effectiveBackground(item, slide, elements)
|
|
370
|
+
if (effective) {
|
|
371
|
+
const ratio = contrastRatio(item.color, effective)
|
|
372
|
+
const large = fontSize >= 24
|
|
373
|
+
const minimum = large ? 3 : 4.5
|
|
374
|
+
if (item.highlight && ratio < 1.15) {
|
|
375
|
+
push('error', page, 'text-invisible',
|
|
376
|
+
`文字与自身高亮背景几乎同色(${ratio.toFixed(2)}:1),文字不可见`, element.id)
|
|
377
|
+
} else if (ratio < minimum) {
|
|
378
|
+
push('error', page, 'contrast', `文字对比度 ${ratio.toFixed(2)}:1,低于 ${minimum}:1`, element.id)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (item.color && !isNeutral(item.color)) {
|
|
384
|
+
palette.add(`#${item.color.map((v) => v.toString(16).padStart(2, '0')).join('')}`)
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
/* 文字重叠 / 同文案重复绘制 */
|
|
389
|
+
for (let i = 0; i < textItems.length; i++) {
|
|
390
|
+
for (let j = i + 1; j < textItems.length; j++) {
|
|
391
|
+
const a = textItems[i]
|
|
392
|
+
const b = textItems[j]
|
|
393
|
+
if (!a.text || !b.text) continue
|
|
394
|
+
const overlap = overlapArea(a.glyph, b.glyph)
|
|
395
|
+
const minArea = Math.min(areaOf(a.glyph), areaOf(b.glyph))
|
|
396
|
+
if (minArea <= 0) continue
|
|
397
|
+
const ratio = overlap / minArea
|
|
398
|
+
const tuning = auditTuning()
|
|
399
|
+
const overlapMin = Number(tuning.overlapMin) || 0.15
|
|
400
|
+
const overlapErr = Number(tuning.overlapErr) || 0.3
|
|
401
|
+
if (ratio <= overlapMin) continue
|
|
402
|
+
|
|
403
|
+
if (a.text === b.text && a.text.length >= 2) {
|
|
404
|
+
push('error', page, 'duplicate-text',
|
|
405
|
+
`同一段文字重复绘制:${a.element.id} 与 ${b.element.id}(“${a.text.slice(0, 24)}”)`,
|
|
406
|
+
`${a.element.id},${b.element.id}`)
|
|
407
|
+
} else if (ratio > overlapErr) {
|
|
408
|
+
push('error', page, 'text-overlap',
|
|
409
|
+
`文本元素重叠:${a.element.id} 与 ${b.element.id}(重叠 ${Math.round(ratio * 100)}%)`,
|
|
410
|
+
`${a.element.id},${b.element.id}`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* 文字被上层不透明元素遮挡 */
|
|
416
|
+
for (const item of textItems) {
|
|
417
|
+
for (let i = item.index + 1; i < elements.length; i++) {
|
|
418
|
+
const upper = elements[i]
|
|
419
|
+
if (upper.type !== 'shape' && upper.type !== 'image') continue
|
|
420
|
+
const coverage = shapeCoverage(item.glyph, upper)
|
|
421
|
+
if (coverage <= 0.55) continue
|
|
422
|
+
if (upper.type === 'image') {
|
|
423
|
+
push('error', page, 'text-occluded', `文字 ${item.element.id} 被上层图片遮挡`, item.element.id)
|
|
424
|
+
break
|
|
425
|
+
}
|
|
426
|
+
const fill = parseRgba(upper.fill)
|
|
427
|
+
if (fill && fill.rgb && fill.alpha >= 0.85) {
|
|
428
|
+
push('error', page, 'text-occluded', `文字 ${item.element.id} 被上层形状 ${upper.id} 遮挡`, item.element.id)
|
|
429
|
+
break
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (textChars > maxCharsPerSlide) {
|
|
435
|
+
push(textChars > maxCharsPerSlide * 1.5 ? 'error' : 'warning', page, 'density',
|
|
436
|
+
`页面文字偏多:${textChars} 字(建议 ≤ ${maxCharsPerSlide})`)
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
/* 重复版式:同一套非文字结构出现在 3 页以上,且这些页都有正文。 */
|
|
441
|
+
const layoutGroups = new Map()
|
|
442
|
+
slides.forEach((slide, pageIndex) => {
|
|
443
|
+
const fingerprint = layoutFingerprint(slide)
|
|
444
|
+
if (!fingerprint || !hasHeavyDecoration(slide)) return
|
|
445
|
+
const bodyTexts = (slide.elements || [])
|
|
446
|
+
.filter((element) => textHolder(element))
|
|
447
|
+
.map((element) => stripHtml(htmlOf(element)))
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
if (bodyTexts.length < 4 || bodyTexts.join('').length < 50) return
|
|
450
|
+
if (!layoutGroups.has(fingerprint)) layoutGroups.set(fingerprint, [])
|
|
451
|
+
layoutGroups.get(fingerprint).push(pageIndex + 1)
|
|
452
|
+
})
|
|
453
|
+
for (const pages of layoutGroups.values()) {
|
|
454
|
+
if (pages.length >= repeatedLayoutMin) {
|
|
455
|
+
push('error', pages[0], 'repeated-layout',
|
|
456
|
+
`重复版式:${pages.length} 页共用同一套结构(${pages.map((p) => `p${p}`).join('、')})`)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const [src, count] of images) {
|
|
461
|
+
if (count > 1) push('warning', 0, 'duplicate-image', `同一张图片重复使用 ${count} 次:${src.slice(0, 48)}…`)
|
|
462
|
+
}
|
|
463
|
+
const offPalette = [...palette].filter((color) => !themeColors.has(color))
|
|
464
|
+
if (offPalette.length > paletteMax) {
|
|
465
|
+
push('warning', 0, 'palette-drift', `非中性色过多:${offPalette.length} 种(建议 ≤ ${paletteMax})`)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const errors = issues.filter((item) => item.severity === 'error').length
|
|
469
|
+
const warnings = issues.filter((item) => item.severity === 'warning').length
|
|
470
|
+
const score = clamp(100 - errors * 6 - warnings * 2, 0, 100)
|
|
471
|
+
const blocking = issues
|
|
472
|
+
.filter((item) => item.severity === 'error')
|
|
473
|
+
.map(({ page, code, element, message }) => ({ page, code, element, message }))
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
score,
|
|
477
|
+
pass: errors === 0,
|
|
478
|
+
issues,
|
|
479
|
+
blocking,
|
|
480
|
+
summary: {
|
|
481
|
+
slides: slides.length,
|
|
482
|
+
elements: slides.reduce((sum, slide) => sum + (slide.elements?.length || 0), 0),
|
|
483
|
+
textHolders: slides.reduce((sum, s) => sum + (s.elements || []).filter((e) => textHolder(e)).length, 0),
|
|
484
|
+
errors,
|
|
485
|
+
warnings,
|
|
486
|
+
paletteColors: palette.size,
|
|
487
|
+
imageReuse: [...images.values()].filter((count) => count > 1).length,
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function formatAudit(result) {
|
|
493
|
+
const lines = [
|
|
494
|
+
`score ${result.score}/100 · pass=${result.pass ? 'true' : 'false'} · ${result.summary.errors} errors · ${result.summary.warnings} warnings`,
|
|
495
|
+
]
|
|
496
|
+
for (const issue of result.issues) {
|
|
497
|
+
lines.push(`[${issue.severity}] p${issue.page || '-'} ${issue.code}: ${issue.message}${issue.element ? ` (${issue.element})` : ''}`)
|
|
498
|
+
}
|
|
499
|
+
return lines.join('\n')
|
|
500
|
+
}
|