react-native-nano-icons 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +20 -164
  2. package/android/build.gradle +28 -0
  3. package/android/src/main/java/com/nanoicons/NanoIconView.kt +78 -0
  4. package/android/src/main/java/com/nanoicons/NanoIconViewManager.kt +84 -0
  5. package/android/src/main/java/com/nanoicons/NanoIconsPackage.kt +22 -0
  6. package/ios/NanoIconView.h +4 -0
  7. package/ios/NanoIconView.mm +246 -0
  8. package/lib/commonjs/cli/build.js +1 -1
  9. package/lib/commonjs/cli/config.d.ts +2 -2
  10. package/lib/commonjs/cli/config.js +7 -6
  11. package/lib/commonjs/plugin/src/buildFonts.d.ts +1 -0
  12. package/lib/commonjs/plugin/src/buildFonts.js +9 -0
  13. package/lib/commonjs/plugin/src/index.js +1 -34
  14. package/lib/commonjs/plugin/src/withNanoIconsFontLinking.d.ts +6 -6
  15. package/lib/commonjs/plugin/src/withNanoIconsFontLinking.js +11 -15
  16. package/lib/commonjs/scripts/cli.js +15 -5
  17. package/lib/commonjs/src/core/font/compile.d.ts +13 -2
  18. package/lib/commonjs/src/core/font/compile.js +49 -46
  19. package/lib/commonjs/src/core/pipeline/managers.js +19 -3
  20. package/lib/commonjs/src/core/pipeline/run.js +121 -32
  21. package/lib/commonjs/src/core/svg/layers.d.ts +16 -0
  22. package/lib/commonjs/src/core/svg/layers.js +27 -0
  23. package/lib/commonjs/src/core/svg/svg_dom.d.ts +29 -1
  24. package/lib/commonjs/src/core/svg/svg_dom.js +78 -2
  25. package/lib/commonjs/src/core/svg/svg_pathops.d.ts +11 -0
  26. package/lib/commonjs/src/core/svg/svg_pathops.js +209 -19
  27. package/lib/commonjs/src/core/types.d.ts +30 -15
  28. package/lib/module/core/font/compile.js +52 -41
  29. package/lib/module/core/font/compile.js.map +1 -1
  30. package/lib/module/core/pipeline/managers.js +17 -3
  31. package/lib/module/core/pipeline/managers.js.map +1 -1
  32. package/lib/module/core/pipeline/run.js +131 -44
  33. package/lib/module/core/pipeline/run.js.map +1 -1
  34. package/lib/module/core/shims/picosvg-0.22.3-py3-none-any.whl +0 -0
  35. package/lib/module/core/svg/layers.js +34 -0
  36. package/lib/module/core/svg/layers.js.map +1 -1
  37. package/lib/module/core/svg/svg_dom.js +91 -4
  38. package/lib/module/core/svg/svg_dom.js.map +1 -1
  39. package/lib/module/core/svg/svg_pathops.js +203 -19
  40. package/lib/module/core/svg/svg_pathops.js.map +1 -1
  41. package/lib/module/createNanoIconsSet.js +3 -79
  42. package/lib/module/createNanoIconsSet.js.map +1 -1
  43. package/lib/module/createNanoIconsSet.native.js +68 -0
  44. package/lib/module/createNanoIconsSet.native.js.map +1 -0
  45. package/lib/module/createNanoIconsSet.shared.js +91 -0
  46. package/lib/module/createNanoIconsSet.shared.js.map +1 -0
  47. package/lib/module/index.js +1 -2
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/specs/NanoIconViewNativeComponent.ts +15 -0
  50. package/lib/module/types.js +4 -0
  51. package/lib/module/types.js.map +1 -0
  52. package/lib/module/utils/shallowEqualColor.js +15 -0
  53. package/lib/module/utils/shallowEqualColor.js.map +1 -0
  54. package/lib/typescript/src/core/font/compile.d.ts +13 -2
  55. package/lib/typescript/src/core/font/compile.d.ts.map +1 -1
  56. package/lib/typescript/src/core/pipeline/managers.d.ts.map +1 -1
  57. package/lib/typescript/src/core/pipeline/run.d.ts.map +1 -1
  58. package/lib/typescript/src/core/svg/layers.d.ts +16 -0
  59. package/lib/typescript/src/core/svg/layers.d.ts.map +1 -1
  60. package/lib/typescript/src/core/svg/svg_dom.d.ts +29 -1
  61. package/lib/typescript/src/core/svg/svg_dom.d.ts.map +1 -1
  62. package/lib/typescript/src/core/svg/svg_pathops.d.ts +11 -0
  63. package/lib/typescript/src/core/svg/svg_pathops.d.ts.map +1 -1
  64. package/lib/typescript/src/core/types.d.ts +30 -15
  65. package/lib/typescript/src/core/types.d.ts.map +1 -1
  66. package/lib/typescript/src/createNanoIconsSet.d.ts +5 -18
  67. package/lib/typescript/src/createNanoIconsSet.d.ts.map +1 -1
  68. package/lib/typescript/src/createNanoIconsSet.native.d.ts +7 -0
  69. package/lib/typescript/src/createNanoIconsSet.native.d.ts.map +1 -0
  70. package/lib/typescript/src/createNanoIconsSet.shared.d.ts +11 -0
  71. package/lib/typescript/src/createNanoIconsSet.shared.d.ts.map +1 -0
  72. package/lib/typescript/src/index.d.ts.map +1 -1
  73. package/lib/typescript/src/specs/NanoIconViewNativeComponent.d.ts +14 -0
  74. package/lib/typescript/src/specs/NanoIconViewNativeComponent.d.ts.map +1 -0
  75. package/lib/typescript/src/types.d.ts +19 -0
  76. package/lib/typescript/src/types.d.ts.map +1 -0
  77. package/lib/typescript/src/utils/shallowEqualColor.d.ts +4 -0
  78. package/lib/typescript/src/utils/shallowEqualColor.d.ts.map +1 -0
  79. package/package.json +22 -5
  80. package/plugin/src/buildFonts.ts +13 -0
  81. package/plugin/src/index.ts +3 -50
  82. package/plugin/src/withNanoIconsFontLinking.ts +22 -24
  83. package/react-native-nano-icons.podspec +18 -0
  84. package/scripts/cli.ts +14 -5
  85. package/src/core/font/compile.ts +65 -61
  86. package/src/core/pipeline/managers.ts +26 -3
  87. package/src/core/pipeline/run.ts +156 -38
  88. package/src/core/shims/picosvg-0.22.3-py3-none-any.whl +0 -0
  89. package/src/core/svg/layers.ts +44 -0
  90. package/src/core/svg/svg_dom.ts +96 -4
  91. package/src/core/svg/svg_pathops.ts +245 -27
  92. package/src/core/types.ts +21 -10
  93. package/src/createNanoIconsSet.native.tsx +108 -0
  94. package/src/createNanoIconsSet.shared.tsx +121 -0
  95. package/src/createNanoIconsSet.tsx +7 -126
  96. package/src/index.ts +1 -2
  97. package/src/specs/NanoIconViewNativeComponent.ts +15 -0
  98. package/src/types.ts +27 -0
  99. package/src/utils/shallowEqualColor.ts +17 -0
@@ -1,13 +1,15 @@
1
- import fs from 'node:fs';
2
1
  import fsp from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
 
5
- import { compileTtfFromGlyphSVGs } from '../font/compile.js';
6
- import { picoFromFile } from './managers.js';
4
+ import {
5
+ compileTtfFromGlyphs,
6
+ parseCompileTtfFromGlyphsError,
7
+ type FontGlyph,
8
+ } from '../font/compile.js';
9
+ import { picoFromFile, PathKitManager } from './managers.js';
7
10
 
8
11
  import {
9
12
  ensureDir,
10
- ensureEmptyDir,
11
13
  type PipelineConfig,
12
14
  type PipelinePaths,
13
15
  } from './config.js';
@@ -16,9 +18,15 @@ import {
16
18
  preprocessSvg,
17
19
  shouldSkipPath,
18
20
  validateSvg,
21
+ extractOriginalEvenoddDs,
22
+ restoreOriginalEvenoddDs,
19
23
  } from '../svg/svg_dom.js';
20
- import { computePlacement, writeLayerSvg } from '../svg/layers.js';
21
- import type { GlyphEntry, NanoGlyphMap } from '../types.js';
24
+ import { computePlacement, transformPathForFont } from '../svg/layers.js';
25
+ import { convertEvenoddToWinding } from '../svg/svg_pathops.js';
26
+ import type {
27
+ GlyphLayer,
28
+ NanoGlyphMap,
29
+ } from '../types.js';
22
30
  import type { NanoLogger } from '../types.js';
23
31
 
24
32
  export type PipelineResult = {
@@ -26,6 +34,78 @@ export type PipelineResult = {
26
34
  glyphmapPath: string;
27
35
  };
28
36
 
37
+ // ---------------------------------------------------------------------------
38
+ // Same-color path merging
39
+ // ---------------------------------------------------------------------------
40
+
41
+ type ParsedPath = { d: string; fill: string | null; fillRule?: 'evenodd'; noMerge?: boolean };
42
+
43
+ /**
44
+ * Concatenate multiple SVG path `d` strings into a single compound path.
45
+ * This preserves the exact geometry of each path (no boolean operations)
46
+ * while combining them into one glyph. Under nonzero winding, this renders
47
+ * identically to drawing each path separately with the same color.
48
+ */
49
+ function concatPathDs(ds: string[]): string | null {
50
+ if (ds.length === 0) return null;
51
+ if (ds.length === 1) return ds[0]!;
52
+ return ds.join(' ');
53
+ }
54
+
55
+ /**
56
+ * Merge consecutive same-color paths into single compound paths via boolean UNION.
57
+ * Preserves z-order: only merges runs of adjacent paths with identical fill color.
58
+ */
59
+ function mergeSameColorPaths(
60
+ paths: ParsedPath[],
61
+ logger?: NanoLogger
62
+ ): ParsedPath[] {
63
+ if (paths.length <= 1) return paths;
64
+
65
+ const result: ParsedPath[] = [];
66
+ let i = 0;
67
+
68
+ while (i < paths.length) {
69
+ const fill = paths[i]!.fill;
70
+
71
+ // Find consecutive run of same fill that are all mergeable.
72
+ // Paths converted from evenodd have compound hole structure and must not
73
+ // be merged — their CW hole contours would cancel CCW contours from
74
+ // adjacent paths, producing incorrect fill.
75
+ let j = i + 1;
76
+ if (!paths[i]!.noMerge) {
77
+ while (
78
+ j < paths.length &&
79
+ paths[j]!.fill === fill &&
80
+ !paths[j]!.noMerge
81
+ ) {
82
+ j++;
83
+ }
84
+ }
85
+
86
+ if (j - i === 1) {
87
+ result.push(paths[i]!);
88
+ } else {
89
+ const group = paths.slice(i, j);
90
+ const merged = concatPathDs(group.map((p) => p.d));
91
+ if (merged) {
92
+ logger?.info(
93
+ ` ⊕ Merged ${group.length} same-color paths (fill=${fill})`
94
+ );
95
+ result.push({ d: merged, fill });
96
+ } else {
97
+ result.push(...group);
98
+ }
99
+ }
100
+ i = j;
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Pipeline
107
+ // ---------------------------------------------------------------------------
108
+
29
109
  /**
30
110
  * Run the font pipeline with given config and paths.
31
111
  * Uses the singleton Pyodide/PathKit instance (initialized on first call).
@@ -40,7 +120,6 @@ export async function runPipeline(
40
120
 
41
121
  logger?.update(`Building "${config.fontFamily}"…`);
42
122
 
43
- ensureEmptyDir(paths.tempDir);
44
123
  ensureDir(paths.outputDir);
45
124
 
46
125
  const files = (await fsp.readdir(paths.inputDir)).filter((f) =>
@@ -48,16 +127,20 @@ export async function runPipeline(
48
127
  );
49
128
 
50
129
  const glyphMap: NanoGlyphMap = {
51
- meta: {
52
- fontFamily: config.fontFamily,
53
- upm: config.upm,
54
- safeZone: config.safeZone,
55
- startUnicode: config.startUnicode,
130
+ m: {
131
+ f: config.fontFamily,
132
+ u: config.upm,
133
+ z: config.safeZone,
134
+ s: config.startUnicode,
56
135
  },
57
- icons: {},
136
+ i: {},
58
137
  };
59
138
 
60
139
  let currentUnicode = config.startUnicode;
140
+ const codepointToIcon = new Map<number, string>();
141
+ const allGlyphs: FontGlyph[] = [];
142
+
143
+ const PathKit = await PathKitManager.getInstance();
61
144
 
62
145
  for (const file of files) {
63
146
  const iconName = path.parse(file).name;
@@ -76,8 +159,40 @@ export async function runPipeline(
76
159
  }
77
160
 
78
161
  const preprocessed = preprocessSvg(rawContent);
162
+
163
+ // Preserve original evenodd `d` strings BEFORE picosvg processes them.
164
+ // Picosvg's simplify (via our PathKit shim) can drop contours from
165
+ // multi-subpath evenodd paths — we restore the originals after.
166
+ const originalEvenoddDs = extractOriginalEvenoddDs(preprocessed);
167
+
79
168
  const flattenedSvg = await picoFromFile(filePath, preprocessed);
80
- const parsed = parseFlattenedSvg(flattenedSvg);
169
+ const parsed = parseFlattenedSvg(flattenedSvg, {
170
+ onSanitize: (original) => {
171
+ logger?.info(
172
+ ` ⚠ Sanitized path in "${file}": path was missing initial moveto (prepended M from endpoint)`
173
+ );
174
+ logger?.info(` Original: ${original.slice(0, 80)}…`);
175
+ },
176
+ });
177
+
178
+ // Restore original evenodd path data (undamaged by picosvg's simplify),
179
+ // then convert to nonzero winding with our containment-based algorithm.
180
+ // Mark as noMerge — compound paths with holes must stay separate so their
181
+ // CW hole contours don't cancel adjacent paths' CCW contours.
182
+ if (originalEvenoddDs.length > 0) {
183
+ restoreOriginalEvenoddDs(parsed.paths, originalEvenoddDs);
184
+ }
185
+ for (const p of parsed.paths) {
186
+ if (p.fillRule === 'evenodd') {
187
+ logger?.info(` ↻ Converting evenodd path to nonzero winding in "${file}"`);
188
+ p.d = convertEvenoddToWinding(PathKit, p.d);
189
+ delete p.fillRule;
190
+ (p as ParsedPath).noMerge = true;
191
+ }
192
+ }
193
+
194
+ // Merge consecutive same-color paths into single compound glyphs
195
+ const mergedPaths = mergeSameColorPaths(parsed.paths, logger);
81
196
 
82
197
  const { vx, vy, scale, xOff, yOff, adv } = computePlacement({
83
198
  upm: config.upm,
@@ -85,31 +200,34 @@ export async function runPipeline(
85
200
  viewBox: parsed.viewBox,
86
201
  });
87
202
 
88
- const entry: GlyphEntry = { adv, layers: [] };
203
+ const layers: GlyphLayer[] = [];
89
204
 
90
- for (const p of parsed.paths) {
205
+ for (const p of mergedPaths) {
91
206
  if (shouldSkipPath(p.d, p.fill)) continue;
92
207
 
93
208
  const cp = currentUnicode++;
209
+ codepointToIcon.set(cp, iconName);
94
210
 
95
- await writeLayerSvg({
96
- tempDir: paths.tempDir,
97
- upm: config.upm,
98
- adv,
211
+ const fontD = transformPathForFont(PathKit, p.d, {
99
212
  vx,
100
213
  vy,
101
214
  scale,
102
215
  xOff,
103
216
  yOff,
104
- d: p.d,
217
+ upm: config.upm,
218
+ });
219
+
220
+ allGlyphs.push({
105
221
  codepoint: cp,
222
+ advanceWidth: adv,
223
+ d: fontD,
106
224
  });
107
225
 
108
- entry.layers.push({ codepoint: cp, color: p.fill || 'black' });
226
+ layers.push([cp, p.fill || 'black']);
109
227
  }
110
228
 
111
- if (entry.layers.length > 0) {
112
- glyphMap.icons[iconName] = entry;
229
+ if (layers.length > 0) {
230
+ glyphMap.i[iconName] = [adv, layers];
113
231
  }
114
232
  }
115
233
 
@@ -119,27 +237,27 @@ export async function runPipeline(
119
237
  );
120
238
 
121
239
  if (options?.inputHash) {
122
- glyphMap.meta.hash = options.inputHash;
240
+ glyphMap.m.h = options.inputHash;
123
241
  }
124
- await fsp.writeFile(glyphmapPath, JSON.stringify(glyphMap, null, 2), 'utf8');
242
+ await fsp.writeFile(glyphmapPath, JSON.stringify(glyphMap), 'utf8');
125
243
 
126
244
  logger?.info(`Compiling TTF…`);
127
245
  const ttfPath = path.join(paths.outputDir, `${config.fontFamily}.ttf`);
128
246
 
129
- await compileTtfFromGlyphSVGs({
130
- glyphDir: paths.tempDir,
131
- outTtfPath: ttfPath,
132
- fontName: config.fontFamily,
133
- upm: config.upm,
134
- ascent: config.upm,
135
- descent: 0,
136
- });
137
-
138
- if (fs.existsSync(paths.tempDir)) {
139
- fs.rmSync(paths.tempDir, { recursive: true, force: true });
247
+ try {
248
+ await compileTtfFromGlyphs({
249
+ glyphs: allGlyphs,
250
+ outTtfPath: ttfPath,
251
+ fontName: config.fontFamily,
252
+ upm: config.upm,
253
+ ascent: config.upm,
254
+ descent: 0,
255
+ });
256
+ } catch (err: unknown) {
257
+ parseCompileTtfFromGlyphsError(err, codepointToIcon);
140
258
  }
141
259
 
142
- const iconCount = Object.keys(glyphMap.icons).length;
260
+ const iconCount = Object.keys(glyphMap.i).length;
143
261
  const elapsed = Date.now() - startTime;
144
262
  logger?.succeed(
145
263
  `Built ${config.fontFamily}.ttf [${iconCount} icon${
@@ -1,6 +1,8 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import type { PathKitModule } from '../types.js';
5
+
4
6
  function roundInt(n: number): number {
5
7
  return Math.round(n);
6
8
  }
@@ -64,3 +66,45 @@ export async function writeLayerSvg(opts: {
64
66
 
65
67
  await fsp.writeFile(path.join(opts.tempDir, `u${hex}.svg`), layerSvg, 'utf8');
66
68
  }
69
+
70
+ /**
71
+ * Transform an SVG path `d` string from source SVG coordinates into font
72
+ * glyph coordinates (Y-up, with placement scaling and centering applied).
73
+ *
74
+ * Combines placement transform (translate + scale + translate) with the
75
+ * SVG→font Y-axis flip into a single affine transform applied via PathKit.
76
+ */
77
+ export function transformPathForFont(
78
+ PathKit: PathKitModule,
79
+ d: string,
80
+ opts: {
81
+ vx: number;
82
+ vy: number;
83
+ scale: number;
84
+ xOff: number;
85
+ yOff: number;
86
+ upm: number;
87
+ }
88
+ ): string {
89
+ const { vx, vy, scale, xOff, yOff, upm } = opts;
90
+
91
+ const p = PathKit.FromSVGString(d);
92
+ if (!p) return d;
93
+
94
+ // Combined affine: placement + Y-flip for font coordinates.
95
+ // x' = scale * (x - vx) + xOff
96
+ // y' = upm - (scale * (y - vy) + yOff)
97
+ //
98
+ // SkMatrix row-major: [scaleX, skewX, transX, skewY, scaleY, transY, 0,0,1]
99
+ const scaleX = scale;
100
+ const scaleY = -scale;
101
+ const transX = xOff - vx * scale;
102
+ const transY = upm - yOff + vy * scale;
103
+
104
+ p.transform(scaleX, 0, transX, 0, scaleY, transY, 0, 0, 1);
105
+
106
+ const result = p.toSVGString();
107
+ p.delete?.();
108
+
109
+ return result;
110
+ }
@@ -3,7 +3,7 @@ import { parseColor } from '../../utils/parse';
3
3
 
4
4
  export type ParsedFlatSvg = {
5
5
  viewBox: [number, number, number, number];
6
- paths: Array<{ d: string; fill: string | null }>;
6
+ paths: Array<{ d: string; fill: string | null; fillRule?: 'evenodd' }>;
7
7
  };
8
8
 
9
9
  // if the fill is implicit, walk ancestors for the first explicit fill value
@@ -29,12 +29,43 @@ export function calculateOpColor(
29
29
  return `rgba(${r},${g},${b},${finalAlpha})`;
30
30
  }
31
31
 
32
- export const parsePath = (p: Element): { d: string; fill: string | null } => {
32
+ /**
33
+ * If a flattened path lost its initial moveto (e.g. picosvg dropped an empty
34
+ * `Mx y z` subpath), prepend `M` using the path's last coordinate pair.
35
+ * For closed icon shapes the endpoint equals the start point.
36
+ */
37
+ export function sanitizePathData(d: string): { d: string; sanitized: boolean } {
38
+ const trimmed = d.trim();
39
+ if (!trimmed || /^[Mm]/.test(trimmed)) {
40
+ return { d: trimmed, sanitized: false };
41
+ }
42
+
43
+ // Strip trailing close commands, then grab the last two numbers as x,y
44
+ const withoutClose = trimmed.replace(/[Zz]\s*$/, '');
45
+ const nums = withoutClose.match(/-?\d+(?:\.\d+)?/g);
46
+ if (!nums || nums.length < 2) {
47
+ return { d: trimmed, sanitized: false };
48
+ }
49
+
50
+ const x = nums[nums.length - 2];
51
+ const y = nums[nums.length - 1];
52
+ return { d: `M${x},${y} ${trimmed}`, sanitized: true };
53
+ }
54
+
55
+ export const parsePath = (
56
+ p: Element
57
+ ): { d: string; fill: string | null; fillRule?: 'evenodd' } => {
33
58
  const d = p.getAttribute('d') ?? '';
34
59
 
35
60
  const op = p.getAttribute('opacity');
36
61
  const fillOp = p.getAttribute('fill-opacity');
37
62
  const fill = p.getAttribute('fill');
63
+ // picosvg may drop fill-rule but preserve clip-rule; treat either as evenodd
64
+ const fillRule =
65
+ p.getAttribute('fill-rule') === 'evenodd' ||
66
+ p.getAttribute('clip-rule') === 'evenodd'
67
+ ? ('evenodd' as const)
68
+ : undefined;
38
69
 
39
70
  if (op !== null || fillOp !== null) {
40
71
  const opVal = op !== null ? parseFloat(op) : 1;
@@ -43,16 +74,21 @@ export const parsePath = (p: Element): { d: string; fill: string | null } => {
43
74
  return {
44
75
  d,
45
76
  fill: calculateOpColor(fill, combinedOpacity, p),
77
+ fillRule,
46
78
  };
47
79
  }
48
80
 
49
81
  return {
50
82
  d,
51
83
  fill,
84
+ fillRule,
52
85
  };
53
86
  };
54
87
 
55
- export function parseFlattenedSvg(flattenedSvg: string): ParsedFlatSvg {
88
+ export function parseFlattenedSvg(
89
+ flattenedSvg: string,
90
+ options?: { onSanitize?: (original: string) => void }
91
+ ): ParsedFlatSvg {
56
92
  const dom = new JSDOM(flattenedSvg);
57
93
  const doc = dom.window.document;
58
94
 
@@ -69,7 +105,16 @@ export function parseFlattenedSvg(flattenedSvg: string): ParsedFlatSvg {
69
105
 
70
106
  const pathEls = Array.from(doc.querySelectorAll('path'));
71
107
 
72
- const paths = pathEls.map(parsePath).filter((p) => p.d.trim() !== '');
108
+ const paths = pathEls
109
+ .map(parsePath)
110
+ .filter((p) => p.d.trim() !== '')
111
+ .map((p) => {
112
+ const { d, sanitized } = sanitizePathData(p.d);
113
+ if (sanitized) {
114
+ options?.onSanitize?.(p.d);
115
+ }
116
+ return { ...p, d };
117
+ });
73
118
 
74
119
  return { viewBox, paths };
75
120
  }
@@ -92,8 +137,55 @@ export function validateSvg(content: string): SvgValidation {
92
137
  return { valid: true };
93
138
  }
94
139
 
140
+ /**
141
+ * Extract the original `d` strings of evenodd paths from the raw SVG
142
+ * BEFORE picosvg processes it. Picosvg's simplify (via our PathKit shim)
143
+ * can drop contours from multi-subpath evenodd paths, so we preserve
144
+ * the originals and apply our own winding conversion later.
145
+ *
146
+ * Returns one `d` string per evenodd path, in document order.
147
+ */
148
+ export function extractOriginalEvenoddDs(svgContent: string): string[] {
149
+ if (!/<[^>]*fill-rule\s*=\s*["']evenodd/i.test(svgContent)) {
150
+ return [];
151
+ }
152
+
153
+ const dom = new JSDOM(svgContent, { contentType: 'image/svg+xml' });
154
+ const doc = dom.window.document;
155
+ const results: string[] = [];
156
+
157
+ const pathEls = doc.querySelectorAll(
158
+ 'path[fill-rule="evenodd"], path[clip-rule="evenodd"]'
159
+ );
160
+ for (const el of pathEls) {
161
+ const d = el.getAttribute('d');
162
+ if (d) results.push(d);
163
+ }
164
+ return results;
165
+ }
166
+
167
+ /**
168
+ * Replace picosvg's (potentially damaged) evenodd path data with the
169
+ * preserved originals. Matches by position: the Nth evenodd path in
170
+ * the parsed output gets the Nth original `d` string.
171
+ */
172
+ export function restoreOriginalEvenoddDs(
173
+ paths: ParsedFlatSvg['paths'],
174
+ originalDs: string[]
175
+ ): void {
176
+ let oi = 0;
177
+ for (const p of paths) {
178
+ if (p.fillRule === 'evenodd' && oi < originalDs.length) {
179
+ p.d = originalDs[oi]!;
180
+ oi++;
181
+ }
182
+ }
183
+ }
184
+
95
185
  // ensure the svg has a xmlns attribute
96
186
  export function preprocessSvg(content: string): string {
97
187
  if (/xmlns\s*=/.test(content)) return content;
98
188
  return content.replace(/<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"');
99
189
  }
190
+
191
+