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