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
package/scripts/cli.ts CHANGED
@@ -2,14 +2,16 @@
2
2
  /**
3
3
  * Bare React Native workflow: build icon fonts and link them into the native project.
4
4
  *
5
- * Run from your app root: npx react-native-nano-icons [--verbose]
5
+ * Run from your app root: npx react-native-nano-icons [--verbose] [--path <dir>]
6
6
  *
7
7
  * Reads .nanoicons.json (same shape as Expo plugin options) so Expo and bare apps
8
8
  * share one config format.
9
9
  *
10
10
  * Flags:
11
- * --verbose Show per-SVG processing details and pipeline timing
11
+ * --verbose Show per-SVG processing details and pipeline timing
12
+ * --path <dir> Directory containing .nanoicons.json (default: cwd)
12
13
  */
14
+ import path from 'node:path';
13
15
  import {
14
16
  createOraLogger,
15
17
  loadNanoIconsConfig,
@@ -21,10 +23,17 @@ async function main(): Promise<void> {
21
23
  const verbose = process.argv.includes('--verbose');
22
24
  const level = verbose ? 'verbose' : 'normal';
23
25
 
26
+ const pathIdx = process.argv.indexOf('--path');
27
+ const projectRoot = process.cwd();
28
+ const configRoot =
29
+ pathIdx !== -1 && process.argv[pathIdx + 1]
30
+ ? path.resolve(projectRoot, process.argv[pathIdx + 1]!)
31
+ : projectRoot;
32
+
24
33
  const logger = await createOraLogger(level);
25
- const config = loadNanoIconsConfig(process.cwd());
26
- const built = await buildAllFonts(config.iconSets, process.cwd(), { logger });
27
- await linkBare(process.cwd(), built, logger);
34
+ const config = loadNanoIconsConfig(configRoot);
35
+ const built = await buildAllFonts(config.iconSets, projectRoot, { logger });
36
+ await linkBare(projectRoot, built, logger);
28
37
  }
29
38
 
30
39
  main().catch((err: unknown) => {
@@ -1,38 +1,74 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { once } from 'node:events';
4
- import { type Transform } from 'node:stream';
5
3
 
6
4
  import { forceTtfMetrics } from './metrics.js';
7
5
  import svg2ttf from 'svg2ttf';
8
- import { parseCodepointFromFilename } from '../../utils/parse.js';
9
6
 
10
- async function writeGlyphStreamToFont(
11
- fontStream: Transform,
12
- svgPath: string,
13
- filename: string
14
- ): Promise<void> {
15
- const codepoint = parseCodepointFromFilename(filename);
16
- const name = path.basename(filename, '.svg');
7
+ export type FontGlyph = {
8
+ codepoint: number;
9
+ advanceWidth: number;
10
+ /** Path data already in font coordinates (Y-up, placement applied). */
11
+ d: string;
12
+ };
17
13
 
18
- return new Promise((resolve, reject) => {
19
- const glyphStream = fs.createReadStream(svgPath);
14
+ function escapeXml(s: string): string {
15
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
16
+ }
20
17
 
21
- (glyphStream as any).metadata = {
22
- name,
23
- unicode: [String.fromCodePoint(codepoint)],
24
- };
18
+ /**
19
+ * Build an SVG font XML string from pre-transformed glyph data.
20
+ */
21
+ function buildSvgFontXml(opts: {
22
+ fontName: string;
23
+ glyphs: FontGlyph[];
24
+ upm: number;
25
+ ascent: number;
26
+ descent: number;
27
+ }): string {
28
+ const { fontName, glyphs, upm, ascent, descent } = opts;
25
29
 
26
- glyphStream.on('error', reject);
27
- // Do not add fontStream.on("error", reject) here — one per glyph would exceed
28
- // Node's default MaxListeners (10). Font stream errors are handled once below.
29
- fontStream.write(glyphStream);
30
- glyphStream.on('end', resolve);
30
+ const glyphLines = glyphs.map((g) => {
31
+ const hex = g.codepoint.toString(16);
32
+ const name = `u${hex.padStart(4, '0')}`;
33
+ return `<glyph glyph-name="${name}" unicode="&#x${hex};" horiz-adv-x="${g.advanceWidth}" d="${escapeXml(g.d)}"/>`;
31
34
  });
35
+
36
+ return `<?xml version="1.0" standalone="no"?>
37
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
38
+ <svg xmlns="http://www.w3.org/2000/svg">
39
+ <defs>
40
+ <font id="${escapeXml(fontName)}" horiz-adv-x="${upm}">
41
+ <font-face font-family="${escapeXml(fontName)}" units-per-em="${upm}" ascent="${ascent}" descent="${-Math.abs(descent)}"/>
42
+ <missing-glyph horiz-adv-x="0"/>
43
+ ${glyphLines.join('\n')}
44
+ </font>
45
+ </defs>
46
+ </svg>`;
47
+ }
48
+
49
+ export function parseCompileTtfFromGlyphsError(
50
+ err: unknown,
51
+ codepointToIcon: Map<number, string>
52
+ ) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ const cpMatch = msg.match(/glyph\s+"u([0-9a-fA-F]+)"/);
55
+ if (cpMatch) {
56
+ const cp = parseInt(cpMatch[1]!, 16);
57
+ const iconName = codepointToIcon.get(cp);
58
+ const detail = iconName
59
+ ? `icon "${iconName}" (codepoint u${cpMatch[1]})`
60
+ : `codepoint u${cpMatch[1]}`;
61
+ throw new Error(`Font compilation failed for ${detail}: ${msg}`);
62
+ }
63
+ throw err;
32
64
  }
33
65
 
34
- export async function compileTtfFromGlyphSVGs(opts: {
35
- glyphDir: string;
66
+ /**
67
+ * Compile a TTF font from pre-transformed glyph data.
68
+ * Builds SVG font XML directly (no intermediate files), then converts via svg2ttf.
69
+ */
70
+ export async function compileTtfFromGlyphs(opts: {
71
+ glyphs: FontGlyph[];
36
72
  outTtfPath: string;
37
73
  fontName: string;
38
74
  upm: number;
@@ -40,52 +76,20 @@ export async function compileTtfFromGlyphSVGs(opts: {
40
76
  descent: number;
41
77
  lineGap?: number;
42
78
  }): Promise<void> {
43
- const { SVGIcons2SVGFontStream } = await import('svgicons2svgfont');
44
-
45
- const { glyphDir, outTtfPath, fontName, upm, ascent, descent } = opts;
79
+ const { glyphs, outTtfPath, fontName, upm, ascent, descent } = opts;
46
80
  const lineGap = opts.lineGap ?? 0;
47
81
 
48
- const files = fs
49
- .readdirSync(glyphDir)
50
- .filter((f) => /^u[0-9a-fA-F]+\.svg$/.test(f))
51
- .sort(
52
- (a, b) => parseCodepointFromFilename(a) - parseCodepointFromFilename(b)
53
- );
54
-
55
- if (files.length === 0)
56
- throw new Error(`No glyph SVGs found in: ${glyphDir}`);
82
+ if (glyphs.length === 0)
83
+ throw new Error('No glyphs to compile');
57
84
 
58
- const fontStream = new SVGIcons2SVGFontStream({
85
+ const svgFontString = buildSvgFontXml({
59
86
  fontName,
60
- fontHeight: upm,
61
- normalize: false,
87
+ glyphs,
88
+ upm,
62
89
  ascent,
63
90
  descent,
64
91
  });
65
92
 
66
- const svgFontChunks: Buffer[] = [];
67
- fontStream.on('data', (c: Buffer | string) =>
68
- svgFontChunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))
69
- );
70
-
71
- // Single error listener for the font stream; per-glyph errors are handled in writeGlyphStreamToFont.
72
- let fontStreamReject: (err: Error) => void;
73
- const fontStreamErrorPromise = new Promise<never>((_, rej) => {
74
- fontStreamReject = rej;
75
- });
76
- fontStream.on('error', (err: Error) => fontStreamReject(err));
77
-
78
- for (const f of files) {
79
- await Promise.race([
80
- writeGlyphStreamToFont(fontStream, path.join(glyphDir, f), f),
81
- fontStreamErrorPromise,
82
- ]);
83
- }
84
-
85
- fontStream.end();
86
- await once(fontStream, 'end');
87
-
88
- const svgFontString = Buffer.concat(svgFontChunks).toString('utf8');
89
93
  const ttfRaw = svg2ttf(svgFontString);
90
94
  const rawBuf = Buffer.from(ttfRaw.buffer);
91
95
 
@@ -73,14 +73,37 @@ export class PyodideManager {
73
73
 
74
74
  await py.loadPackage(['micropip', 'lxml'], { messageCallback: () => {} });
75
75
 
76
+ // Resolve local picosvg wheel path for offline-first installation.
77
+ const picosvgWhlDir = path.join(
78
+ getPackageRoot(),
79
+ 'src',
80
+ 'core',
81
+ 'shims'
82
+ );
83
+ const picosvgWhl = (await fs.readdir(picosvgWhlDir))
84
+ .find((f) => f.startsWith('picosvg-') && f.endsWith('.whl'));
85
+ const localWhlUrl = picosvgWhl
86
+ ? `file://${path.join(picosvgWhlDir, picosvgWhl)}`
87
+ : null;
88
+
89
+ py.globals.set('_picosvg_local_whl', localWhlUrl);
90
+
76
91
  await py.runPythonAsync(`
77
92
  import sys
78
93
  if "/" not in sys.path:
79
94
  sys.path.insert(0, "/")
80
-
95
+
81
96
  import micropip
82
- await micropip.install("picosvg", deps=False)
83
-
97
+
98
+ _whl = _picosvg_local_whl
99
+ if _whl:
100
+ try:
101
+ await micropip.install(_whl, deps=False)
102
+ except Exception:
103
+ await micropip.install("picosvg", deps=False)
104
+ else:
105
+ await micropip.install("picosvg", deps=False)
106
+
84
107
  import pathops
85
108
  import picosvg
86
109
  `);
@@ -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
+ }