react-native-nano-icons 0.1.2 → 0.1.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.
Files changed (91) 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 +286 -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/scripts/cli.js +15 -5
  12. package/lib/commonjs/src/core/font/compile.d.ts +13 -2
  13. package/lib/commonjs/src/core/font/compile.js +49 -46
  14. package/lib/commonjs/src/core/pipeline/managers.js +19 -3
  15. package/lib/commonjs/src/core/pipeline/run.js +121 -32
  16. package/lib/commonjs/src/core/svg/layers.d.ts +16 -0
  17. package/lib/commonjs/src/core/svg/layers.js +27 -0
  18. package/lib/commonjs/src/core/svg/svg_dom.d.ts +29 -1
  19. package/lib/commonjs/src/core/svg/svg_dom.js +78 -2
  20. package/lib/commonjs/src/core/svg/svg_pathops.d.ts +11 -0
  21. package/lib/commonjs/src/core/svg/svg_pathops.js +209 -19
  22. package/lib/commonjs/src/core/types.d.ts +30 -15
  23. package/lib/module/core/font/compile.js +52 -41
  24. package/lib/module/core/font/compile.js.map +1 -1
  25. package/lib/module/core/pipeline/managers.js +17 -3
  26. package/lib/module/core/pipeline/managers.js.map +1 -1
  27. package/lib/module/core/pipeline/run.js +131 -44
  28. package/lib/module/core/pipeline/run.js.map +1 -1
  29. package/lib/module/core/shims/picosvg-0.22.3-py3-none-any.whl +0 -0
  30. package/lib/module/core/svg/layers.js +34 -0
  31. package/lib/module/core/svg/layers.js.map +1 -1
  32. package/lib/module/core/svg/svg_dom.js +91 -4
  33. package/lib/module/core/svg/svg_dom.js.map +1 -1
  34. package/lib/module/core/svg/svg_pathops.js +203 -19
  35. package/lib/module/core/svg/svg_pathops.js.map +1 -1
  36. package/lib/module/createNanoIconsSet.js +3 -79
  37. package/lib/module/createNanoIconsSet.js.map +1 -1
  38. package/lib/module/createNanoIconsSet.native.js +108 -0
  39. package/lib/module/createNanoIconsSet.native.js.map +1 -0
  40. package/lib/module/createNanoIconsSet.shared.js +91 -0
  41. package/lib/module/createNanoIconsSet.shared.js.map +1 -0
  42. package/lib/module/index.js +1 -2
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/specs/NanoIconViewNativeComponent.ts +15 -0
  45. package/lib/module/types.js +4 -0
  46. package/lib/module/types.js.map +1 -0
  47. package/lib/module/utils/shallowEqualColor.js +15 -0
  48. package/lib/module/utils/shallowEqualColor.js.map +1 -0
  49. package/lib/typescript/src/core/font/compile.d.ts +13 -2
  50. package/lib/typescript/src/core/font/compile.d.ts.map +1 -1
  51. package/lib/typescript/src/core/pipeline/managers.d.ts.map +1 -1
  52. package/lib/typescript/src/core/pipeline/run.d.ts.map +1 -1
  53. package/lib/typescript/src/core/svg/layers.d.ts +16 -0
  54. package/lib/typescript/src/core/svg/layers.d.ts.map +1 -1
  55. package/lib/typescript/src/core/svg/svg_dom.d.ts +29 -1
  56. package/lib/typescript/src/core/svg/svg_dom.d.ts.map +1 -1
  57. package/lib/typescript/src/core/svg/svg_pathops.d.ts +11 -0
  58. package/lib/typescript/src/core/svg/svg_pathops.d.ts.map +1 -1
  59. package/lib/typescript/src/core/types.d.ts +30 -15
  60. package/lib/typescript/src/core/types.d.ts.map +1 -1
  61. package/lib/typescript/src/createNanoIconsSet.d.ts +5 -18
  62. package/lib/typescript/src/createNanoIconsSet.d.ts.map +1 -1
  63. package/lib/typescript/src/createNanoIconsSet.native.d.ts +7 -0
  64. package/lib/typescript/src/createNanoIconsSet.native.d.ts.map +1 -0
  65. package/lib/typescript/src/createNanoIconsSet.shared.d.ts +11 -0
  66. package/lib/typescript/src/createNanoIconsSet.shared.d.ts.map +1 -0
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/specs/NanoIconViewNativeComponent.d.ts +14 -0
  69. package/lib/typescript/src/specs/NanoIconViewNativeComponent.d.ts.map +1 -0
  70. package/lib/typescript/src/types.d.ts +19 -0
  71. package/lib/typescript/src/types.d.ts.map +1 -0
  72. package/lib/typescript/src/utils/shallowEqualColor.d.ts +4 -0
  73. package/lib/typescript/src/utils/shallowEqualColor.d.ts.map +1 -0
  74. package/package.json +22 -5
  75. package/react-native-nano-icons.podspec +18 -0
  76. package/scripts/cli.ts +14 -5
  77. package/src/core/font/compile.ts +65 -61
  78. package/src/core/pipeline/managers.ts +26 -3
  79. package/src/core/pipeline/run.ts +156 -38
  80. package/src/core/shims/picosvg-0.22.3-py3-none-any.whl +0 -0
  81. package/src/core/svg/layers.ts +44 -0
  82. package/src/core/svg/svg_dom.ts +96 -4
  83. package/src/core/svg/svg_pathops.ts +245 -27
  84. package/src/core/types.ts +21 -10
  85. package/src/createNanoIconsSet.native.tsx +140 -0
  86. package/src/createNanoIconsSet.shared.tsx +121 -0
  87. package/src/createNanoIconsSet.tsx +7 -126
  88. package/src/index.ts +1 -2
  89. package/src/specs/NanoIconViewNativeComponent.ts +15 -0
  90. package/src/types.ts +27 -0
  91. package/src/utils/shallowEqualColor.ts +17 -0
@@ -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
+
@@ -457,9 +457,13 @@ function bestStartMinYMinX(
457
457
  return best;
458
458
  }
459
459
 
460
- // proxy to for picosvg to interatc with pathkit
461
- export function buildPathopsBackend(PathKit: PathKitModule) {
462
- const VERB: VerbMap = {
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Containment helpers (module-level for reuse by fixPathWinding)
463
+ // ---------------------------------------------------------------------------
464
+
465
+ function buildVerbMap(PathKit: PathKitModule): VerbMap {
466
+ return {
463
467
  MOVE: PathKit.MOVE_VERB ?? 0,
464
468
  LINE: PathKit.LINE_VERB ?? 1,
465
469
  QUAD: PathKit.QUAD_VERB ?? 2,
@@ -467,6 +471,241 @@ export function buildPathopsBackend(PathKit: PathKitModule) {
467
471
  CUBIC: PathKit.CUBIC_VERB ?? 4,
468
472
  CLOSE: PathKit.CLOSE_VERB ?? 5,
469
473
  };
474
+ }
475
+
476
+ /**
477
+ * Convert contour commands to a polyline by sampling curves.
478
+ * Used for ray-casting containment tests.
479
+ */
480
+ function contourToPolyline(
481
+ contourCmds: readonly Cmd[],
482
+ V: VerbMap,
483
+ steps = 8
484
+ ): Point[] {
485
+ let cx = 0,
486
+ cy = 0;
487
+ let sx = 0,
488
+ sy = 0;
489
+ const pts: Point[] = [];
490
+
491
+ for (const cmd of contourCmds) {
492
+ const v = cmd[0]!;
493
+ if (v === V.MOVE) {
494
+ cx = cmd[1]!;
495
+ cy = cmd[2]!;
496
+ sx = cx;
497
+ sy = cy;
498
+ pts.push([cx, cy]);
499
+ } else if (v === V.LINE) {
500
+ cx = cmd[1]!;
501
+ cy = cmd[2]!;
502
+ pts.push([cx, cy]);
503
+ } else if (v === V.QUAD) {
504
+ const x0 = cx,
505
+ y0 = cy;
506
+ const x1 = cmd[1]!,
507
+ y1 = cmd[2]!;
508
+ const x2 = cmd[3]!,
509
+ y2 = cmd[4]!;
510
+ for (let i = 1; i <= steps; i++) {
511
+ const t = i / steps;
512
+ const mt = 1 - t;
513
+ pts.push([mt * mt * x0 + 2 * mt * t * x1 + t * t * x2, mt * mt * y0 + 2 * mt * t * y1 + t * t * y2]);
514
+ }
515
+ cx = x2;
516
+ cy = y2;
517
+ } else if (v === V.CUBIC) {
518
+ const x0 = cx,
519
+ y0 = cy;
520
+ const x1 = cmd[1]!,
521
+ y1 = cmd[2]!;
522
+ const x2 = cmd[3]!,
523
+ y2 = cmd[4]!;
524
+ const x3 = cmd[5]!,
525
+ y3 = cmd[6]!;
526
+ for (let i = 1; i <= steps; i++) {
527
+ const t = i / steps;
528
+ const mt = 1 - t;
529
+ pts.push([
530
+ mt * mt * mt * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x3,
531
+ mt * mt * mt * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * y3,
532
+ ]);
533
+ }
534
+ cx = x3;
535
+ cy = y3;
536
+ } else if (v === V.CLOSE) {
537
+ if (cx !== sx || cy !== sy) pts.push([sx, sy]);
538
+ cx = sx;
539
+ cy = sy;
540
+ }
541
+ }
542
+ return pts;
543
+ }
544
+
545
+ /**
546
+ * Ray-casting point-in-polygon test.
547
+ */
548
+ function pointInPolygon(px: number, py: number, poly: Point[]): boolean {
549
+ let inside = false;
550
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
551
+ const [xi, yi] = poly[i]!;
552
+ const [xj, yj] = poly[j]!;
553
+ if (
554
+ yi > py !== yj > py &&
555
+ px < ((xj - xi) * (py - yi)) / (yj - yi) + xi
556
+ ) {
557
+ inside = !inside;
558
+ }
559
+ }
560
+ return inside;
561
+ }
562
+
563
+ /**
564
+ * Get a representative point on the contour boundary (midpoint of first segment).
565
+ */
566
+ function getContourSamplePoint(contourCmds: readonly Cmd[], V: VerbMap): Point | null {
567
+ let cx = 0,
568
+ cy = 0;
569
+ for (const cmd of contourCmds) {
570
+ const v = cmd[0]!;
571
+ if (v === V.MOVE) {
572
+ cx = cmd[1]!;
573
+ cy = cmd[2]!;
574
+ } else if (v === V.LINE) {
575
+ return [(cx + cmd[1]!) / 2, (cy + cmd[2]!) / 2];
576
+ } else if (v === V.QUAD) {
577
+ const t = 0.5,
578
+ mt = 0.5;
579
+ return [
580
+ mt * mt * cx + 2 * mt * t * cmd[1]! + t * t * cmd[3]!,
581
+ mt * mt * cy + 2 * mt * t * cmd[2]! + t * t * cmd[4]!,
582
+ ];
583
+ } else if (v === V.CUBIC) {
584
+ const t = 0.5,
585
+ mt = 0.5;
586
+ return [
587
+ mt ** 3 * cx + 3 * mt ** 2 * t * cmd[1]! + 3 * mt * t ** 2 * cmd[3]! + t ** 3 * cmd[5]!,
588
+ mt ** 3 * cy + 3 * mt ** 2 * t * cmd[2]! + 3 * mt * t ** 2 * cmd[4]! + t ** 3 * cmd[6]!,
589
+ ];
590
+ }
591
+ }
592
+ return null;
593
+ }
594
+
595
+ /**
596
+ * Apply containment-based winding fix to contour objects.
597
+ * Even nesting depth = CCW (outer), odd = CW (hole).
598
+ */
599
+ function applyContainmentWinding(
600
+ contourObjs: Array<{ cmds: Cmd[]; explicitCloseWanted: boolean; absA: number }>,
601
+ V: VerbMap
602
+ ): void {
603
+ const n = contourObjs.length;
604
+ if (n === 0) return;
605
+
606
+ const polylines = contourObjs.map((obj) => contourToPolyline(obj.cmds, V));
607
+ const samplePts = contourObjs.map((obj) => getContourSamplePoint(obj.cmds, V));
608
+ const depths = new Array<number>(n).fill(0);
609
+
610
+ for (let i = 0; i < n; i++) {
611
+ const pt = samplePts[i];
612
+ if (!pt) continue;
613
+ for (let j = 0; j < n; j++) {
614
+ if (i === j) continue;
615
+ if (pointInPolygon(pt[0], pt[1], polylines[j]!)) {
616
+ depths[i] = (depths[i] ?? 0) + 1;
617
+ }
618
+ }
619
+ }
620
+
621
+ const ensureOrient = (
622
+ obj: { cmds: Cmd[]; explicitCloseWanted: boolean },
623
+ wantCCW: boolean
624
+ ) => {
625
+ const a = approxSignedAreaFromContourCmds(obj.cmds, V);
626
+ const isCCW = a > 0;
627
+ if (wantCCW !== isCCW) {
628
+ obj.cmds = reverseClosedContourKeepStart(obj.cmds, obj.explicitCloseWanted, V);
629
+ }
630
+ };
631
+
632
+ for (let i = 0; i < n; i++) {
633
+ ensureOrient(contourObjs[i]!, depths[i]! % 2 === 0);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Convert a path `d` string with evenodd fill semantics to an equivalent
639
+ * path that renders identically under nonzero winding.
640
+ *
641
+ * Steps:
642
+ * 1. Parse via PathKit, set fill type to EVENODD, simplify (resolve topology)
643
+ * 2. Split into contours, compute containment depths
644
+ * 3. Fix winding: even depth = CCW (outer), odd depth = CW (hole)
645
+ * 4. Reconstruct d string
646
+ */
647
+ export function convertEvenoddToWinding(
648
+ PathKit: PathKitModule,
649
+ d: string
650
+ ): string {
651
+ const V = buildVerbMap(PathKit);
652
+
653
+ // 1. Parse and simplify with EVENODD fill type
654
+ const p = PathKit.FromSVGString(d);
655
+ if (!p) return d;
656
+
657
+ const FILL_EVENODD =
658
+ PathKit?.FillType?.EVENODD ?? PathKit?.FillType?.EVEN_ODD ?? 1;
659
+ p.setFillType(FILL_EVENODD);
660
+ p.simplify();
661
+
662
+ // Get the simplified SVG string and re-parse for command access
663
+ const simplified = p.toSVGString();
664
+ p.delete?.();
665
+
666
+ const p2 = PathKit.FromSVGString(simplified);
667
+ if (!p2) return simplified;
668
+
669
+ const cmds: Cmd[] = p2.toCmds();
670
+ p2.delete?.();
671
+
672
+ if (cmds.length === 0) return simplified;
673
+
674
+ // 2. Split into contours
675
+ const contourObjs = splitContours(cmds, V).map((c) => {
676
+ const explicitCloseWanted = explicitCloseWantedFromCmds(c, V);
677
+ const cc = ensureClosed(c, V);
678
+ const a = approxSignedAreaFromContourCmds(cc, V);
679
+ return { cmds: cc, absA: Math.abs(a), explicitCloseWanted };
680
+ });
681
+
682
+ contourObjs.sort((x, y) => y.absA - x.absA);
683
+
684
+ // 3. Fix winding via containment analysis
685
+ applyContainmentWinding(contourObjs, V);
686
+
687
+ // 4. Reconstruct d string from fixed commands
688
+ const allCmds = contourObjs.flatMap((x) => x.cmds);
689
+ const parts: string[] = [];
690
+ for (const cmd of allCmds) {
691
+ const v = cmd[0]!;
692
+ if (v === V.MOVE) parts.push(`M${roundN(cmd[1]!)} ${roundN(cmd[2]!)}`);
693
+ else if (v === V.LINE) parts.push(`L${roundN(cmd[1]!)} ${roundN(cmd[2]!)}`);
694
+ else if (v === V.QUAD)
695
+ parts.push(`Q${roundN(cmd[1]!)} ${roundN(cmd[2]!)} ${roundN(cmd[3]!)} ${roundN(cmd[4]!)}`);
696
+ else if (v === V.CUBIC)
697
+ parts.push(
698
+ `C${roundN(cmd[1]!)} ${roundN(cmd[2]!)} ${roundN(cmd[3]!)} ${roundN(cmd[4]!)} ${roundN(cmd[5]!)} ${roundN(cmd[6]!)}`
699
+ );
700
+ else if (v === V.CLOSE) parts.push('Z');
701
+ }
702
+
703
+ return parts.join(' ');
704
+ }
705
+
706
+ // proxy to for picosvg to interatc with pathkit
707
+ export function buildPathopsBackend(PathKit: PathKitModule) {
708
+ const VERB: VerbMap = buildVerbMap(PathKit);
470
709
 
471
710
  const FILL_EVENODD =
472
711
  PathKit?.FillType?.EVENODD ?? PathKit?.FillType?.EVEN_ODD ?? 1;
@@ -500,30 +739,9 @@ export function buildPathopsBackend(PathKit: PathKitModule) {
500
739
  // Big-to-small ordering gives deterministic outer→inner ordering.
501
740
  contourObjs.sort((x, y) => y.absA - x.absA);
502
741
 
503
- // Enforce winding convention:
504
- // - largest contour CCW (positive area)
505
- // - all subsequent contours CW (negative area)
506
- const ensureOrient = (
507
- obj: { cmds: Cmd[]; explicitCloseWanted: boolean },
508
- wantCCW: boolean
509
- ) => {
510
- const a = approxSignedAreaFromContourCmds(obj.cmds, VERB);
511
- const isCCW = a > 0;
512
- if (wantCCW !== isCCW) {
513
- obj.cmds = reverseClosedContourKeepStart(
514
- obj.cmds,
515
- obj.explicitCloseWanted,
516
- VERB
517
- );
518
- }
519
- };
520
-
521
- if (contourObjs.length) {
522
- ensureOrient(contourObjs[0]!, true);
523
- for (let i = 1; i < contourObjs.length; i++) {
524
- ensureOrient(contourObjs[i]!, false);
525
- }
526
- }
742
+ // Containment-based winding: compute nesting depth per contour via
743
+ // ray-casting point-in-polygon. Even depth = outer (CCW), odd = hole (CW).
744
+ applyContainmentWinding(contourObjs, VERB);
527
745
 
528
746
  // Rotate starts deterministically:
529
747
  // 1) If we have recorded MOVE points, try to rotate contour to a move point that lies on it.
package/src/core/types.ts CHANGED
@@ -123,16 +123,27 @@ export type PyodideModule = {
123
123
  };
124
124
  };
125
125
 
126
- export type GlyphLayer = { codepoint: number; color: string };
127
- export type GlyphEntry = { adv: number; layers: GlyphLayer[] };
126
+ export type GlyphLayer = [codepoint: number, color: string];
127
+ export type GlyphEntry = [adv: number, layers: GlyphLayer[]];
128
128
  export type IconsMap = Record<string, GlyphEntry>;
129
+
130
+ /**
131
+ * m - metadata,
132
+ * f - font family,
133
+ * u - units per em,
134
+ * z - safe zone,
135
+ * s - start unicode,
136
+ * h - hash,
137
+ * i - icons,
138
+ * adv - advance width,
139
+ */
129
140
  export type NanoGlyphMap = {
130
- meta: {
131
- fontFamily: string;
132
- upm: number;
133
- safeZone: number;
134
- startUnicode: number;
135
- hash?: string;
136
- };
137
- icons: IconsMap;
141
+ m: { f: string; u: number; z: number; s: number; h?: string };
142
+ i: IconsMap;
143
+ };
144
+
145
+ /** Accepts JSON-inferred types where arrays aren't tuples. */
146
+ export type NanoGlyphMapInput = {
147
+ m: { f: string; u: number; z: number; s: number; h?: string };
148
+ i: Record<string, readonly unknown[]>;
138
149
  };
@@ -0,0 +1,140 @@
1
+ import { memo, useMemo } from 'react';
2
+ import { PixelRatio, UIManager, processColor } from 'react-native';
3
+ import type { NanoGlyphMapInput, GlyphEntry } from './core/types';
4
+ import type { IconComponent, IconProps } from './types';
5
+ import { shallowEqualColor } from './utils/shallowEqualColor';
6
+ import NanoIconViewNative from './specs/NanoIconViewNativeComponent';
7
+ import { createJSIconSet } from './createNanoIconsSet.shared';
8
+
9
+ export type { IconComponent, IconProps };
10
+ export { shallowEqualColor };
11
+
12
+ const DEFAULT_ICON_SIZE = 12;
13
+
14
+ const HAS_NATIVE_IMPL = UIManager.hasViewManagerConfig('NanoIconView');
15
+
16
+ // Shared processColor cache — avoids redundant color parsing for repeated
17
+ // color strings like "black", "rgba(0,0,0,0.3)" across thousands of icons
18
+ const processedColorCache = new Map<string, number>();
19
+ function cachedProcessColor(color: string): number {
20
+ let result = processedColorCache.get(color);
21
+ if (result === undefined) {
22
+ result = (processColor(color) ?? 0xff000000) as number;
23
+ processedColorCache.set(color, result);
24
+ }
25
+ return result;
26
+ }
27
+
28
+ export function createIconSet<GM extends NanoGlyphMapInput>(
29
+ glyphMap: GM
30
+ ): IconComponent<GM> {
31
+ if (!HAS_NATIVE_IMPL) {
32
+ return createJSIconSet(glyphMap);
33
+ }
34
+
35
+ const fontFamilyBasename = glyphMap.m.f;
36
+ const unitsPerEm = glyphMap.m.u;
37
+
38
+ const resolveEntry = (name: keyof GM['i']): GlyphEntry => {
39
+ return (glyphMap.i[name as string] ?? [
40
+ unitsPerEm,
41
+ [[63, 'black']],
42
+ ]) as GlyphEntry;
43
+ };
44
+
45
+ // Pre-compute per-icon static data (codepoints, default colors) once at set creation
46
+ // Avoids layers.map() + processColor per icon mount
47
+ const codepointsCache = new Map<string, readonly number[]>();
48
+ const defaultColorsCache = new Map<string, readonly number[]>();
49
+
50
+ function getCodepoints(name: string, layers: GlyphEntry[1]): readonly number[] {
51
+ let cp = codepointsCache.get(name);
52
+ if (!cp) {
53
+ cp = layers.map(([c]) => c);
54
+ codepointsCache.set(name, cp);
55
+ }
56
+ return cp;
57
+ }
58
+
59
+ function getDefaultColors(name: string, layers: GlyphEntry[1]): readonly number[] {
60
+ let colors = defaultColorsCache.get(name);
61
+ if (!colors) {
62
+ colors = layers.map(([, srcColor]) => cachedProcessColor(srcColor ?? 'black'));
63
+ defaultColorsCache.set(name, colors);
64
+ }
65
+ return colors;
66
+ }
67
+
68
+ const Icon = memo(
69
+ ({
70
+ name,
71
+ size = DEFAULT_ICON_SIZE,
72
+ color,
73
+ style,
74
+ allowFontScaling = true,
75
+ accessible,
76
+ accessibilityLabel,
77
+ accessibilityRole = 'image',
78
+ testID,
79
+ ref,
80
+ }: IconProps<keyof GM['i']>) => {
81
+ const fontScale = allowFontScaling ? PixelRatio.getFontScale() : 1;
82
+ const [adv, layers] = resolveEntry(name);
83
+ const scaledSize = size * fontScale;
84
+ const width = (adv / unitsPerEm) * scaledSize;
85
+
86
+ const nameStr = name as string;
87
+ const codepoints = getCodepoints(nameStr, layers);
88
+
89
+ const processedColors = useMemo(() => {
90
+ // Fast path: no custom color — use pre-computed defaults
91
+ if (color === undefined || color === null) {
92
+ return getDefaultColors(nameStr, layers);
93
+ }
94
+ const colorArray = Array.isArray(color) ? color : [color];
95
+ const lastPaletteColor = colorArray.length
96
+ ? colorArray[colorArray.length - 1]
97
+ : undefined;
98
+ return layers.map(([, srcColor], i) => {
99
+ const layerColor =
100
+ colorArray[i] ?? lastPaletteColor ?? srcColor ?? 'black';
101
+ return cachedProcessColor(layerColor as string);
102
+ });
103
+ }, [nameStr, color]);
104
+
105
+ const nativeStyle = useMemo(
106
+ () => [{ width, height: scaledSize }, style],
107
+ [scaledSize, width, style]
108
+ );
109
+
110
+ return (
111
+ <NanoIconViewNative
112
+ ref={ref}
113
+ fontFamily={fontFamilyBasename}
114
+ codepoints={codepoints}
115
+ colors={processedColors}
116
+ fontSize={size}
117
+ advanceWidth={adv}
118
+ unitsPerEm={unitsPerEm}
119
+ iconWidth={width}
120
+ iconHeight={scaledSize}
121
+ style={nativeStyle}
122
+ accessible={accessible}
123
+ accessibilityRole={accessibilityRole}
124
+ accessibilityLabel={accessibilityLabel ?? (name as string)}
125
+ testID={testID}
126
+ />
127
+ );
128
+ },
129
+ (prev, next) =>
130
+ prev.name === next.name &&
131
+ prev.size === next.size &&
132
+ prev.allowFontScaling === next.allowFontScaling &&
133
+ prev.style === next.style &&
134
+ shallowEqualColor(prev.color, next.color)
135
+ );
136
+
137
+ Icon.displayName = `NanoIcon(${fontFamilyBasename})`;
138
+
139
+ return Icon;
140
+ }