@sakitam-gis/sprite-cli 1.0.0 → 1.0.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/dist/cli.js +88 -58
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +303 -0
- package/dist/index.js +901 -0
- package/dist/index.js.map +1 -0
- package/package.json +12 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
// src/sprite.ts
|
|
2
|
+
import fs2 from "fs/promises";
|
|
3
|
+
import path2 from "path";
|
|
4
|
+
import stringify from "json-stable-stringify";
|
|
5
|
+
|
|
6
|
+
// src/core/layout.ts
|
|
7
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
8
|
+
|
|
9
|
+
// src/core/shelf-pack.ts
|
|
10
|
+
var Bin = class {
|
|
11
|
+
/**
|
|
12
|
+
* @param id Unique id of the bin
|
|
13
|
+
* @param x Left coordinate of the bin
|
|
14
|
+
* @param y Top coordinate of the bin
|
|
15
|
+
* @param w Width of the bin
|
|
16
|
+
* @param h Height of the bin
|
|
17
|
+
* @param maxw Max width of the bin (defaults to `w` if not provided)
|
|
18
|
+
* @param maxh Max height of the bin (defaults to `h` if not provided)
|
|
19
|
+
*/
|
|
20
|
+
constructor(id, x, y, w, h, maxw, maxh) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.x = x;
|
|
23
|
+
this.y = y;
|
|
24
|
+
this.w = w;
|
|
25
|
+
this.h = h;
|
|
26
|
+
this.maxw = maxw || w;
|
|
27
|
+
this.maxh = maxh || h;
|
|
28
|
+
this.refcount = 0;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var Shelf = class {
|
|
32
|
+
/**
|
|
33
|
+
* @param y Top coordinate of the new shelf
|
|
34
|
+
* @param w Width of the new shelf
|
|
35
|
+
* @param h Height of the new shelf
|
|
36
|
+
*/
|
|
37
|
+
constructor(y, w, h) {
|
|
38
|
+
this.x = 0;
|
|
39
|
+
this.y = y;
|
|
40
|
+
this.w = this.free = w;
|
|
41
|
+
this.h = h;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 在货架上分配一个 bin
|
|
45
|
+
*
|
|
46
|
+
* @param w Width of the bin to allocate
|
|
47
|
+
* @param h Height of the bin to allocate
|
|
48
|
+
* @param id Unique id of the bin to allocate
|
|
49
|
+
* @returns Bin object or null if allocation failed
|
|
50
|
+
*/
|
|
51
|
+
alloc(w, h, id) {
|
|
52
|
+
if (w > this.free || h > this.h) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const x = this.x;
|
|
56
|
+
this.x += w;
|
|
57
|
+
this.free -= w;
|
|
58
|
+
return new Bin(id, x, this.y, w, h, w, this.h);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 调整货架宽度
|
|
62
|
+
*
|
|
63
|
+
* @param w Requested new width of the shelf
|
|
64
|
+
* @returns true
|
|
65
|
+
*/
|
|
66
|
+
resize(w) {
|
|
67
|
+
this.free += w - this.w;
|
|
68
|
+
this.w = w;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var ShelfPack = class {
|
|
73
|
+
/**
|
|
74
|
+
* @param w Initial width of the sprite
|
|
75
|
+
* @param h Initial height of the sprite
|
|
76
|
+
* @param options Configuration options
|
|
77
|
+
*/
|
|
78
|
+
constructor(w = 64, h = 64, options = {}) {
|
|
79
|
+
this.w = w;
|
|
80
|
+
this.h = h;
|
|
81
|
+
this.autoResize = !!options.autoResize;
|
|
82
|
+
this.shelves = [];
|
|
83
|
+
this.freebins = [];
|
|
84
|
+
this.stats = {};
|
|
85
|
+
this.bins = {};
|
|
86
|
+
this.maxId = 0;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 批量打包多个 bins
|
|
90
|
+
*
|
|
91
|
+
* @param bins Array of requested bins
|
|
92
|
+
* @param options Pack options
|
|
93
|
+
* @returns Array of allocated Bins
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const bins = [
|
|
98
|
+
* { id: 1, w: 12, h: 12 },
|
|
99
|
+
* { id: 2, w: 12, h: 16 },
|
|
100
|
+
* ];
|
|
101
|
+
* const results = sprite.pack(bins, { inPlace: false });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
pack(bins, options = {}) {
|
|
105
|
+
const binArray = [].concat(bins);
|
|
106
|
+
const results = [];
|
|
107
|
+
for (let i = 0; i < binArray.length; i++) {
|
|
108
|
+
const w = binArray[i].w || binArray[i].width;
|
|
109
|
+
const h = binArray[i].h || binArray[i].height;
|
|
110
|
+
const id = binArray[i].id;
|
|
111
|
+
if (w && h) {
|
|
112
|
+
const allocation = this.packOne(w, h, id);
|
|
113
|
+
if (!allocation) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (options.inPlace) {
|
|
117
|
+
binArray[i].x = allocation.x;
|
|
118
|
+
binArray[i].y = allocation.y;
|
|
119
|
+
binArray[i].id = allocation.id;
|
|
120
|
+
}
|
|
121
|
+
results.push(allocation);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.shrink();
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 打包单个 bin
|
|
129
|
+
*
|
|
130
|
+
* 每个 bin 都有唯一标识符。如果未提供 id,将自动创建一个。
|
|
131
|
+
* Bins 自动进行引用计数。
|
|
132
|
+
*
|
|
133
|
+
* @param w Width of the bin to allocate
|
|
134
|
+
* @param h Height of the bin to allocate
|
|
135
|
+
* @param id Unique identifier for this bin
|
|
136
|
+
* @returns Bin object or null if allocation failed
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* const bin = sprite.packOne(12, 16, 'a');
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
packOne(w, h, id) {
|
|
144
|
+
const best = { freebin: -1, shelf: -1, waste: Infinity };
|
|
145
|
+
let y = 0;
|
|
146
|
+
let bin;
|
|
147
|
+
let shelf;
|
|
148
|
+
let waste;
|
|
149
|
+
let i;
|
|
150
|
+
if (typeof id === "string" || typeof id === "number") {
|
|
151
|
+
bin = this.getBin(id);
|
|
152
|
+
if (bin) {
|
|
153
|
+
this.ref(bin);
|
|
154
|
+
return bin;
|
|
155
|
+
}
|
|
156
|
+
if (typeof id === "number") {
|
|
157
|
+
this.maxId = Math.max(id, this.maxId);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
id = ++this.maxId;
|
|
161
|
+
}
|
|
162
|
+
for (i = 0; i < this.freebins.length; i++) {
|
|
163
|
+
bin = this.freebins[i];
|
|
164
|
+
if (h === bin.maxh && w === bin.maxw) {
|
|
165
|
+
return this.allocFreebin(i, w, h, id);
|
|
166
|
+
}
|
|
167
|
+
if (h > bin.maxh || w > bin.maxw) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (h <= bin.maxh && w <= bin.maxw) {
|
|
171
|
+
waste = bin.maxw * bin.maxh - w * h;
|
|
172
|
+
if (waste < best.waste) {
|
|
173
|
+
best.waste = waste;
|
|
174
|
+
best.freebin = i;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (i = 0; i < this.shelves.length; i++) {
|
|
179
|
+
shelf = this.shelves[i];
|
|
180
|
+
y += shelf.h;
|
|
181
|
+
if (w > shelf.free) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (h === shelf.h) {
|
|
185
|
+
return this.allocShelf(i, w, h, id);
|
|
186
|
+
}
|
|
187
|
+
if (h > shelf.h) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (h < shelf.h) {
|
|
191
|
+
waste = (shelf.h - h) * w;
|
|
192
|
+
if (waste < best.waste) {
|
|
193
|
+
best.freebin = -1;
|
|
194
|
+
best.waste = waste;
|
|
195
|
+
best.shelf = i;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (best.freebin !== -1) {
|
|
200
|
+
return this.allocFreebin(best.freebin, w, h, id);
|
|
201
|
+
}
|
|
202
|
+
if (best.shelf !== -1) {
|
|
203
|
+
return this.allocShelf(best.shelf, w, h, id);
|
|
204
|
+
}
|
|
205
|
+
if (h <= this.h - y && w <= this.w) {
|
|
206
|
+
shelf = new Shelf(y, this.w, h);
|
|
207
|
+
return this.allocShelf(this.shelves.push(shelf) - 1, w, h, id);
|
|
208
|
+
}
|
|
209
|
+
if (this.autoResize) {
|
|
210
|
+
const h1 = this.h;
|
|
211
|
+
let h2 = this.h;
|
|
212
|
+
const w1 = this.w;
|
|
213
|
+
let w2 = this.w;
|
|
214
|
+
if (w1 <= h1 || w > w1) {
|
|
215
|
+
w2 = Math.max(w, w1) * 2;
|
|
216
|
+
}
|
|
217
|
+
if (h1 < w1 || h > h1) {
|
|
218
|
+
h2 = Math.max(h, h1) * 2;
|
|
219
|
+
}
|
|
220
|
+
this.resize(w2, h2);
|
|
221
|
+
return this.packOne(w, h, id);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 通过重用现有 freebin 分配一个 bin
|
|
227
|
+
*
|
|
228
|
+
* @param index Index into the `this.freebins` array
|
|
229
|
+
* @param w Width of the bin to allocate
|
|
230
|
+
* @param h Height of the bin to allocate
|
|
231
|
+
* @param id Unique identifier for this bin
|
|
232
|
+
* @returns Bin object
|
|
233
|
+
*/
|
|
234
|
+
allocFreebin(index, w, h, id) {
|
|
235
|
+
const bin = this.freebins.splice(index, 1)[0];
|
|
236
|
+
bin.id = id;
|
|
237
|
+
bin.w = w;
|
|
238
|
+
bin.h = h;
|
|
239
|
+
bin.refcount = 0;
|
|
240
|
+
this.bins[id] = bin;
|
|
241
|
+
this.ref(bin);
|
|
242
|
+
return bin;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* 在现有货架上分配 bin
|
|
246
|
+
*
|
|
247
|
+
* @param index Index into the `this.shelves` array
|
|
248
|
+
* @param w Width of the bin to allocate
|
|
249
|
+
* @param h Height of the bin to allocate
|
|
250
|
+
* @param id Unique identifier for this bin
|
|
251
|
+
* @returns Bin object
|
|
252
|
+
*/
|
|
253
|
+
allocShelf(index, w, h, id) {
|
|
254
|
+
const shelf = this.shelves[index];
|
|
255
|
+
const bin = shelf.alloc(w, h, id);
|
|
256
|
+
if (bin) {
|
|
257
|
+
this.bins[id] = bin;
|
|
258
|
+
this.ref(bin);
|
|
259
|
+
}
|
|
260
|
+
return bin;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 将 sprite 的宽度/高度缩小到最小
|
|
264
|
+
*
|
|
265
|
+
* 由于 shelf-pack 在空间不足时会先加倍宽度再加倍高度,
|
|
266
|
+
* 如果这发生在打包结束时,可能会导致较大的未使用空间
|
|
267
|
+
*/
|
|
268
|
+
shrink() {
|
|
269
|
+
if (this.shelves.length > 0) {
|
|
270
|
+
let w2 = 0;
|
|
271
|
+
let h2 = 0;
|
|
272
|
+
for (let j = 0; j < this.shelves.length; j++) {
|
|
273
|
+
const shelf = this.shelves[j];
|
|
274
|
+
h2 += shelf.h;
|
|
275
|
+
w2 = Math.max(shelf.w - shelf.free, w2);
|
|
276
|
+
}
|
|
277
|
+
this.resize(w2, h2);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 根据 id 返回已打包的 bin
|
|
282
|
+
*
|
|
283
|
+
* @param id Unique identifier for this bin
|
|
284
|
+
* @returns The requested bin, or undefined if not found
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* ```ts
|
|
288
|
+
* const bin = sprite.getBin('a');
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
getBin(id) {
|
|
292
|
+
return this.bins[id];
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 增加 bin 的引用计数并更新统计信息
|
|
296
|
+
*
|
|
297
|
+
* @param bin Bin instance
|
|
298
|
+
* @returns New refcount of the bin
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* ```ts
|
|
302
|
+
* const bin = sprite.getBin('a');
|
|
303
|
+
* if (bin) sprite.ref(bin);
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
ref(bin) {
|
|
307
|
+
if (++bin.refcount === 1) {
|
|
308
|
+
const h = bin.h;
|
|
309
|
+
this.stats[h] = (this.stats[h] || 0) + 1;
|
|
310
|
+
}
|
|
311
|
+
return bin.refcount;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 减少 bin 的引用计数并更新统计信息
|
|
315
|
+
* 当引用计数达到 0 时,bin 将自动标记为空闲空间
|
|
316
|
+
*
|
|
317
|
+
* @param bin Bin instance
|
|
318
|
+
* @returns New refcount of the bin
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* ```ts
|
|
322
|
+
* const bin = sprite.getBin('a');
|
|
323
|
+
* if (bin) sprite.unref(bin);
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
unref(bin) {
|
|
327
|
+
if (bin.refcount === 0) {
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
if (--bin.refcount === 0) {
|
|
331
|
+
this.stats[bin.h]--;
|
|
332
|
+
delete this.bins[bin.id];
|
|
333
|
+
this.freebins.push(bin);
|
|
334
|
+
}
|
|
335
|
+
return bin.refcount;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* 清除 sprite,重置所有内容和统计信息
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```ts
|
|
342
|
+
* sprite.clear();
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
clear() {
|
|
346
|
+
this.shelves = [];
|
|
347
|
+
this.freebins = [];
|
|
348
|
+
this.stats = {};
|
|
349
|
+
this.bins = {};
|
|
350
|
+
this.maxId = 0;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* 调整 sprite 尺寸
|
|
354
|
+
*
|
|
355
|
+
* @param w Requested new sprite width
|
|
356
|
+
* @param h Requested new sprite height
|
|
357
|
+
* @returns `true` if resize succeeded
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* ```ts
|
|
361
|
+
* sprite.resize(256, 256);
|
|
362
|
+
* ```
|
|
363
|
+
*/
|
|
364
|
+
resize(w, h) {
|
|
365
|
+
this.w = w;
|
|
366
|
+
this.h = h;
|
|
367
|
+
for (let i = 0; i < this.shelves.length; i++) {
|
|
368
|
+
this.shelves[i].resize(w);
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// src/core/metadata.ts
|
|
375
|
+
import { optimize } from "svgo";
|
|
376
|
+
import computePathBounds from "svg-boundings";
|
|
377
|
+
var metadataPrefix = "mapbox-";
|
|
378
|
+
function strongRound(value, precision = 3) {
|
|
379
|
+
const error = +Math.pow(0.1, precision).toFixed(precision);
|
|
380
|
+
if (+value.toFixed(precision) !== value) {
|
|
381
|
+
const rounded = +value.toFixed(precision - 1);
|
|
382
|
+
value = +Math.abs(rounded - value).toFixed(precision + 1) >= error ? +value.toFixed(precision) : rounded;
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
async function extractMetadata(svg, pixelRatio = 1) {
|
|
387
|
+
const svgString = svg.toString("utf-8");
|
|
388
|
+
if (!svgString.includes("mapbox-stretch") && !svgString.includes("mapbox-text-placeholder")) {
|
|
389
|
+
return {};
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const optimized = optimize(svgString, {
|
|
393
|
+
plugins: [
|
|
394
|
+
{
|
|
395
|
+
name: "convertShapeToPath",
|
|
396
|
+
params: {
|
|
397
|
+
convertArcs: true
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "convertTransform"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "cleanupIds",
|
|
405
|
+
params: {
|
|
406
|
+
preservePrefixes: ["mapbox-"]
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
]
|
|
410
|
+
});
|
|
411
|
+
const { DOMParser: DOMParser2 } = await import("@xmldom/xmldom");
|
|
412
|
+
const doc = new DOMParser2().parseFromString(optimized.data, "image/svg+xml");
|
|
413
|
+
const metadata = {};
|
|
414
|
+
const paths = doc.getElementsByTagName("path");
|
|
415
|
+
for (let i = 0; i < paths.length; i++) {
|
|
416
|
+
const path3 = paths[i];
|
|
417
|
+
const id = path3.getAttribute("id");
|
|
418
|
+
const d = path3.getAttribute("d");
|
|
419
|
+
if (!id || !id.startsWith(metadataPrefix) || !d) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const bounds = computePathBounds.path({ d }, true);
|
|
424
|
+
if (!Number.isFinite(bounds.left) || !Number.isFinite(bounds.right) || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.bottom)) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const left = strongRound(bounds.left * pixelRatio);
|
|
428
|
+
const top = strongRound(bounds.top * pixelRatio);
|
|
429
|
+
const right = strongRound(bounds.right * pixelRatio);
|
|
430
|
+
const bottom = strongRound(bounds.bottom * pixelRatio);
|
|
431
|
+
const metadataType = id.substring(metadataPrefix.length);
|
|
432
|
+
if (metadataType.startsWith("stretch-x") || metadataType === "stretch-x") {
|
|
433
|
+
metadata.stretchX = metadata.stretchX || [];
|
|
434
|
+
metadata.stretchX.push([left, right]);
|
|
435
|
+
} else if (metadataType.startsWith("stretch-y") || metadataType === "stretch-y") {
|
|
436
|
+
metadata.stretchY = metadata.stretchY || [];
|
|
437
|
+
metadata.stretchY.push([top, bottom]);
|
|
438
|
+
} else if (metadataType === "stretch") {
|
|
439
|
+
metadata.stretchX = metadata.stretchX || [];
|
|
440
|
+
metadata.stretchX.push([left, right]);
|
|
441
|
+
metadata.stretchY = metadata.stretchY || [];
|
|
442
|
+
metadata.stretchY.push([top, bottom]);
|
|
443
|
+
} else if (metadataType === "content") {
|
|
444
|
+
metadata.content = [left, top, right, bottom];
|
|
445
|
+
} else if (metadataType === "text-placeholder") {
|
|
446
|
+
metadata.placeholder = [left, top, right, bottom];
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.warn(`Failed to compute bounds for ${id}: ${err}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (metadata.stretchX) {
|
|
453
|
+
metadata.stretchX.sort((a, b) => a[0] - b[0]);
|
|
454
|
+
}
|
|
455
|
+
if (metadata.stretchY) {
|
|
456
|
+
metadata.stretchY.sort((a, b) => a[0] - b[0]);
|
|
457
|
+
}
|
|
458
|
+
return metadata;
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error(`Failed to extract metadata: ${err}`);
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function hasMetadata(svg) {
|
|
465
|
+
const svgString = svg.toString("utf-8");
|
|
466
|
+
return svgString.includes("mapbox-stretch") || svgString.includes("mapbox-text-placeholder");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/core/layout.ts
|
|
470
|
+
function heightAndIdComparator(a, b) {
|
|
471
|
+
const heightDiff = b.height - a.height;
|
|
472
|
+
if (heightDiff !== 0) {
|
|
473
|
+
return heightDiff;
|
|
474
|
+
}
|
|
475
|
+
return a.id.localeCompare(b.id);
|
|
476
|
+
}
|
|
477
|
+
function packImages(images) {
|
|
478
|
+
const sorted = [...images].sort(heightAndIdComparator);
|
|
479
|
+
const sprite = new ShelfPack(1, 1, { autoResize: true });
|
|
480
|
+
sprite.pack(sorted, { inPlace: true });
|
|
481
|
+
const packed = sorted.map((img) => ({
|
|
482
|
+
id: img.id,
|
|
483
|
+
x: img.x,
|
|
484
|
+
y: img.y,
|
|
485
|
+
w: img.width,
|
|
486
|
+
h: img.height
|
|
487
|
+
}));
|
|
488
|
+
return {
|
|
489
|
+
packed,
|
|
490
|
+
width: sprite.w,
|
|
491
|
+
height: sprite.h
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
var svgDimensionsCache = /* @__PURE__ */ new Map();
|
|
495
|
+
async function getImageDimensions(img, pixelRatio, useCache = true) {
|
|
496
|
+
if (img.width && img.height) {
|
|
497
|
+
return {
|
|
498
|
+
width: Math.ceil(img.width * pixelRatio),
|
|
499
|
+
height: Math.ceil(img.height * pixelRatio)
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (img.png) {
|
|
503
|
+
const { createCanvas, Image } = await import("canvas");
|
|
504
|
+
return new Promise((resolve, reject) => {
|
|
505
|
+
const image = new Image();
|
|
506
|
+
image.onload = () => {
|
|
507
|
+
resolve({
|
|
508
|
+
width: Math.ceil(image.width * pixelRatio),
|
|
509
|
+
height: Math.ceil(image.height * pixelRatio)
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
image.onerror = reject;
|
|
513
|
+
image.src = img.png;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (img.svg) {
|
|
517
|
+
let cachedDims;
|
|
518
|
+
let cacheKey;
|
|
519
|
+
if (useCache) {
|
|
520
|
+
cacheKey = img.svg.toString("base64");
|
|
521
|
+
cachedDims = svgDimensionsCache.get(cacheKey);
|
|
522
|
+
if (cachedDims) {
|
|
523
|
+
return {
|
|
524
|
+
width: Math.ceil(cachedDims.width * pixelRatio),
|
|
525
|
+
height: Math.ceil(cachedDims.height * pixelRatio)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const svgString = img.svg.toString("utf8");
|
|
531
|
+
const doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
|
|
532
|
+
const svgElement = doc.documentElement;
|
|
533
|
+
const parseError = doc.getElementsByTagName("parsererror");
|
|
534
|
+
if (parseError && parseError.length > 0) {
|
|
535
|
+
throw new Error(`Failed to parse SVG for ${img.id}: Invalid XML`);
|
|
536
|
+
}
|
|
537
|
+
let width = 0;
|
|
538
|
+
let height = 0;
|
|
539
|
+
const widthAttr = svgElement.getAttribute("width");
|
|
540
|
+
const heightAttr = svgElement.getAttribute("height");
|
|
541
|
+
if (widthAttr && heightAttr) {
|
|
542
|
+
width = parseFloat(widthAttr);
|
|
543
|
+
height = parseFloat(heightAttr);
|
|
544
|
+
} else {
|
|
545
|
+
const viewBox = svgElement.getAttribute("viewBox");
|
|
546
|
+
if (viewBox) {
|
|
547
|
+
const parts = viewBox.split(/\s+/);
|
|
548
|
+
if (parts.length === 4) {
|
|
549
|
+
width = parseFloat(parts[2]);
|
|
550
|
+
height = parseFloat(parts[3]);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!width || !height) {
|
|
555
|
+
console.warn(`No dimensions found for ${img.id}, using default 24x24`);
|
|
556
|
+
width = 24;
|
|
557
|
+
height = 24;
|
|
558
|
+
}
|
|
559
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
560
|
+
throw new Error(`Invalid dimensions for ${img.id}: ${width}x${height}`);
|
|
561
|
+
}
|
|
562
|
+
if (useCache && cacheKey) {
|
|
563
|
+
svgDimensionsCache.set(cacheKey, { width, height });
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
width: Math.ceil(width * pixelRatio),
|
|
567
|
+
height: Math.ceil(height * pixelRatio)
|
|
568
|
+
};
|
|
569
|
+
} catch (err) {
|
|
570
|
+
throw new Error(`Failed to process SVG ${img.id}: ${err}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
width: Math.ceil(24 * pixelRatio),
|
|
575
|
+
height: Math.ceil(24 * pixelRatio)
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function clearDimensionsCache() {
|
|
579
|
+
svgDimensionsCache.clear();
|
|
580
|
+
}
|
|
581
|
+
async function generateLayout(options) {
|
|
582
|
+
const {
|
|
583
|
+
imgs,
|
|
584
|
+
pixelRatio = 1,
|
|
585
|
+
format = false,
|
|
586
|
+
extractMetadata: shouldExtractMetadata = true
|
|
587
|
+
} = options;
|
|
588
|
+
const imagesWithDims = await Promise.all(
|
|
589
|
+
imgs.map(async (img) => ({
|
|
590
|
+
id: img.id,
|
|
591
|
+
svg: img.svg,
|
|
592
|
+
png: img.png,
|
|
593
|
+
...await getImageDimensions(img, pixelRatio)
|
|
594
|
+
}))
|
|
595
|
+
);
|
|
596
|
+
const { packed } = packImages(imagesWithDims);
|
|
597
|
+
const layout = {};
|
|
598
|
+
for (const item of packed) {
|
|
599
|
+
const originalImg = imgs.find((i) => i.id === item.id);
|
|
600
|
+
if (format) {
|
|
601
|
+
layout[item.id] = {
|
|
602
|
+
width: item.w,
|
|
603
|
+
height: item.h,
|
|
604
|
+
x: item.x,
|
|
605
|
+
y: item.y,
|
|
606
|
+
pixelRatio
|
|
607
|
+
};
|
|
608
|
+
if (shouldExtractMetadata && originalImg && originalImg.svg && hasMetadata(originalImg.svg)) {
|
|
609
|
+
try {
|
|
610
|
+
const metadata = await extractMetadata(originalImg.svg, pixelRatio);
|
|
611
|
+
Object.assign(layout[item.id], metadata);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.warn(`Failed to extract metadata for ${item.id}: ${err}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
const img = imagesWithDims.find((i) => i.id === item.id);
|
|
618
|
+
if (img) {
|
|
619
|
+
layout[item.id] = {
|
|
620
|
+
width: item.w,
|
|
621
|
+
height: item.h,
|
|
622
|
+
x: item.x,
|
|
623
|
+
y: item.y,
|
|
624
|
+
pixelRatio,
|
|
625
|
+
svg: img.svg,
|
|
626
|
+
png: img.png
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return layout;
|
|
632
|
+
}
|
|
633
|
+
async function generateLayoutUnique(options) {
|
|
634
|
+
const { imgs, pixelRatio = 1, format = false } = options;
|
|
635
|
+
const imgMap = /* @__PURE__ */ new Map();
|
|
636
|
+
for (const img of imgs) {
|
|
637
|
+
const imgString = (img.svg || img.png)?.toString("base64");
|
|
638
|
+
if (!imgString) continue;
|
|
639
|
+
const existing = imgMap.get(imgString);
|
|
640
|
+
if (existing) {
|
|
641
|
+
existing.push(img.id);
|
|
642
|
+
} else {
|
|
643
|
+
imgMap.set(imgString, [img.id]);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const uniqueImgs = [];
|
|
647
|
+
const idMapping = /* @__PURE__ */ new Map();
|
|
648
|
+
for (const [imgString, ids] of imgMap.entries()) {
|
|
649
|
+
const primaryId = ids[0];
|
|
650
|
+
const buffer = Buffer.from(imgString, "base64");
|
|
651
|
+
const originalImg = imgs.find((i) => i.id === primaryId);
|
|
652
|
+
if (originalImg.svg) {
|
|
653
|
+
uniqueImgs.push({ id: primaryId, svg: buffer });
|
|
654
|
+
} else {
|
|
655
|
+
uniqueImgs.push({ id: primaryId, png: buffer });
|
|
656
|
+
}
|
|
657
|
+
for (const id of ids) {
|
|
658
|
+
idMapping.set(id, primaryId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const baseLayout = await generateLayout({
|
|
662
|
+
imgs: uniqueImgs,
|
|
663
|
+
pixelRatio,
|
|
664
|
+
format: false
|
|
665
|
+
});
|
|
666
|
+
const layout = {};
|
|
667
|
+
for (const img of imgs) {
|
|
668
|
+
const primaryId = idMapping.get(img.id);
|
|
669
|
+
if (primaryId && baseLayout[primaryId]) {
|
|
670
|
+
const baseItem = baseLayout[primaryId];
|
|
671
|
+
layout[img.id] = format ? {
|
|
672
|
+
width: baseItem.width,
|
|
673
|
+
height: baseItem.height,
|
|
674
|
+
x: baseItem.x,
|
|
675
|
+
y: baseItem.y,
|
|
676
|
+
pixelRatio
|
|
677
|
+
} : baseItem;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return layout;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/core/image.ts
|
|
684
|
+
import sharp from "sharp";
|
|
685
|
+
async function renderImage(imageBuffer, isPNG, ctx, x, y, width, height, pixelRatio) {
|
|
686
|
+
const { Image } = await import("canvas");
|
|
687
|
+
return new Promise((resolve, reject) => {
|
|
688
|
+
const img = new Image();
|
|
689
|
+
img.onload = () => {
|
|
690
|
+
try {
|
|
691
|
+
ctx.drawImage(img, x, y, width, height);
|
|
692
|
+
resolve();
|
|
693
|
+
} catch (error) {
|
|
694
|
+
reject(error);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
img.onerror = (error) => {
|
|
698
|
+
reject(new Error(`Failed to load image: ${error}`));
|
|
699
|
+
};
|
|
700
|
+
if (isPNG) {
|
|
701
|
+
img.src = imageBuffer;
|
|
702
|
+
} else {
|
|
703
|
+
const svgString = imageBuffer.toString("utf8");
|
|
704
|
+
const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svgString).toString("base64")}`;
|
|
705
|
+
img.src = svgDataUrl;
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
function getCanvasDimensions(layout) {
|
|
710
|
+
let maxX = 0;
|
|
711
|
+
let maxY = 0;
|
|
712
|
+
for (const item of Object.values(layout)) {
|
|
713
|
+
maxX = Math.max(maxX, item.x + item.width);
|
|
714
|
+
maxY = Math.max(maxY, item.y + item.height);
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
width: maxX || 1,
|
|
718
|
+
height: maxY || 1
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
async function generateImage(layout) {
|
|
722
|
+
const { createCanvas } = await import("canvas");
|
|
723
|
+
const layoutKeys = Object.keys(layout);
|
|
724
|
+
if (layoutKeys.length === 0) {
|
|
725
|
+
throw new Error("Cannot generate image from empty layout");
|
|
726
|
+
}
|
|
727
|
+
const { width, height } = getCanvasDimensions(layout);
|
|
728
|
+
if (width === 0 || height === 0) {
|
|
729
|
+
throw new Error("Invalid canvas dimensions: width and height must be greater than 0");
|
|
730
|
+
}
|
|
731
|
+
const canvas = createCanvas(width, height);
|
|
732
|
+
const ctx = canvas.getContext("2d");
|
|
733
|
+
ctx.clearRect(0, 0, width, height);
|
|
734
|
+
const renderPromises = [];
|
|
735
|
+
for (const [id, item] of Object.entries(layout)) {
|
|
736
|
+
const svg = item.svg;
|
|
737
|
+
const png = item.png;
|
|
738
|
+
if (!svg && !png) {
|
|
739
|
+
console.warn(`Warning: No image data for '${id}', skipping...`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const imageBuffer = svg || png;
|
|
743
|
+
const isPNG = !!png;
|
|
744
|
+
const promise = renderImage(
|
|
745
|
+
imageBuffer,
|
|
746
|
+
isPNG,
|
|
747
|
+
ctx,
|
|
748
|
+
item.x,
|
|
749
|
+
item.y,
|
|
750
|
+
item.width,
|
|
751
|
+
item.height,
|
|
752
|
+
item.pixelRatio
|
|
753
|
+
);
|
|
754
|
+
renderPromises.push(promise);
|
|
755
|
+
}
|
|
756
|
+
await Promise.all(renderPromises);
|
|
757
|
+
return canvas.toBuffer("image/png");
|
|
758
|
+
}
|
|
759
|
+
async function generateOptimizedImage(layout, options = {}) {
|
|
760
|
+
const { colors = 256, quality = 80, palette = true } = options;
|
|
761
|
+
const standardPng = await generateImage(layout);
|
|
762
|
+
const optimized = await sharp(standardPng).png({
|
|
763
|
+
palette,
|
|
764
|
+
quality,
|
|
765
|
+
colors,
|
|
766
|
+
compressionLevel: 9,
|
|
767
|
+
// 最高压缩级别
|
|
768
|
+
adaptiveFiltering: true,
|
|
769
|
+
force: true
|
|
770
|
+
}).toBuffer();
|
|
771
|
+
return optimized;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/utils.ts
|
|
775
|
+
import fs from "fs/promises";
|
|
776
|
+
import path from "path";
|
|
777
|
+
async function getFilePaths(dir) {
|
|
778
|
+
const entries = await fs.readdir(dir);
|
|
779
|
+
return entries.filter((entry) => !entry.startsWith(".") && (entry.endsWith(".svg") || entry.endsWith(".png"))).map((entry) => path.join(dir, entry));
|
|
780
|
+
}
|
|
781
|
+
async function loadFile(filePath) {
|
|
782
|
+
const buffer = await fs.readFile(filePath);
|
|
783
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
784
|
+
const id = path.basename(filePath).replace(/\.(svg|png)$/i, "");
|
|
785
|
+
if (ext === ".svg") {
|
|
786
|
+
return { svg: buffer, id };
|
|
787
|
+
} else if (ext === ".png") {
|
|
788
|
+
return { png: buffer, id };
|
|
789
|
+
} else {
|
|
790
|
+
throw new Error(`Unsupported file format: ${ext}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function sortById(a, b) {
|
|
794
|
+
return b.id < a.id ? 1 : -1;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/sprite.ts
|
|
798
|
+
async function ensureDir(filePath) {
|
|
799
|
+
const dir = path2.dirname(filePath);
|
|
800
|
+
try {
|
|
801
|
+
await fs2.access(dir);
|
|
802
|
+
} catch {
|
|
803
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async function saveLayout(outfile, layout, format = true) {
|
|
807
|
+
const jsonPath = `${outfile}.json`;
|
|
808
|
+
await ensureDir(jsonPath);
|
|
809
|
+
const formattedLayout = format ? stringify(layout, { space: " " }) || "{}" : JSON.stringify(layout);
|
|
810
|
+
await fs2.writeFile(jsonPath, formattedLayout, "utf8");
|
|
811
|
+
}
|
|
812
|
+
async function saveImage(outfile, layout) {
|
|
813
|
+
const pngPath = `${outfile}.png`;
|
|
814
|
+
await ensureDir(pngPath);
|
|
815
|
+
const image = await generateImage(layout);
|
|
816
|
+
await fs2.writeFile(pngPath, image);
|
|
817
|
+
}
|
|
818
|
+
async function createLayout(buffers, ratio, format, unique) {
|
|
819
|
+
const genLayout = unique ? generateLayoutUnique : generateLayout;
|
|
820
|
+
return await genLayout({ imgs: buffers, pixelRatio: ratio, format });
|
|
821
|
+
}
|
|
822
|
+
async function generateSprite(options) {
|
|
823
|
+
const { output, input, ratio = 1, unique = false, format = true } = options;
|
|
824
|
+
let outfile = output;
|
|
825
|
+
if (outfile.endsWith("/")) {
|
|
826
|
+
const defaultName = ratio === 1 ? "sprite" : `sprite@${ratio}x`;
|
|
827
|
+
outfile = outfile + defaultName;
|
|
828
|
+
}
|
|
829
|
+
const filePaths = await getFilePaths(input);
|
|
830
|
+
if (filePaths.length === 0) {
|
|
831
|
+
throw new Error(`No SVG or PNG files found in directory: ${input}`);
|
|
832
|
+
}
|
|
833
|
+
const buffers = await Promise.all(filePaths.map((file) => loadFile(file)));
|
|
834
|
+
buffers.sort(sortById);
|
|
835
|
+
const layoutForJson = await createLayout(buffers, ratio, format, unique);
|
|
836
|
+
const layoutForImage = await createLayout(buffers, ratio, false, unique);
|
|
837
|
+
await Promise.all([
|
|
838
|
+
saveLayout(outfile, layoutForJson, format),
|
|
839
|
+
saveImage(outfile, layoutForImage)
|
|
840
|
+
]);
|
|
841
|
+
return {
|
|
842
|
+
jsonPath: `${outfile}.json`,
|
|
843
|
+
pngPath: `${outfile}.png`,
|
|
844
|
+
layout: layoutForJson,
|
|
845
|
+
imageCount: buffers.length
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
async function generateSpriteMultiple(input, output, ratios = [1, 2], unique = false) {
|
|
849
|
+
const results = await Promise.all(
|
|
850
|
+
ratios.map((ratio) => {
|
|
851
|
+
const outputPath = ratio === 1 ? output : `${output}@${ratio}x`;
|
|
852
|
+
return generateSprite({
|
|
853
|
+
output: outputPath,
|
|
854
|
+
input,
|
|
855
|
+
ratio,
|
|
856
|
+
unique
|
|
857
|
+
});
|
|
858
|
+
})
|
|
859
|
+
);
|
|
860
|
+
return results;
|
|
861
|
+
}
|
|
862
|
+
async function generateSpriteFromBuffers(buffers, output, options = {}) {
|
|
863
|
+
const { ratio = 1, unique = false, format = true } = options;
|
|
864
|
+
if (buffers.length === 0) {
|
|
865
|
+
throw new Error("No image buffers provided");
|
|
866
|
+
}
|
|
867
|
+
buffers.sort(sortById);
|
|
868
|
+
let outfile = output;
|
|
869
|
+
if (outfile.endsWith("/")) {
|
|
870
|
+
const defaultName = ratio === 1 ? "sprite" : `sprite@${ratio}x`;
|
|
871
|
+
outfile = outfile + defaultName;
|
|
872
|
+
}
|
|
873
|
+
const layoutForJson = await createLayout(buffers, ratio, format, unique);
|
|
874
|
+
const layoutForImage = await createLayout(buffers, ratio, false, unique);
|
|
875
|
+
await Promise.all([
|
|
876
|
+
saveLayout(outfile, layoutForJson, format),
|
|
877
|
+
saveImage(outfile, layoutForImage)
|
|
878
|
+
]);
|
|
879
|
+
return {
|
|
880
|
+
jsonPath: `${outfile}.json`,
|
|
881
|
+
pngPath: `${outfile}.png`,
|
|
882
|
+
layout: layoutForJson,
|
|
883
|
+
imageCount: buffers.length
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
export {
|
|
887
|
+
clearDimensionsCache,
|
|
888
|
+
extractMetadata,
|
|
889
|
+
generateImage,
|
|
890
|
+
generateLayout,
|
|
891
|
+
generateLayoutUnique,
|
|
892
|
+
generateOptimizedImage,
|
|
893
|
+
generateSprite,
|
|
894
|
+
generateSpriteFromBuffers,
|
|
895
|
+
generateSpriteMultiple,
|
|
896
|
+
getFilePaths,
|
|
897
|
+
hasMetadata,
|
|
898
|
+
loadFile,
|
|
899
|
+
sortById
|
|
900
|
+
};
|
|
901
|
+
//# sourceMappingURL=index.js.map
|