@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,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(/&nbsp;/g, ' ')
38
+ .replace(/&amp;/g, '&')
39
+ .replace(/&lt;/g, '<')
40
+ .replace(/&gt;/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
+ }