dsh-drawai 0.1.1 → 0.2.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/README.md +40 -14
- package/lib/client.js +10917 -8389
- package/lib/index.js +4107 -3776
- package/lib/mxfile.js +86 -15
- package/lib/raster.js +313 -0
- package/lib/style-kernel.js +209 -1
- package/lib/trace-image.js +904 -0
- package/package.json +2 -2
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
/**
|
|
3
|
+
* 逐像素矢量化 —— 把一张位图变成一段内嵌 SVG 标记(DrawAI 的 svg 内容节点用)。
|
|
4
|
+
*
|
|
5
|
+
* 这里只有**像素进、SVG 出**,不碰文件系统、不碰 ctx:宿主工具与命令行脚本共用同一份。
|
|
6
|
+
*
|
|
7
|
+
* 管线(每一步都能在返回的 metrics 里看到数字):
|
|
8
|
+
* ① 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
|
|
9
|
+
* ② k-means 定调色板(K 个前景色 + 背景单独占第 0 号)
|
|
10
|
+
* ③ 3× 超采样逐样本归类 → 3×3 众数滤波 → 降回原分辨率 → <minArea 的碎块并入邻色
|
|
11
|
+
* ④ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
|
|
12
|
+
* ⑤ RDP 简化 + 相对增量编码 → 每色一条 <path fill-rule="nonzero">
|
|
13
|
+
*
|
|
14
|
+
* 为什么是"格边跟随"而不是 marching squares:标签图在鞍点(对角相接)处 marching squares
|
|
15
|
+
* 要人为定连通性,而格边有向图本身就无歧义 —— 每条边只属于一个区域,走完一圈恰好回到起点。
|
|
16
|
+
* 代价是必须用 4 连通分区(对角相接算两个区域),见下面 connectedRegions 的注释。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 把一个范围的数夹进 [lo, hi]。 */
|
|
20
|
+
function clamp(v, lo, hi) {
|
|
21
|
+
return v < lo ? lo : v > hi ? hi : v
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ────────────────────────── ① 背景 ────────────────────────── */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从四边泛洪标出背景。
|
|
28
|
+
*
|
|
29
|
+
* 判据是"近白/近灰"而不是"等于纯白":截图和导出图的白底常带一点点色偏(#fdfdfc 这种),
|
|
30
|
+
* 用纯白判会漏掉大片底色。泛洪(而不是全局阈值)保证只吃掉**与边界连通**的那一片 ——
|
|
31
|
+
* 角色内部的白围裙不连边界,不会被误删。
|
|
32
|
+
*/
|
|
33
|
+
function markBackground(rgba, width, height, options) {
|
|
34
|
+
const bright = options.bgBright
|
|
35
|
+
const tol = options.bgTol
|
|
36
|
+
const isBg = new Uint8Array(width * height)
|
|
37
|
+
const stack = []
|
|
38
|
+
|
|
39
|
+
function nearWhite(i) {
|
|
40
|
+
const o = i * 4
|
|
41
|
+
const a = rgba[o + 3]
|
|
42
|
+
if (a < 16) return true // 全透明也算底色
|
|
43
|
+
const r = rgba[o]
|
|
44
|
+
const g = rgba[o + 1]
|
|
45
|
+
const b = rgba[o + 2]
|
|
46
|
+
const mx = r > g ? (r > b ? r : b) : g > b ? g : b
|
|
47
|
+
const mn = r < g ? (r < b ? r : b) : g < b ? g : b
|
|
48
|
+
return mx >= bright && mx - mn <= tol
|
|
49
|
+
}
|
|
50
|
+
function push(x, y) {
|
|
51
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return
|
|
52
|
+
const i = y * width + x
|
|
53
|
+
if (isBg[i] === 1) return
|
|
54
|
+
if (!nearWhite(i)) return
|
|
55
|
+
isBg[i] = 1
|
|
56
|
+
stack.push(i)
|
|
57
|
+
}
|
|
58
|
+
for (let x = 0; x < width; x += 1) {
|
|
59
|
+
push(x, 0)
|
|
60
|
+
push(x, height - 1)
|
|
61
|
+
}
|
|
62
|
+
for (let y = 0; y < height; y += 1) {
|
|
63
|
+
push(0, y)
|
|
64
|
+
push(width - 1, y)
|
|
65
|
+
}
|
|
66
|
+
while (stack.length > 0) {
|
|
67
|
+
const i = stack.pop()
|
|
68
|
+
const x = i % width
|
|
69
|
+
const y = (i - x) / width
|
|
70
|
+
push(x + 1, y)
|
|
71
|
+
push(x - 1, y)
|
|
72
|
+
push(x, y + 1)
|
|
73
|
+
push(x, y - 1)
|
|
74
|
+
}
|
|
75
|
+
return isBg
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ────────────────────────── ② 调色板 ────────────────────────── */
|
|
79
|
+
|
|
80
|
+
/** 线性同余随机数(自带,避免依赖 Math.random 的可重复性问题)。 */
|
|
81
|
+
function makeRandom(seed) {
|
|
82
|
+
let state = seed >>> 0
|
|
83
|
+
return function next() {
|
|
84
|
+
state = (state * 1664525 + 1013904223) >>> 0
|
|
85
|
+
return state / 4294967296
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* k-means(k-means++ 初始化 + Lloyd 迭代)。
|
|
91
|
+
*
|
|
92
|
+
* 样本是 `Float64Array(n*3)` 的 RGB。空簇会去抢一个最远的样本,免得调色板里出现重复色。
|
|
93
|
+
*/
|
|
94
|
+
function kmeans(samples, k, iterations, seed) {
|
|
95
|
+
const n = samples.length / 3
|
|
96
|
+
const rand = makeRandom(seed)
|
|
97
|
+
const cent = []
|
|
98
|
+
// 首个质心的三个通道**各取一个随机样本**(不是同一个像素的三个通道)——
|
|
99
|
+
// 与 tools/vectorize-image.mjs 的那份实现保持一致:两处跑同一张图必须给出同一份调色板,
|
|
100
|
+
// 否则"命令行跑出来的"和"插件跑出来的"会是两张不一样的图。
|
|
101
|
+
const r0 = Math.floor(rand() * n)
|
|
102
|
+
const g0 = Math.floor(rand() * n)
|
|
103
|
+
const b0 = Math.floor(rand() * n)
|
|
104
|
+
cent.push([samples[r0 * 3], samples[g0 * 3 + 1], samples[b0 * 3 + 2]])
|
|
105
|
+
const dist2 = new Float64Array(n).fill(Infinity)
|
|
106
|
+
while (cent.length < k) {
|
|
107
|
+
const last = cent[cent.length - 1]
|
|
108
|
+
let sum = 0
|
|
109
|
+
for (let i = 0; i < n; i += 1) {
|
|
110
|
+
const dr = samples[i * 3] - last[0]
|
|
111
|
+
const dg = samples[i * 3 + 1] - last[1]
|
|
112
|
+
const db = samples[i * 3 + 2] - last[2]
|
|
113
|
+
const d = dr * dr + dg * dg + db * db
|
|
114
|
+
if (d < dist2[i]) dist2[i] = d
|
|
115
|
+
sum += dist2[i]
|
|
116
|
+
}
|
|
117
|
+
let t = rand() * sum
|
|
118
|
+
let pick = n - 1
|
|
119
|
+
for (let i = 0; i < n; i += 1) {
|
|
120
|
+
t -= dist2[i]
|
|
121
|
+
if (t <= 0) {
|
|
122
|
+
pick = i
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cent.push([samples[pick * 3], samples[pick * 3 + 1], samples[pick * 3 + 2]])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const assign = new Uint8Array(n)
|
|
130
|
+
const sum = new Float64Array(k * 3)
|
|
131
|
+
const count = new Int32Array(k)
|
|
132
|
+
let inertia = 0
|
|
133
|
+
for (let iter = 0; iter < iterations; iter += 1) {
|
|
134
|
+
sum.fill(0)
|
|
135
|
+
count.fill(0)
|
|
136
|
+
inertia = 0
|
|
137
|
+
for (let i = 0; i < n; i += 1) {
|
|
138
|
+
const r = samples[i * 3]
|
|
139
|
+
const g = samples[i * 3 + 1]
|
|
140
|
+
const b = samples[i * 3 + 2]
|
|
141
|
+
let best = 0
|
|
142
|
+
let bestD = Infinity
|
|
143
|
+
for (let c = 0; c < k; c += 1) {
|
|
144
|
+
const dr = r - cent[c][0]
|
|
145
|
+
const dg = g - cent[c][1]
|
|
146
|
+
const db = b - cent[c][2]
|
|
147
|
+
const d = dr * dr + dg * dg + db * db
|
|
148
|
+
if (d < bestD) {
|
|
149
|
+
bestD = d
|
|
150
|
+
best = c
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
assign[i] = best
|
|
154
|
+
inertia += bestD
|
|
155
|
+
sum[best * 3] += r
|
|
156
|
+
sum[best * 3 + 1] += g
|
|
157
|
+
sum[best * 3 + 2] += b
|
|
158
|
+
count[best] += 1
|
|
159
|
+
}
|
|
160
|
+
for (let c = 0; c < k; c += 1) {
|
|
161
|
+
if (count[c] === 0) {
|
|
162
|
+
let far = 0
|
|
163
|
+
let farD = -1
|
|
164
|
+
for (let i = 0; i < n; i += 7) {
|
|
165
|
+
const dr = samples[i * 3] - cent[c][0]
|
|
166
|
+
const dg = samples[i * 3 + 1] - cent[c][1]
|
|
167
|
+
const db = samples[i * 3 + 2] - cent[c][2]
|
|
168
|
+
const d = dr * dr + dg * dg + db * db
|
|
169
|
+
if (d > farD) {
|
|
170
|
+
farD = d
|
|
171
|
+
far = i
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
cent[c] = [samples[far * 3], samples[far * 3 + 1], samples[far * 3 + 2]]
|
|
175
|
+
} else {
|
|
176
|
+
cent[c] = [sum[c * 3] / count[c], sum[c * 3 + 1] / count[c], sum[c * 3 + 2] / count[c]]
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { cent: cent, assign: assign, inertia: inertia }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ────────────────────────── ④ 轮廓 ────────────────────────── */
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 按颜色把像素分成 4 连通区域,并沿**格边**走出闭合环。
|
|
187
|
+
*
|
|
188
|
+
* 区域 = (颜色, 4 连通块)。用 4 连通是刻意的:一条格边只能属于一个区域,
|
|
189
|
+
* 而对角相接的两个像素在格边图上本来就不相邻 —— 硬按 8 连通合并它们,左转规则会在
|
|
190
|
+
* 那个"捏角点"处找不到出口。
|
|
191
|
+
*
|
|
192
|
+
* 边的方向是**区域恒在左侧**:右边界朝上、下边界朝左。方向给错的话整张图的入度就不等于
|
|
193
|
+
* 出度,走一步就断在空气里(这个坑真实踩过:外环整圈变成一堆 3~4 个点的碎片)。
|
|
194
|
+
*/
|
|
195
|
+
function traceLoops(comp, width, height, regionCount) {
|
|
196
|
+
const stride = width + 1
|
|
197
|
+
const outMap = new Map()
|
|
198
|
+
function addEdge(cornerX, cornerY, dx, dy, id) {
|
|
199
|
+
const key = cornerY * stride + cornerX
|
|
200
|
+
let list = outMap.get(key)
|
|
201
|
+
if (list === undefined) {
|
|
202
|
+
list = []
|
|
203
|
+
outMap.set(key, list)
|
|
204
|
+
}
|
|
205
|
+
list.push({ dx: dx, dy: dy, id: id })
|
|
206
|
+
}
|
|
207
|
+
const at = function (x, y) {
|
|
208
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return -1
|
|
209
|
+
return comp[y * width + x]
|
|
210
|
+
}
|
|
211
|
+
for (let y = 0; y < height; y += 1) {
|
|
212
|
+
for (let x = 0; x < width; x += 1) {
|
|
213
|
+
const id = comp[y * width + x]
|
|
214
|
+
const right = at(x + 1, y)
|
|
215
|
+
if (right !== id) {
|
|
216
|
+
if (id >= 0) addEdge(x + 1, y + 1, 0, -1, id)
|
|
217
|
+
if (right >= 0) addEdge(x + 1, y, 0, 1, right)
|
|
218
|
+
}
|
|
219
|
+
const down = at(x, y + 1)
|
|
220
|
+
if (down !== id) {
|
|
221
|
+
if (id >= 0) addEdge(x, y + 1, 1, 0, id)
|
|
222
|
+
if (down >= 0) addEdge(x + 1, y + 1, -1, 0, down)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 左转优先:向上→左、向左→下、向下→右、向右→上(区域始终在左手边)
|
|
228
|
+
function turnOrder(dx, dy) {
|
|
229
|
+
if (dy === -1) return [[-1, 0], [0, -1], [0, 1], [1, 0]] // 向上走:左转是左
|
|
230
|
+
if (dx === -1) return [[0, 1], [-1, 0], [0, -1], [1, 0]] // 向左走:左转是下
|
|
231
|
+
if (dy === 1) return [[1, 0], [0, 1], [-1, 0], [0, -1]] // 向下走:左转是右
|
|
232
|
+
return [[0, -1], [1, 0], [0, 1], [-1, 0]] // 向右走:左转是上
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const loopsByRegion = new Map()
|
|
236
|
+
const guardMax = 4 * stride * (height + 1)
|
|
237
|
+
outMap.forEach(function (arr, startKey) {
|
|
238
|
+
while (arr.length > 0) {
|
|
239
|
+
const first = arr.pop()
|
|
240
|
+
const points = []
|
|
241
|
+
let cx = startKey % stride
|
|
242
|
+
let cy = (startKey - cx) / stride
|
|
243
|
+
let edge = first
|
|
244
|
+
let guard = 0
|
|
245
|
+
while (edge !== null && guard < guardMax) {
|
|
246
|
+
guard += 1
|
|
247
|
+
points.push([cx, cy])
|
|
248
|
+
cx += edge.dx
|
|
249
|
+
cy += edge.dy
|
|
250
|
+
const key = cy * stride + cx
|
|
251
|
+
const list = outMap.get(key)
|
|
252
|
+
if (list === undefined || list.length === 0) {
|
|
253
|
+
edge = null
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
if (key === startKey && list.indexOf(first) >= 0) {
|
|
257
|
+
list.splice(list.indexOf(first), 1)
|
|
258
|
+
edge = null
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
let pick = -1
|
|
262
|
+
const order = turnOrder(edge.dx, edge.dy)
|
|
263
|
+
for (let t = 0; t < order.length && pick < 0; t += 1) {
|
|
264
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
265
|
+
if (list[i].id === edge.id && list[i].dx === order[t][0] && list[i].dy === order[t][1]) {
|
|
266
|
+
pick = i
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (pick < 0) {
|
|
272
|
+
edge = null
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
edge = list.splice(pick, 1)[0]
|
|
276
|
+
}
|
|
277
|
+
if (points.length < 3) continue
|
|
278
|
+
let signed = 0
|
|
279
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
280
|
+
const a = points[i]
|
|
281
|
+
const b = points[(i + 1) % points.length]
|
|
282
|
+
signed += a[0] * b[1] - b[0] * a[1]
|
|
283
|
+
}
|
|
284
|
+
const id = first.id
|
|
285
|
+
let list = loopsByRegion.get(id)
|
|
286
|
+
if (list === undefined) {
|
|
287
|
+
list = []
|
|
288
|
+
loopsByRegion.set(id, list)
|
|
289
|
+
}
|
|
290
|
+
list.push({ points: points, area: signed / 2 })
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
void regionCount
|
|
294
|
+
return loopsByRegion
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 按颜色把面积 < minArea 的 4 连通小块并进**最相似的邻色**。
|
|
299
|
+
*
|
|
300
|
+
* 这一版是把参考实现的行为重写正确之后的形态:参考那份 flood fill **只查了谓词没查颜色**,
|
|
301
|
+
* 于是"一块"其实横跨了多个颜色,碎块判据基本失效(实测它只找到 4 个碎块);
|
|
302
|
+
* 而朴素修好(按颜色分块 + 并进"出现最多的邻色")会把大量孤立小斑点画成实心色块,
|
|
303
|
+
* 保真度反而下降(meanDist 270→277)。所以这里的取舍是:
|
|
304
|
+
* · 按颜色分块 —— 判据要跟"看起来是不是同一块颜色"一致;
|
|
305
|
+
* · 并进**颜色最接近**的邻色,而不是"面积最多的"—— 边缘的过渡色应该并回它看起来最像的那一侧;
|
|
306
|
+
* · **不连锁**:一轮里对固定快照判一次,避免小块并进小块滚雪球。
|
|
307
|
+
*/
|
|
308
|
+
function despeckle(labels, width, height, palette, minArea) {
|
|
309
|
+
const colorCount = palette.length
|
|
310
|
+
const comp = new Int32Array(width * height).fill(-1)
|
|
311
|
+
const stack = []
|
|
312
|
+
let mergedPixels = 0
|
|
313
|
+
let blocksMerged = 0
|
|
314
|
+
|
|
315
|
+
// 先把每块 4 连通区域找出来(一次,不随合并改变)
|
|
316
|
+
const blocks = []
|
|
317
|
+
for (let i = 0; i < width * height; i += 1) {
|
|
318
|
+
if (comp[i] !== -1) continue
|
|
319
|
+
const label = labels[i]
|
|
320
|
+
const id = blocks.length
|
|
321
|
+
const pixels = []
|
|
322
|
+
comp[i] = id
|
|
323
|
+
stack.push(i)
|
|
324
|
+
while (stack.length > 0) {
|
|
325
|
+
const p = stack.pop()
|
|
326
|
+
pixels.push(p)
|
|
327
|
+
const x = p % width
|
|
328
|
+
const y = (p - x) / width
|
|
329
|
+
if (x > 0 && comp[p - 1] === -1 && labels[p - 1] === label) {
|
|
330
|
+
comp[p - 1] = id
|
|
331
|
+
stack.push(p - 1)
|
|
332
|
+
}
|
|
333
|
+
if (x + 1 < width && comp[p + 1] === -1 && labels[p + 1] === label) {
|
|
334
|
+
comp[p + 1] = id
|
|
335
|
+
stack.push(p + 1)
|
|
336
|
+
}
|
|
337
|
+
if (y > 0 && comp[p - width] === -1 && labels[p - width] === label) {
|
|
338
|
+
comp[p - width] = id
|
|
339
|
+
stack.push(p - width)
|
|
340
|
+
}
|
|
341
|
+
if (y + 1 < height && comp[p + width] === -1 && labels[p + width] === label) {
|
|
342
|
+
comp[p + width] = id
|
|
343
|
+
stack.push(p + width)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
blocks.push({ label: label, pixels: pixels })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const hist = new Int32Array(colorCount)
|
|
350
|
+
const plans = []
|
|
351
|
+
for (let b = 0; b < blocks.length; b += 1) {
|
|
352
|
+
const block = blocks[b]
|
|
353
|
+
if (block.pixels.length >= minArea) continue
|
|
354
|
+
// 背景(label 0)不参与:它是"没画东西的地方",并它等于把图啃出洞
|
|
355
|
+
if (palette[block.label] === undefined) continue
|
|
356
|
+
hist.fill(0)
|
|
357
|
+
for (let k = 0; k < block.pixels.length; k += 1) {
|
|
358
|
+
const p = block.pixels[k]
|
|
359
|
+
const x = p % width
|
|
360
|
+
const y = (p - x) / width
|
|
361
|
+
for (let dy = -1; dy <= 1; dy += 1) {
|
|
362
|
+
const yy = y + dy
|
|
363
|
+
if (yy < 0 || yy >= height) continue
|
|
364
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
365
|
+
const xx = x + dx
|
|
366
|
+
if (xx < 0 || xx >= width) continue
|
|
367
|
+
const l = labels[yy * width + xx]
|
|
368
|
+
if (l !== block.label) hist[l] += 1
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// 邻色里挑"颜色最接近"的那个(距离同色则取出现更多的那个)
|
|
373
|
+
let to = block.label
|
|
374
|
+
let bestScore = null
|
|
375
|
+
const src = palette[block.label]
|
|
376
|
+
for (let l = 0; l < colorCount; l += 1) {
|
|
377
|
+
if (hist[l] === 0 || l === 0 || l === block.label) continue
|
|
378
|
+
const p = palette[l]
|
|
379
|
+
const d = (src[0] - p[0]) ** 2 + (src[1] - p[1]) ** 2 + (src[2] - p[2]) ** 2
|
|
380
|
+
const score = d - Math.min(hist[l], 60) // 出现得多算一点加成,但不至于压过颜色差异
|
|
381
|
+
if (bestScore === null || score < bestScore) {
|
|
382
|
+
bestScore = score
|
|
383
|
+
to = l
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (to === block.label) continue
|
|
387
|
+
plans.push({ pixels: block.pixels, to: to })
|
|
388
|
+
}
|
|
389
|
+
for (let i = 0; i < plans.length; i += 1) {
|
|
390
|
+
const pixels = plans[i].pixels
|
|
391
|
+
for (let k = 0; k < pixels.length; k += 1) labels[pixels[k]] = plans[i].to
|
|
392
|
+
mergedPixels += pixels.length
|
|
393
|
+
blocksMerged += 1
|
|
394
|
+
}
|
|
395
|
+
return { mergedPixels: mergedPixels, blocksMerged: blocksMerged, blocks: blocks.length }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ────────────────────────── ⑤ 简化与编码 ────────────────────────── */
|
|
399
|
+
|
|
400
|
+
/** 把一条闭合折线在"一对最远点"处断开,两段各跑 Douglas-Peucker。 */
|
|
401
|
+
function simplifyClosed(points, epsilon) {
|
|
402
|
+
const n = points.length
|
|
403
|
+
if (n < 4 || epsilon <= 0) return points.slice()
|
|
404
|
+
let i0 = 0
|
|
405
|
+
for (let i = 1; i < n; i += 1) {
|
|
406
|
+
if (points[i][0] < points[i0][0] || (points[i][0] === points[i0][0] && points[i][1] < points[i0][1])) i0 = i
|
|
407
|
+
}
|
|
408
|
+
let i1 = i0
|
|
409
|
+
let best = -1
|
|
410
|
+
for (let i = 0; i < n; i += 1) {
|
|
411
|
+
const d = (points[i][0] - points[i0][0]) ** 2 + (points[i][1] - points[i0][1]) ** 2
|
|
412
|
+
if (d > best) {
|
|
413
|
+
best = d
|
|
414
|
+
i1 = i
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (i1 === i0) return points.slice()
|
|
418
|
+
const segA = []
|
|
419
|
+
const segB = []
|
|
420
|
+
for (let i = i0; ; i = (i + 1) % n) {
|
|
421
|
+
segA.push(points[i])
|
|
422
|
+
if (i === i1) break
|
|
423
|
+
}
|
|
424
|
+
for (let i = i1; ; i = (i + 1) % n) {
|
|
425
|
+
segB.push(points[i])
|
|
426
|
+
if (i === i0) break
|
|
427
|
+
}
|
|
428
|
+
return simplifyOpen(segA, epsilon).slice(0, -1).concat(simplifyOpen(segB, epsilon).slice(0, -1))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function simplifyOpen(points, epsilon) {
|
|
432
|
+
if (points.length < 3) return points.slice()
|
|
433
|
+
const keep = new Uint8Array(points.length)
|
|
434
|
+
keep[0] = 1
|
|
435
|
+
keep[points.length - 1] = 1
|
|
436
|
+
const stack = [[0, points.length - 1]]
|
|
437
|
+
const eps2 = epsilon * epsilon
|
|
438
|
+
while (stack.length > 0) {
|
|
439
|
+
const range = stack.pop()
|
|
440
|
+
const i0 = range[0]
|
|
441
|
+
const i1 = range[1]
|
|
442
|
+
if (i1 <= i0 + 1) continue
|
|
443
|
+
const x0 = points[i0][0]
|
|
444
|
+
const y0 = points[i0][1]
|
|
445
|
+
const x1 = points[i1][0]
|
|
446
|
+
const y1 = points[i1][1]
|
|
447
|
+
const dx = x1 - x0
|
|
448
|
+
const dy = y1 - y0
|
|
449
|
+
const len2 = dx * dx + dy * dy
|
|
450
|
+
let far = -1
|
|
451
|
+
let farD = -1
|
|
452
|
+
for (let i = i0 + 1; i < i1; i += 1) {
|
|
453
|
+
const px = points[i][0]
|
|
454
|
+
const py = points[i][1]
|
|
455
|
+
let d
|
|
456
|
+
if (len2 === 0) {
|
|
457
|
+
d = (px - x0) ** 2 + (py - y0) ** 2
|
|
458
|
+
} else {
|
|
459
|
+
let t = ((px - x0) * dx + (py - y0) * dy) / len2
|
|
460
|
+
t = clamp(t, 0, 1)
|
|
461
|
+
d = (px - (x0 + t * dx)) ** 2 + (py - (y0 + t * dy)) ** 2
|
|
462
|
+
}
|
|
463
|
+
if (d > farD) {
|
|
464
|
+
farD = d
|
|
465
|
+
far = i
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (farD > eps2) {
|
|
469
|
+
keep[far] = 1
|
|
470
|
+
stack.push([i0, far], [far, i1])
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const out = []
|
|
474
|
+
for (let i = 0; i < points.length; i += 1) if (keep[i] === 1) out.push(points[i])
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 一条闭合环 → path 的 `d`。
|
|
480
|
+
*
|
|
481
|
+
* 起点用绝对 `M`,其余用小写相对 `l dx dy`(命令字母能省就省),**每 8 段用绝对 `L`
|
|
482
|
+
* 重新锚定** —— 相对量取整后会累积误差,锚定把它按段清零。
|
|
483
|
+
* 取整必须**先算再判零**:先判零后取整会写出 `l12`(本是 `l12 0`)或裸 `0` 这种非法 token,
|
|
484
|
+
* 解析器会静默截断整条路径(这个坑真实踩过:图形只剩几个碎片)。
|
|
485
|
+
*/
|
|
486
|
+
function encodeLoop(points, precision) {
|
|
487
|
+
const factor = Math.pow(10, precision)
|
|
488
|
+
const round = function (v) {
|
|
489
|
+
const r = Math.round(v * factor) / factor
|
|
490
|
+
return Object.is(r, -0) ? 0 : r
|
|
491
|
+
}
|
|
492
|
+
const text = function (v) {
|
|
493
|
+
const r = round(v)
|
|
494
|
+
return String(r)
|
|
495
|
+
}
|
|
496
|
+
if (points.length < 3) return null
|
|
497
|
+
let d = 'M' + text(points[0][0]) + ' ' + text(points[0][1])
|
|
498
|
+
let px = points[0][0]
|
|
499
|
+
let py = points[0][1]
|
|
500
|
+
let emitted = 0
|
|
501
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
502
|
+
const x = points[i][0]
|
|
503
|
+
const y = points[i][1]
|
|
504
|
+
if (i % 8 === 0) {
|
|
505
|
+
d += 'L' + text(x) + ' ' + text(y)
|
|
506
|
+
} else {
|
|
507
|
+
const rx = round(x - px)
|
|
508
|
+
const ry = round(y - py)
|
|
509
|
+
if (rx === 0 && ry === 0) {
|
|
510
|
+
px = x
|
|
511
|
+
py = y
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
if (rx === 0) d += 'l0 ' + text(ry)
|
|
515
|
+
else if (ry === 0) d += 'l' + text(rx) + ' 0'
|
|
516
|
+
else d += 'l' + text(rx) + ' ' + text(ry)
|
|
517
|
+
emitted += 1
|
|
518
|
+
}
|
|
519
|
+
px = x
|
|
520
|
+
py = y
|
|
521
|
+
}
|
|
522
|
+
if (emitted < 2) return null
|
|
523
|
+
return d + 'z'
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/* ────────────────────────── 门面 ────────────────────────── */
|
|
527
|
+
|
|
528
|
+
/** 矢量化的默认参数(宿主工具与命令行共用)。 */
|
|
529
|
+
export const TRACE_DEFAULTS = {
|
|
530
|
+
colors: 12, // 前景调色板色数(背景另占 1 色)
|
|
531
|
+
grid: 499, // 按这个宽度做;高度按原图比例算,够大就原尺寸
|
|
532
|
+
scale: 1, // 先按 scale×scale 面积平均降采样,再做管线(=1 表示不降)
|
|
533
|
+
epsilon: 2.0, // RDP 简化容差(像素)
|
|
534
|
+
precision: 1, // 坐标小数位
|
|
535
|
+
minArea: 10, // 小于这个面积(像素²)的碎块并进邻色
|
|
536
|
+
bgBright: 238, // 判"近白"的亮度下限
|
|
537
|
+
bgTol: 10, // 判"近灰"的通道差上限
|
|
538
|
+
seeds: 3, // k-means 重启次数(取 inertia 最小)
|
|
539
|
+
maxBytes: 50000, // SVG 上限(drawio style 里的 data URI 要塞得下;60000 是硬上限,留余量)
|
|
540
|
+
includeLabels: false, // 额外返回"去碎块后"的标签图(调试/比对用;会额外占 width*height 字节的 base64)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 位图 → SVG 标记。
|
|
545
|
+
*
|
|
546
|
+
* @param {{width:number,height:number,data:Buffer|Uint8Array}} raster RGBA8
|
|
547
|
+
* @param {object} [rawOptions] 见 TRACE_DEFAULTS
|
|
548
|
+
* @returns {{svg:string, metrics:object}}
|
|
549
|
+
*/
|
|
550
|
+
export function traceRasterToSvg(raster, rawOptions) {
|
|
551
|
+
const options = Object.assign({}, TRACE_DEFAULTS, rawOptions === undefined || rawOptions === null ? {} : rawOptions)
|
|
552
|
+
const colors = Math.max(2, Math.min(24, Math.round(options.colors)))
|
|
553
|
+
const grid = Math.max(16, Math.round(options.grid))
|
|
554
|
+
const scale = Math.max(1, Math.round(options.scale))
|
|
555
|
+
const minArea = Math.max(1, Math.round(options.minArea))
|
|
556
|
+
const seeds = Math.max(1, Math.round(options.seeds))
|
|
557
|
+
|
|
558
|
+
const srcWidth = raster.width
|
|
559
|
+
const srcHeight = raster.height
|
|
560
|
+
const rgba = raster.data
|
|
561
|
+
|
|
562
|
+
// 目标尺寸:宽度向 grid 靠(够大就原尺寸),高度按比例(至少 16px)
|
|
563
|
+
let width = srcWidth
|
|
564
|
+
let height = srcHeight
|
|
565
|
+
if (grid < srcWidth) {
|
|
566
|
+
width = grid
|
|
567
|
+
height = Math.max(16, Math.round((srcHeight * grid) / srcWidth))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 降采样(面积平均)+ 缩放:一次写完,后面只认 width×height 的工作图
|
|
571
|
+
const work = new Uint8Array(width * height * 4)
|
|
572
|
+
for (let y = 0; y < height; y += 1) {
|
|
573
|
+
const y0 = Math.floor((y * srcHeight) / height)
|
|
574
|
+
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / height))
|
|
575
|
+
for (let x = 0; x < width; x += 1) {
|
|
576
|
+
const x0 = Math.floor((x * srcWidth) / width)
|
|
577
|
+
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / width))
|
|
578
|
+
let r = 0
|
|
579
|
+
let g = 0
|
|
580
|
+
let b = 0
|
|
581
|
+
let a = 0
|
|
582
|
+
let n = 0
|
|
583
|
+
for (let yy = y0; yy < y1; yy += 1) {
|
|
584
|
+
for (let xx = x0; xx < x1; xx += 1) {
|
|
585
|
+
const o = (yy * srcWidth + xx) * 4
|
|
586
|
+
r += rgba[o]
|
|
587
|
+
g += rgba[o + 1]
|
|
588
|
+
b += rgba[o + 2]
|
|
589
|
+
a += rgba[o + 3]
|
|
590
|
+
n += 1
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const o = (y * width + x) * 4
|
|
594
|
+
work[o] = Math.round(r / n)
|
|
595
|
+
work[o + 1] = Math.round(g / n)
|
|
596
|
+
work[o + 2] = Math.round(b / n)
|
|
597
|
+
work[o + 3] = Math.round(a / n)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ② 背景 + 调色板
|
|
602
|
+
const isBg = markBackground(work, width, height, options)
|
|
603
|
+
const bgMean = [255, 255, 255]
|
|
604
|
+
{
|
|
605
|
+
let n = 0
|
|
606
|
+
const sum = [0, 0, 0]
|
|
607
|
+
for (let i = 0; i < width * height; i += 1) {
|
|
608
|
+
if (isBg[i] === 0) continue
|
|
609
|
+
sum[0] += work[i * 4]
|
|
610
|
+
sum[1] += work[i * 4 + 1]
|
|
611
|
+
sum[2] += work[i * 4 + 2]
|
|
612
|
+
n += 1
|
|
613
|
+
}
|
|
614
|
+
if (n > 0) {
|
|
615
|
+
bgMean[0] = Math.round(sum[0] / n)
|
|
616
|
+
bgMean[1] = Math.round(sum[1] / n)
|
|
617
|
+
bgMean[2] = Math.round(sum[2] / n)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const fgIndex = []
|
|
622
|
+
for (let i = 0; i < width * height; i += 1) if (isBg[i] === 0) fgIndex.push(i)
|
|
623
|
+
if (fgIndex.length === 0) throw new Error('这张图整幅都是背景色(近白/近灰),没有可画的像素')
|
|
624
|
+
|
|
625
|
+
let best = null
|
|
626
|
+
{
|
|
627
|
+
const samples = new Float64Array(fgIndex.length * 3)
|
|
628
|
+
for (let i = 0; i < fgIndex.length; i += 1) {
|
|
629
|
+
const o = fgIndex[i] * 4
|
|
630
|
+
samples[i * 3] = work[o]
|
|
631
|
+
samples[i * 3 + 1] = work[o + 1]
|
|
632
|
+
samples[i * 3 + 2] = work[o + 2]
|
|
633
|
+
}
|
|
634
|
+
for (let s = 1; s <= seeds; s += 1) {
|
|
635
|
+
const got = kmeans(samples, colors, 24, s * 2654435761)
|
|
636
|
+
if (best === null || got.inertia < best.inertia) best = got
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const palette = [bgMean]
|
|
640
|
+
for (let c = 0; c < best.cent.length; c += 1) {
|
|
641
|
+
palette.push([clamp(Math.round(best.cent[c][0]), 0, 255), clamp(Math.round(best.cent[c][1]), 0, 255), clamp(Math.round(best.cent[c][2]), 0, 255)])
|
|
642
|
+
}
|
|
643
|
+
const colorCount = palette.length
|
|
644
|
+
|
|
645
|
+
// ③ 超采样归类(双线性)→ 众数滤波 → 降回原分辨率
|
|
646
|
+
const ss = 3
|
|
647
|
+
const sw = width * ss
|
|
648
|
+
const sh = height * ss
|
|
649
|
+
const gridLabels = new Uint8Array(sw * sh)
|
|
650
|
+
const px = [0, 0, 0]
|
|
651
|
+
for (let y = 0; y < sh; y += 1) {
|
|
652
|
+
const fy = (y + 0.5) / ss - 0.5
|
|
653
|
+
for (let x = 0; x < sw; x += 1) {
|
|
654
|
+
const fx = (x + 0.5) / ss - 0.5
|
|
655
|
+
// 双线性取样(夹在边界内)
|
|
656
|
+
const sx = clamp(fx, 0, width - 1)
|
|
657
|
+
const sy = clamp(fy, 0, height - 1)
|
|
658
|
+
const x0 = Math.floor(sx)
|
|
659
|
+
const y0 = Math.floor(sy)
|
|
660
|
+
const x1 = Math.min(width - 1, x0 + 1)
|
|
661
|
+
const y1 = Math.min(height - 1, y0 + 1)
|
|
662
|
+
const tx = sx - x0
|
|
663
|
+
const ty = sy - y0
|
|
664
|
+
for (let c = 0; c < 3; c += 1) {
|
|
665
|
+
const v00 = work[(y0 * width + x0) * 4 + c]
|
|
666
|
+
const v10 = work[(y0 * width + x1) * 4 + c]
|
|
667
|
+
const v01 = work[(y1 * width + x0) * 4 + c]
|
|
668
|
+
const v11 = work[(y1 * width + x1) * 4 + c]
|
|
669
|
+
px[c] = (v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty
|
|
670
|
+
}
|
|
671
|
+
let bi = 0
|
|
672
|
+
let bd = Infinity
|
|
673
|
+
for (let c = 0; c < colorCount; c += 1) {
|
|
674
|
+
const p = palette[c]
|
|
675
|
+
const dr = px[0] - p[0]
|
|
676
|
+
const dg = px[1] - p[1]
|
|
677
|
+
const db = px[2] - p[2]
|
|
678
|
+
const d = dr * dr + dg * dg + db * db
|
|
679
|
+
if (d < bd) {
|
|
680
|
+
bd = d
|
|
681
|
+
bi = c
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
gridLabels[y * sw + x] = bi
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const scratch = new Uint8Array(gridLabels.length)
|
|
689
|
+
const hist = new Int32Array(colorCount + 1)
|
|
690
|
+
for (let y = 0; y < sh; y += 1) {
|
|
691
|
+
for (let x = 0; x < sw; x += 1) {
|
|
692
|
+
hist.fill(0)
|
|
693
|
+
for (let dy = -1; dy <= 1; dy += 1) {
|
|
694
|
+
const yy = y + dy
|
|
695
|
+
if (yy < 0 || yy >= sh) continue
|
|
696
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
697
|
+
const xx = x + dx
|
|
698
|
+
if (xx < 0 || xx >= sw) continue
|
|
699
|
+
hist[gridLabels[yy * sw + xx]] += 1
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
let bi = gridLabels[y * sw + x]
|
|
703
|
+
let bc = 0
|
|
704
|
+
for (let c = 0; c < colorCount; c += 1) {
|
|
705
|
+
if (hist[c] > bc) {
|
|
706
|
+
bc = hist[c]
|
|
707
|
+
bi = c
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
scratch[y * sw + x] = bi
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
gridLabels.set(scratch)
|
|
714
|
+
|
|
715
|
+
const labels = new Uint8Array(width * height)
|
|
716
|
+
for (let y = 0; y < height; y += 1) {
|
|
717
|
+
for (let x = 0; x < width; x += 1) {
|
|
718
|
+
hist.fill(0)
|
|
719
|
+
for (let dy = 0; dy < ss; dy += 1) {
|
|
720
|
+
const yy = y * ss + dy
|
|
721
|
+
for (let dx = 0; dx < ss; dx += 1) hist[gridLabels[yy * sw + x * ss + dx]] += 1
|
|
722
|
+
}
|
|
723
|
+
let bi = 0
|
|
724
|
+
let bc = -1
|
|
725
|
+
for (let c = 0; c < colorCount; c += 1) {
|
|
726
|
+
if (hist[c] > bc) {
|
|
727
|
+
bc = hist[c]
|
|
728
|
+
bi = c
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
labels[y * width + x] = bi
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const speckle = despeckle(labels, width, height, palette, minArea)
|
|
736
|
+
const labelDump = options.includeLabels === true
|
|
737
|
+
? {
|
|
738
|
+
width: width, height: height, colors: colorCount,
|
|
739
|
+
palette: palette.map(function (c) { return c.join(',') }),
|
|
740
|
+
labels: Buffer.from(labels).toString('base64'),
|
|
741
|
+
}
|
|
742
|
+
: null
|
|
743
|
+
|
|
744
|
+
// ④ 连通域 + 轮廓
|
|
745
|
+
const comp = new Int32Array(width * height).fill(-1)
|
|
746
|
+
const regionLabel = []
|
|
747
|
+
const regionArea = []
|
|
748
|
+
const stack = []
|
|
749
|
+
for (let i = 0; i < width * height; i += 1) {
|
|
750
|
+
if (comp[i] !== -1) continue
|
|
751
|
+
const label = labels[i]
|
|
752
|
+
const id = regionLabel.length
|
|
753
|
+
let area = 0
|
|
754
|
+
comp[i] = id
|
|
755
|
+
stack.push(i)
|
|
756
|
+
while (stack.length > 0) {
|
|
757
|
+
const p = stack.pop()
|
|
758
|
+
area += 1
|
|
759
|
+
const x = p % width
|
|
760
|
+
const y = (p - x) / width
|
|
761
|
+
for (let dy = -1; dy <= 1; dy += 1) {
|
|
762
|
+
const yy = y + dy
|
|
763
|
+
if (yy < 0 || yy >= height) continue
|
|
764
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
765
|
+
const xx = x + dx
|
|
766
|
+
if (xx < 0 || xx >= width) continue
|
|
767
|
+
if (dx !== 0 && dy !== 0) continue // 4 连通(与格边跟随一致)
|
|
768
|
+
const q = yy * width + xx
|
|
769
|
+
if (comp[q] !== -1 || labels[q] !== label) continue
|
|
770
|
+
comp[q] = id
|
|
771
|
+
stack.push(q)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
regionLabel.push(label)
|
|
776
|
+
regionArea.push(area)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const loopsByRegion = traceLoops(comp, width, height, regionLabel.length)
|
|
780
|
+
|
|
781
|
+
// ⑤ 每色一条 path:外环(面积<0,顺时针)+ 洞(>0,反向)
|
|
782
|
+
const byColor = []
|
|
783
|
+
for (let c = 0; c < colorCount; c += 1) byColor.push({ outer: [], hole: [], area: 0 })
|
|
784
|
+
loopsByRegion.forEach(function (loops, id) {
|
|
785
|
+
const label = regionLabel[id]
|
|
786
|
+
const bucket = byColor[label]
|
|
787
|
+
for (let i = 0; i < loops.length; i += 1) {
|
|
788
|
+
if (loops[i].area < 0) bucket.outer.push(loops[i])
|
|
789
|
+
else bucket.hole.push(loops[i])
|
|
790
|
+
}
|
|
791
|
+
bucket.area += regionArea[id]
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
const hex = function (color) {
|
|
795
|
+
let out = '#'
|
|
796
|
+
for (let c = 0; c < 3; c += 1) out += color[c].toString(16).padStart(2, '0')
|
|
797
|
+
return out
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const order = []
|
|
801
|
+
for (let c = 0; c < colorCount; c += 1) order.push(c)
|
|
802
|
+
order.sort(function (a, b) {
|
|
803
|
+
return byColor[b].area - byColor[a].area
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
let attempts = 0
|
|
807
|
+
let pathCount = 0
|
|
808
|
+
let loopCount = 0
|
|
809
|
+
let svg = ''
|
|
810
|
+
// 超预算就逐步收紧:先抬 RDP 容差,再丢小洞
|
|
811
|
+
const ladder = [
|
|
812
|
+
{ epsilon: options.epsilon, minLoopArea: 4 },
|
|
813
|
+
{ epsilon: Math.max(options.epsilon, 2.5), minLoopArea: 4 },
|
|
814
|
+
{ epsilon: Math.max(options.epsilon, 3), minLoopArea: 9 },
|
|
815
|
+
{ epsilon: Math.max(options.epsilon, 3.5), minLoopArea: 25 },
|
|
816
|
+
{ epsilon: Math.max(options.epsilon, 4), minLoopArea: 64 },
|
|
817
|
+
]
|
|
818
|
+
for (let step = 0; step < ladder.length; step += 1) {
|
|
819
|
+
attempts = step + 1
|
|
820
|
+
const plan = ladder[step]
|
|
821
|
+
let body = ''
|
|
822
|
+
pathCount = 0
|
|
823
|
+
loopCount = 0
|
|
824
|
+
for (let k = 0; k < order.length; k += 1) {
|
|
825
|
+
const bucket = byColor[order[k]]
|
|
826
|
+
let d = ''
|
|
827
|
+
const all = bucket.outer.concat(bucket.hole)
|
|
828
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
829
|
+
const loop = all[i]
|
|
830
|
+
if (Math.abs(loop.area) < plan.minLoopArea) continue
|
|
831
|
+
// 洞反向 → nonzero 里挖空
|
|
832
|
+
const points = loop.area > 0 ? loop.points.slice().reverse() : loop.points
|
|
833
|
+
const seg = encodeLoop(simplifyClosed(points, plan.epsilon), options.precision)
|
|
834
|
+
if (seg === null) continue
|
|
835
|
+
d += seg
|
|
836
|
+
loopCount += 1
|
|
837
|
+
}
|
|
838
|
+
if (d === '') continue
|
|
839
|
+
body += "<path fill='" + hex(palette[order[k]]) + "' fill-rule='nonzero' d='" + d + "'/>"
|
|
840
|
+
pathCount += 1
|
|
841
|
+
}
|
|
842
|
+
svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 " + width + " " + height + "'>" + body + '</svg>'
|
|
843
|
+
if (svg.length <= options.maxBytes) break
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// 精确性度量:各色层覆盖的像素数之和 vs 图像总像素(格边轮廓下两者应当完全一致)
|
|
847
|
+
let covered = 0
|
|
848
|
+
for (let c = 0; c < colorCount; c += 1) covered += byColor[c].area
|
|
849
|
+
const loopsTotal = { value: 0 }
|
|
850
|
+
loopsByRegion.forEach(function (loops) {
|
|
851
|
+
loopsTotal.value += loops.length
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
let paletteArea = 0
|
|
855
|
+
for (let c = 0; c < colorCount; c += 1) paletteArea += byColor[c].area
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
svg: svg,
|
|
859
|
+
labels: labelDump,
|
|
860
|
+
metrics: {
|
|
861
|
+
source: { width: srcWidth, height: srcHeight },
|
|
862
|
+
work: { width: width, height: height, scale: scale },
|
|
863
|
+
colors: colorCount - 1,
|
|
864
|
+
palette: palette.map(hex),
|
|
865
|
+
backgroundPixels: (function () {
|
|
866
|
+
let n = 0
|
|
867
|
+
for (let i = 0; i < width * height; i += 1) if (isBg[i] === 1) n += 1
|
|
868
|
+
return n
|
|
869
|
+
})(),
|
|
870
|
+
foregroundPixels: fgIndex.length,
|
|
871
|
+
despeckle: speckle,
|
|
872
|
+
regions: regionLabel.length,
|
|
873
|
+
loops: loopsTotal.value,
|
|
874
|
+
paths: pathCount,
|
|
875
|
+
loopsKept: loopCount,
|
|
876
|
+
drawnPixels: covered,
|
|
877
|
+
totalPixels: width * height,
|
|
878
|
+
exactPixelCoverage: covered === width * height,
|
|
879
|
+
svgChars: svg.length,
|
|
880
|
+
budget: options.maxBytes,
|
|
881
|
+
attempts: attempts,
|
|
882
|
+
paletteAreaSum: paletteArea,
|
|
883
|
+
},
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* 把 SVG 标记编码进 drawio 的 `image=` 值(`data:image/svg+xml,<百分号编码>`)。
|
|
889
|
+
*
|
|
890
|
+
* 为什么用百分号编码而不是 base64:编码后的串里一个 `;` 都没有,而 drawio 与本内核的
|
|
891
|
+
* style 解析都是按 `;` 分段 —— base64 形态的 `data:image/svg+xml;base64,` 那个分号是解析器的雷。
|
|
892
|
+
* 这里编得比 JSON 严格一点(`<` `>` `#` `%` `"` 和空格),换来的好处是:不管宿主把它塞进
|
|
893
|
+
* 属性还是直接写进 style,都不会有歧义。
|
|
894
|
+
*/
|
|
895
|
+
export function svgToImageValue(svg) {
|
|
896
|
+
const encoded = String(svg)
|
|
897
|
+
.replace(/%/g, '%25')
|
|
898
|
+
.replace(/</g, '%3C')
|
|
899
|
+
.replace(/>/g, '%3E')
|
|
900
|
+
.replace(/#/g, '%23')
|
|
901
|
+
.replace(/"/g, '%22')
|
|
902
|
+
.replace(/ /g, '%20')
|
|
903
|
+
return 'data:image/svg+xml,' + encoded
|
|
904
|
+
}
|