@sakitam-gis/sprite-cli 1.0.3 → 1.0.4

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/QUICK_START.md CHANGED
@@ -90,6 +90,24 @@ icons/
90
90
  - ✅ SVG(推荐)- 矢量图形,自动优化
91
91
  - ✅ PNG - 光栅图像
92
92
 
93
+ <a id="svg-class-styles"></a>
94
+
95
+ #### SVG 图标的 class 样式
96
+
97
+ 设计工具导出的 SVG 可以直接作为输入。例如,下图在雪碧图中显示红色填充和浅色描边。
98
+
99
+ ```svg
100
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
101
+ <style>.st0 { fill: #FF0000; stroke: #F9F7F7; stroke-width: 2 }</style>
102
+ <path class="st0" d="M2 2h12v12H2z"/>
103
+ </svg>
104
+ ```
105
+
106
+ - CLI 和 Node.js API 在内存中补齐缺少的展示属性。元素上已有的同名属性和内联 `style` 优先。
107
+ - 支持 `.st0`、`.st0, .st1` 以及一个元素带多个 class。匹配的类声明之间先比较 `!important`,再取样式表中靠后的声明。
108
+ - 支持填充、描边、线宽与虚线、透明度、`color`、渐变色标和填充/裁剪规则。可通过 `fill: url(#gradientId)` 引用 SVG 内部渐变,也可写成 `url(#gradientId) hsl(120,100%,50%)` 指定引用失效时的备用色。半透明颜色支持 `rgba()`。
109
+ - 预处理只展开内嵌样式表中顶层的简单类选择器(类名使用 ASCII 字母、数字、下划线或连字符)。后代选择器、复合选择器、伪类、条件规则、外部样式表及 CSS 变量均不在展开范围内。依赖这些写法的图标,建议在导出时把最终颜色写入元素属性。
110
+
93
111
  ### 2. 生成雪碧图
94
112
 
95
113
  最简单的用法:
@@ -148,9 +166,6 @@ sprite-maker [output] [input] [options]
148
166
  | `--retina` | 生成 2x 像素比率(等同于 --ratio=2) | boolean | `false` | `--retina` |
149
167
  | `--ratio=<n>` | 指定像素比率 | number | `1` | `--ratio=3` |
150
168
  | `--unique` | 去重模式,相同图像只存储一次 | boolean | `false` | `--unique` |
151
- | `--colors=<n>` | PNG 颜色数量(2-256) | number | `256` | `--colors=128` |
152
- | `--quality=<n>` | PNG 质量(0-100) | number | `80` | `--quality=90` |
153
- | `--palette` | 使用调色板模式压缩 PNG | boolean | `false` | `--palette` |
154
169
  | `--help, -h` | 显示帮助信息 | - | - | `--help` |
155
170
 
156
171
  ## 🎨 高级功能
@@ -309,77 +324,20 @@ sprite-maker sprites/ icons/ --retina
309
324
 
310
325
  ## 🗜️ PNG 优化
311
326
 
312
- 本工具使用 **Sharp** 库对生成的 PNG 文件进行高级优化。
313
-
314
- ### 优化选项
315
-
316
- #### 1. 颜色量化
317
-
318
- 减少颜色数量以降低文件大小:
319
-
320
- ```bash
321
- # 使用 128 色调色板(默认 256 色)
322
- sprite-maker output/sprite icons/ --colors=128
323
- ```
324
-
325
- **推荐值:**
326
- - `256` - 最高质量(默认)
327
- - `128` - 高质量,文件较小
328
- - `64` - 中等质量,文件更小
329
- - `32` - 低质量,最小文件
330
-
331
- #### 2. 质量设置
332
-
333
- 调整 PNG 压缩质量:
334
-
335
- ```bash
336
- # 90% 质量(默认 80%)
337
- sprite-maker output/sprite icons/ --quality=90
338
- ```
339
-
340
- **推荐值:**
341
- - `90-100` - 高质量,文件较大
342
- - `80-89` - 平衡质量和大小(推荐)
343
- - `60-79` - 较小文件,质量略降
344
- - `< 60` - 不推荐
345
-
346
- #### 3. 调色板模式
347
-
348
- 启用调色板模式进一步压缩:
349
-
350
- ```bash
351
- sprite-maker output/sprite icons/ --palette
352
- ```
353
-
354
- **效果:**
355
- - ✅ 大幅减小文件大小(通常 30-50%)
356
- - ⚠️ 可能轻微影响颜色精度
357
- - ✅ 适用于图标和简单图形
358
-
359
- #### 4. 组合使用
327
+ 在 Node.js 中调用 `generateOptimizedImage` 可设置调色板、颜色数和质量。传入包含图像数据的布局(`format: false`),将返回的 PNG Buffer 写入文件。
360
328
 
361
- 结合多个选项获得最佳效果:
329
+ ```js
330
+ import { writeFile } from 'node:fs/promises';
331
+ import {
332
+ getFilePaths, loadFile, generateLayout, generateOptimizedImage,
333
+ } from '@sakitam-gis/sprite-cli';
362
334
 
363
- ```bash
364
- # 最小文件大小(适用于图标)
365
- sprite-maker output/sprite icons/ --palette --colors=64 --quality=75
366
-
367
- # 高质量压缩(适用于复杂图形)
368
- sprite-maker output/sprite icons/ --colors=256 --quality=90
369
-
370
- # 平衡模式(推荐)
371
- sprite-maker output/sprite icons/ --palette --colors=128 --quality=80
335
+ const imgs = await Promise.all((await getFilePaths('icons/')).map(loadFile));
336
+ const layout = await generateLayout({ imgs, pixelRatio: 1, format: false });
337
+ const png = await generateOptimizedImage(layout, { palette: true, colors: 128, quality: 80 });
338
+ await writeFile('sprite.png', png);
372
339
  ```
373
340
 
374
- ### 优化效果对比
375
-
376
- | 配置 | 文件大小 | 质量 | 适用场景 |
377
- |------|----------|------|----------|
378
- | 默认 | 100% | 优秀 | 通用 |
379
- | `--palette --colors=128` | ~60% | 良好 | 图标、简单图形 |
380
- | `--palette --colors=64` | ~40% | 中等 | 图标(不追求完美) |
381
- | `--quality=90` | ~110% | 极佳 | 需要高保真的场景 |
382
-
383
341
  ## 💡 实际应用场景
384
342
 
385
343
  ### 场景 1: Web 应用图标
@@ -450,7 +408,7 @@ sprite-maker map-styles/sprite@2x map-icons/ --retina
450
408
 
451
409
  ```bash
452
410
  # 生成游戏角色精灵表(PNG 输入)
453
- sprite-maker game/sprites/characters characters/*.png --unique --palette
411
+ sprite-maker game/sprites/characters characters/ --unique
454
412
  ```
455
413
 
456
414
  ### 场景 4: 移动应用资源
@@ -471,8 +429,8 @@ sprite-maker mobile/assets/icons@3x icons/ --ratio=3
471
429
  "scripts": {
472
430
  "prebuild": "npm run build:sprites",
473
431
  "build:sprites": "npm run sprites:1x && npm run sprites:2x",
474
- "sprites:1x": "sprite-maker public/sprites/icon assets/icons/ --palette --unique",
475
- "sprites:2x": "sprite-maker public/sprites/icon@2x assets/icons/ --retina --palette --unique",
432
+ "sprites:1x": "sprite-maker public/sprites/icon assets/icons/ --unique",
433
+ "sprites:2x": "sprite-maker public/sprites/icon@2x assets/icons/ --retina --unique",
476
434
  "watch:sprites": "chokidar 'assets/icons/**/*.svg' -c 'npm run build:sprites'"
477
435
  }
478
436
  }
@@ -482,11 +440,7 @@ sprite-maker mobile/assets/icons@3x icons/ --ratio=3
482
440
 
483
441
  ### Q1: 生成的 PNG 文件太大,如何优化?
484
442
 
485
- **A:** 使用优化选项:
486
-
487
- ```bash
488
- sprite-maker output/sprite icons/ --palette --colors=128 --quality=80
489
- ```
443
+ 使用 Node.js API 调整调色板颜色数和质量,示例见 [PNG 优化](#-png-优化)。
490
444
 
491
445
  ### Q2: 支持哪些图像格式?
492
446
 
@@ -503,8 +457,8 @@ sprite-maker output/sprite icons/ --palette --colors=128 --quality=80
503
457
  - 可处理 500+ 图标
504
458
 
505
459
  ```bash
506
- # 大批量图标建议使用 unique 模式和优化
507
- sprite-maker output/sprite icons/ --unique --palette --colors=128
460
+ # 大批量图标建议使用 unique 模式
461
+ sprite-maker output/sprite icons/ --unique
508
462
  ```
509
463
 
510
464
  ### Q4: unique 模式什么时候使用?
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  ### 🎨 图像支持
17
17
  - ✅ **SVG 支持** - 原生支持矢量图形,自动优化
18
+ - ✅ **SVG 类样式** - 自动读取内嵌 `<style>` 中的简单类规则,保留图标的填充和描边颜色。[支持范围与示例](./QUICK_START.md#svg-class-styles)
18
19
  - ✅ **PNG 支持** - 支持光栅图像输入
19
20
  - ✅ **多种像素比率** - 支持 1x、2x、3x 等 Retina 显示屏
20
21
  - ✅ **智能去重** - unique 模式,自动识别相同图像
@@ -114,16 +115,6 @@ sprite-maker output/sprite@3x icons/ --ratio=3 # 3x
114
115
  sprite-maker output/sprite icons/ --unique
115
116
  ```
116
117
 
117
- #### 5️⃣ PNG 优化选项
118
-
119
- ```bash
120
- # 调色板模式(减小文件大小)
121
- sprite-maker output/sprite icons/ --palette --colors=128
122
-
123
- # 自定义质量
124
- sprite-maker output/sprite icons/ --quality=90
125
- ```
126
-
127
118
  ## 📖 详细文档
128
119
 
129
120
  查看 [快速开始指南](./QUICK_START.md) 获取更多详细示例和高级用法。
@@ -135,9 +126,6 @@ sprite-maker output/sprite icons/ --quality=90
135
126
  | `--retina` | 生成 2x 像素比率(等同于 --ratio=2) | `false` |
136
127
  | `--ratio=<n>` | 指定像素比率(1x, 2x, 3x 等) | `1` |
137
128
  | `--unique` | 去重模式,相同图像只存储一次 | `false` |
138
- | `--colors=<n>` | PNG 颜色数量(2-256) | `256` |
139
- | `--quality=<n>` | PNG 质量(0-100) | `80` |
140
- | `--palette` | 使用调色板模式压缩 PNG | `false` |
141
129
  | `--help, -h` | 显示帮助信息 | - |
142
130
 
143
131
  ## 🛠️ 开发
package/dist/cli.js CHANGED
@@ -110,7 +110,7 @@ var ShelfPack = class {
110
110
  * ```
111
111
  */
112
112
  pack(bins, options = {}) {
113
- const binArray = [].concat(bins);
113
+ const binArray = Array.isArray(bins) ? bins : [bins];
114
114
  const results = [];
115
115
  for (let i = 0; i < binArray.length; i++) {
116
116
  const w = binArray[i].w || binArray[i].width;
@@ -416,8 +416,8 @@ async function extractMetadata(svg, pixelRatio = 1) {
416
416
  }
417
417
  ]
418
418
  });
419
- const { DOMParser: DOMParser2 } = await import("@xmldom/xmldom");
420
- const doc = new DOMParser2().parseFromString(optimized.data, "image/svg+xml");
419
+ const { DOMParser: DOMParser3 } = await import("@xmldom/xmldom");
420
+ const doc = new DOMParser3().parseFromString(optimized.data, "image/svg+xml");
421
421
  const metadata = {};
422
422
  const paths = doc.getElementsByTagName("path");
423
423
  for (let i = 0; i < paths.length; i++) {
@@ -454,7 +454,7 @@ async function extractMetadata(svg, pixelRatio = 1) {
454
454
  metadata.placeholder = [left, top, right, bottom];
455
455
  }
456
456
  } catch (err) {
457
- console.warn(`Failed to compute bounds for ${id}: ${err}`);
457
+ console.warn(`Failed to compute bounds for ${id}: ${String(err)}`);
458
458
  }
459
459
  }
460
460
  if (metadata.stretchX) {
@@ -465,7 +465,7 @@ async function extractMetadata(svg, pixelRatio = 1) {
465
465
  }
466
466
  return metadata;
467
467
  } catch (err) {
468
- console.error(`Failed to extract metadata: ${err}`);
468
+ console.error(`Failed to extract metadata: ${String(err)}`);
469
469
  return {};
470
470
  }
471
471
  }
@@ -483,7 +483,9 @@ function heightAndIdComparator(a, b) {
483
483
  return a.id.localeCompare(b.id);
484
484
  }
485
485
  function packImages(images) {
486
- const sorted = [...images].sort(heightAndIdComparator);
486
+ const sorted = [...images].sort(
487
+ heightAndIdComparator
488
+ );
487
489
  const sprite = new ShelfPack(1, 1, { autoResize: true });
488
490
  sprite.pack(sorted, { inPlace: true });
489
491
  const packed = sorted.map((img) => ({
@@ -571,7 +573,7 @@ async function getImageDimensions(img, pixelRatio, useCache = true) {
571
573
  height: Math.ceil(height * pixelRatio)
572
574
  };
573
575
  } catch (err) {
574
- throw new Error(`Failed to process SVG ${img.id}: ${err}`);
576
+ throw new Error(`Failed to process SVG ${img.id}: ${String(err)}`);
575
577
  }
576
578
  }
577
579
  return {
@@ -611,7 +613,7 @@ async function generateLayout(options) {
611
613
  const metadata = await extractMetadata(originalImg.svg, pixelRatio);
612
614
  Object.assign(layout[item.id], metadata);
613
615
  } catch (err) {
614
- console.warn(`Failed to extract metadata for ${item.id}: ${err}`);
616
+ console.warn(`Failed to extract metadata for ${item.id}: ${String(err)}`);
615
617
  }
616
618
  }
617
619
  } else {
@@ -685,51 +687,145 @@ async function generateLayoutUnique(options) {
685
687
  import sharp from "sharp";
686
688
  import { createCanvas, loadImage as loadImage2 } from "@napi-rs/canvas";
687
689
 
690
+ // src/core/svg-styles.ts
691
+ import { DOMParser as DOMParser2, XMLSerializer } from "@xmldom/xmldom";
692
+ import postcss from "postcss";
693
+ var PRESENTATION = /* @__PURE__ */ new Set([
694
+ "fill",
695
+ "fill-opacity",
696
+ "fill-rule",
697
+ "stroke",
698
+ "stroke-width",
699
+ "stroke-opacity",
700
+ "stroke-linecap",
701
+ "stroke-linejoin",
702
+ "stroke-miterlimit",
703
+ "stroke-dasharray",
704
+ "stroke-dashoffset",
705
+ "opacity",
706
+ "color",
707
+ "stop-color",
708
+ "stop-opacity",
709
+ "clip-rule"
710
+ ]);
711
+ function parseCss(css) {
712
+ try {
713
+ return postcss.parse(css);
714
+ } catch (error) {
715
+ if (error instanceof postcss.CssSyntaxError) return void 0;
716
+ throw error;
717
+ }
718
+ }
719
+ function select(target, prop, next) {
720
+ const prev = target.get(prop);
721
+ if (!prev || (next.important !== prev.important ? next.important : next.order >= prev.order)) {
722
+ target.set(prop, next);
723
+ }
724
+ }
725
+ function collectRules(doc) {
726
+ const byClass = /* @__PURE__ */ new Map();
727
+ let order = 0;
728
+ for (const style of Array.from(doc.getElementsByTagNameNS("*", "style"))) {
729
+ if (style.namespaceURI !== doc.documentElement.namespaceURI) continue;
730
+ const type = style.getAttribute("type")?.trim().toLowerCase();
731
+ const media = style.getAttribute("media")?.trim().toLowerCase();
732
+ if (type && type !== "text/css" || media && media !== "all") continue;
733
+ for (const rule of parseCss(style.textContent || "")?.nodes ?? []) {
734
+ if (rule.type !== "rule") continue;
735
+ const properties = /* @__PURE__ */ new Map();
736
+ for (const decl of rule.nodes) {
737
+ if (decl.type !== "decl") continue;
738
+ const prop = decl.prop.toLowerCase();
739
+ if (PRESENTATION.has(prop)) {
740
+ select(properties, prop, {
741
+ value: decl.value,
742
+ important: !!decl.important,
743
+ order: order++
744
+ });
745
+ }
746
+ }
747
+ for (const selector of new Set(rule.selectors)) {
748
+ if (!/^\.[\w-]+$/.test(selector)) continue;
749
+ const name = selector.slice(1);
750
+ const declarations = byClass.get(name) ?? /* @__PURE__ */ new Map();
751
+ for (const [prop, value] of properties) select(declarations, prop, value);
752
+ byClass.set(name, declarations);
753
+ }
754
+ }
755
+ }
756
+ return byClass;
757
+ }
758
+ function simplifyImportant(style) {
759
+ const winners = /* @__PURE__ */ new Map();
760
+ for (const decl of style.nodes) {
761
+ if (decl.type === "decl" && decl.important && PRESENTATION.has(decl.prop.toLowerCase())) {
762
+ winners.set(decl.prop.toLowerCase(), decl);
763
+ }
764
+ }
765
+ if (!winners.size) return false;
766
+ style.walkDecls((decl) => {
767
+ const winner = winners.get(decl.prop.toLowerCase());
768
+ if (winner === decl) decl.important = false;
769
+ else if (winner) decl.remove();
770
+ });
771
+ return true;
772
+ }
773
+ function inlineSvgClassStyles(svg) {
774
+ if (!/<(?:[^\s<>:]+:)?style[\s>]/.test(svg) && !/!\s*important/i.test(svg)) return svg;
775
+ const doc = new DOMParser2().parseFromString(svg, "image/svg+xml");
776
+ const byClass = collectRules(doc);
777
+ let changed = false;
778
+ for (const element of Array.from(doc.getElementsByTagName("*"))) {
779
+ if (element.namespaceURI !== doc.documentElement.namespaceURI) continue;
780
+ const properties = /* @__PURE__ */ new Map();
781
+ for (const name of new Set((element.getAttribute("class") || "").split(/\s+/))) {
782
+ for (const [prop, value] of byClass.get(name) ?? []) select(properties, prop, value);
783
+ }
784
+ const inline = element.getAttribute("style");
785
+ if (inline && (properties.size || inline.includes("!"))) {
786
+ const style = parseCss(inline);
787
+ if (!style) continue;
788
+ style.walkDecls((decl) => {
789
+ properties.delete(decl.prop.toLowerCase());
790
+ });
791
+ if (simplifyImportant(style)) {
792
+ element.setAttribute("style", style.toString());
793
+ changed = true;
794
+ }
795
+ }
796
+ for (const [prop, { value }] of properties) {
797
+ if (!element.hasAttribute(prop)) {
798
+ element.setAttribute(prop, value);
799
+ changed = true;
800
+ }
801
+ }
802
+ }
803
+ return changed ? new XMLSerializer().serializeToString(doc) : svg;
804
+ }
805
+
688
806
  // src/core/colors.ts
689
807
  import { colord, extend } from "colord";
690
808
  import namesPlugin from "colord/plugins/names";
691
809
  extend([namesPlugin]);
692
- var COLOR_FUNC_RE = /(?:#[0-9a-fA-F]{3,8}|(?:hsl|rgb)a?\([^)]*\))/g;
693
- var COLOR_ATTR_RE = /\b(?:fill|stroke|stop-color|flood-color|color|lighting-color)\s*=\s*"([^"]*)"/gi;
694
- var STYLE_COLOR_PROP_RE = /(?:fill|stroke|stop-color|flood-color|color|lighting-color|background-color)\s*:\s*([^;"}{]+)/gi;
695
- function toHex(value) {
696
- const c = colord(value);
697
- if (!c.isValid()) return null;
698
- const hex = c.toHex();
699
- const rgba = c.toRgb();
700
- return rgba.a === 1 ? hex.slice(0, 7) : hex;
810
+ var COLOR_ATTR_RE = /\b(?:fill|stroke|stop-color|flood-color|color|lighting-color)\s*=\s*(["'])(.*?)\1/gi;
811
+ var STYLE_COLOR_PROP_RE = /(?:fill|stroke|stop-color|flood-color|color|lighting-color|background-color)\s*:\s*([^;"'}{]+)/gi;
812
+ function normalizePaint(value) {
813
+ const trimmed = value.trim();
814
+ const url = /^url\((?:"[^"]*"|'[^']*'|[^)]*)\)\s*/i.exec(trimmed)?.[0] ?? "";
815
+ const color = colord(trimmed.slice(url.length));
816
+ if (!color.isValid()) return value;
817
+ const normalized = color.toRgb().a === 1 ? color.toHex() : color.toRgbString();
818
+ return url + normalized;
701
819
  }
702
820
  function normalizeSvgColors(svgString) {
703
- const lower = svgString.toLowerCase();
704
- if (!lower.includes("hsl") && !lower.includes("rgb") && !lower.includes("color")) {
705
- return svgString;
706
- }
707
- let result = svgString;
708
- result = result.replace(COLOR_ATTR_RE, (fullMatch, attrValue) => {
709
- const replaced = attrValue.replace(
710
- COLOR_FUNC_RE,
711
- (funcMatch) => toHex(funcMatch) || funcMatch
712
- );
713
- return fullMatch.replace(attrValue, replaced);
714
- });
715
- result = result.replace(STYLE_COLOR_PROP_RE, (fullMatch, colorValue) => {
716
- const trimmed = colorValue.trim();
717
- const hex = toHex(trimmed);
718
- if (hex) {
719
- return fullMatch.replace(colorValue, ` ${hex}`);
720
- }
721
- return fullMatch;
722
- });
723
- result = result.replace(COLOR_ATTR_RE, (fullMatch, attrValue) => {
724
- const trimmed = attrValue.trim();
725
- if (!trimmed || trimmed.startsWith("url(")) return fullMatch;
726
- const hex = toHex(trimmed);
727
- if (hex && hex !== trimmed.toLowerCase()) {
728
- return fullMatch.replace(attrValue, hex);
729
- }
730
- return fullMatch;
731
- });
732
- return result;
821
+ if (!/(?:hsl|rgb|color|#[\da-f]{4}\b|#[\da-f]{8}\b)/i.test(svgString)) return svgString;
822
+ return svgString.replace(
823
+ STYLE_COLOR_PROP_RE,
824
+ (full, value) => full.replace(value, () => normalizePaint(value))
825
+ ).replace(
826
+ COLOR_ATTR_RE,
827
+ (full, quote, value) => full.replace(quote + value + quote, () => quote + normalizePaint(value) + quote)
828
+ );
733
829
  }
734
830
 
735
831
  // src/core/image.ts
@@ -739,7 +835,7 @@ async function renderImage(imageBuffer, isPNG, ctx, x, y, width, height) {
739
835
  if (isPNG) {
740
836
  src = imageBuffer;
741
837
  } else {
742
- const svgString = normalizeSvgColors(imageBuffer.toString("utf8"));
838
+ const svgString = normalizeSvgColors(inlineSvgClassStyles(imageBuffer.toString("utf8")));
743
839
  const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svgString).toString("base64")}`;
744
840
  src = svgDataUrl;
745
841
  }
@@ -776,7 +872,10 @@ async function generateImage(layout) {
776
872
  const ctx = canvas.getContext("2d");
777
873
  ctx.clearRect(0, 0, width, height);
778
874
  const renderPromises = [];
875
+ const rendered = /* @__PURE__ */ new Set();
779
876
  for (const [id, item] of Object.entries(layout)) {
877
+ if (rendered.has(item)) continue;
878
+ rendered.add(item);
780
879
  const svg = item.svg;
781
880
  const png = item.png;
782
881
  if (!svg && !png) {