From ad9f0875428a3de995a8ead7a42459b07c445368 Mon Sep 17 00:00:00 2001 From: Stanislaw Bialecki Date: Thu, 22 Oct 2026 00:59:24 +0200 Subject: [PATCH 1/3] feat(pipeline): add a curveTolerance icon set option --- README.md | 1 + .../__tests__/build.unit.test.ts | 23 +++++++ .../__tests__/fingerprint.unit.test.ts | 2 + .../__tests__/iconSetOptions.e2e.test.ts | 66 +++++++++++++++++++ packages/react-native-nano-icons/cli/build.ts | 11 ++++ .../docs/FONT_INTEGRITY.md | 4 +- .../plugin/src/types.ts | 2 + .../src/core/pipeline/config.ts | 1 + .../src/core/pipeline/prepareIcon.ts | 5 +- .../src/core/pipeline/preparedSvgCache.ts | 2 +- .../src/core/pipeline/runFontPipeline.ts | 2 + .../src/utils/fingerPrint.ts | 2 + 12 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-nano-icons/__tests__/iconSetOptions.e2e.test.ts diff --git a/README.md b/README.md index c8a3d071..a6480641 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,7 @@ Each entry of `iconSets` (in the Expo plugin options or `.nanoicons.json`) gener | `outputDir` | `string` | No | Sibling `nanoicons` folder | Path where the `.ttf` and `.glyphmap.json` files are saved, relative to the folder of `.nanoicons.json` or the app config. Defaults to a `nanoicons` folder next to `inputDir`. | | `upm` | `number` | No | `1024` | Units Per Em. Defines the resolution of the font grid. | | `safeZone` | `number` | No | `upm` | Height, in font units, that each icon's `viewBox` is scaled to. The rest of the em square is padding. Must not exceed `upm`. | +| `curveTolerance` | `number` | No | `1/512` (≈ `0.002`) | How far a curve in the font may be from the curve in the SVG, as a fraction of the icon size. Fonts store curves as quadratic Béziers, so cubic curves are approximated. At `size={512}` the default allows 1 px. Lower values store more points. | | `startUnicode` | `string \| number` | No | `0xe900` | The first Unicode codepoint for the icon glyphs. A string is parsed as hex (`"e900"` or `"0xe900"`). | | `linking` | `'static' \| 'dynamic'` | No | `'static'` | Delivery mode for the generated TTF. `'static'` bundles it into the native app. `'dynamic'` excludes it from native linking so the host app can deliver it at runtime (e.g. via an OTA update). See [OTA Updates & Dynamic Linking](#%EF%B8%8F-ota-updates--dynamic-linking). | | `web` | `boolean` | No | `false` | Also emit `.woff2` into `outputDir`, rebuilt together with the `.ttf` on every change. It is never linked natively. On web, pass its URL to `createNanoIconSet`, see [Web](#-web). | diff --git a/packages/react-native-nano-icons/__tests__/build.unit.test.ts b/packages/react-native-nano-icons/__tests__/build.unit.test.ts index 0facbd9a..204c6378 100644 --- a/packages/react-native-nano-icons/__tests__/build.unit.test.ts +++ b/packages/react-native-nano-icons/__tests__/build.unit.test.ts @@ -27,6 +27,7 @@ const STORED_FAMILY = `${FONT_FAMILY}-89efdcba`; const DEFAULT_INPUTS = { upm: 1024, safeZone: 1024, + curveTolerance: 1 / 512, startUnicode: 0xe900, version: packageVersion(), toolchain: fontToolchainVersions(), @@ -101,6 +102,28 @@ describe('buildAllFonts — skip/rebuild logic', () => { expect(mockRunPipeline).toHaveBeenCalledTimes(1); }); + test('curveTolerance reaches the pipeline and the fingerprint', async () => { + writeFakeOutputs(outputDir, FONT_FAMILY, inputHash); + await buildAllFonts( + [{ ...makeIconSet(), curveTolerance: 1 / 2048 }], + os.tmpdir() + ); + expect(mockRunPipeline).toHaveBeenCalledTimes(1); + expect(mockRunPipeline.mock.calls[0]![0]).toMatchObject({ + curveTolerance: 1 / 2048, + }); + }); + + test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'a curveTolerance of %p is rejected', + async (curveTolerance) => { + await expect( + buildAllFonts([{ ...makeIconSet(), curveTolerance }], os.tmpdir()) + ).rejects.toThrow(/curveTolerance .* must be a positive number/); + expect(mockRunPipeline).not.toHaveBeenCalled(); + } + ); + test('runFontPipeline is not called when output files exist with matching hash', async () => { writeFakeOutputs(outputDir, FONT_FAMILY, inputHash); await buildAllFonts([makeIconSet()], os.tmpdir()); diff --git a/packages/react-native-nano-icons/__tests__/fingerprint.unit.test.ts b/packages/react-native-nano-icons/__tests__/fingerprint.unit.test.ts index 93d38858..5f1dd7fc 100644 --- a/packages/react-native-nano-icons/__tests__/fingerprint.unit.test.ts +++ b/packages/react-native-nano-icons/__tests__/fingerprint.unit.test.ts @@ -14,6 +14,7 @@ const SAMPLE_SVG = ''; const INPUTS: FingerprintInputs = { upm: 1024, safeZone: 1020, + curveTolerance: 1 / 512, startUnicode: 0xe900, version: '1.0.0', toolchain: ['fonteditor-core@2.6.3', 'svg2ttf@6.0.3'], @@ -114,6 +115,7 @@ describe('getFingerprintSync', () => { test.each([ ['upm', { ...INPUTS, upm: 512 }], ['safeZone', { ...INPUTS, safeZone: 1000 }], + ['curveTolerance', { ...INPUTS, curveTolerance: 1 / 4096 }], ['startUnicode', { ...INPUTS, startUnicode: 0xf000 }], ['version', { ...INPUTS, version: '1.0.1' }], [ diff --git a/packages/react-native-nano-icons/__tests__/iconSetOptions.e2e.test.ts b/packages/react-native-nano-icons/__tests__/iconSetOptions.e2e.test.ts new file mode 100644 index 00000000..b4db0acd --- /dev/null +++ b/packages/react-native-nano-icons/__tests__/iconSetOptions.e2e.test.ts @@ -0,0 +1,66 @@ +/** @jest-environment node */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + runFontPipeline, + type PipelineConfig, +} from '../src/core/pipeline/index'; +import type { NanoGlyphMap } from '../src/core/types'; + +const TESTICONS_DIR = path.resolve( + __dirname, + '../../../examples/BareReactNativeExample/assets/testicons' +); +const ICONS = ['usFlag.svg', 'person-walking.svg', 'star.svg']; + +const dirs: string[] = []; + +afterAll(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +async function build(options: Partial) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-options-')); + dirs.push(root); + const inputDir = path.join(root, 'in'); + fs.mkdirSync(inputDir); + for (const icon of ICONS) { + fs.copyFileSync(path.join(TESTICONS_DIR, icon), path.join(inputDir, icon)); + } + const result = await runFontPipeline( + { + fontFamily: 'Options', + upm: 1024, + safeZone: 1024, + startUnicode: 0xe900, + linking: 'static', + ...options, + }, + { + inputDir, + outputDir: path.join(root, 'out'), + tempDir: path.join(root, 'tmp'), + } + ); + return { + glyphMap: JSON.parse( + fs.readFileSync(result.glyphmapPath, 'utf8') + ) as NanoGlyphMap, + ttfSize: fs.statSync(result.ttfPath).size, + }; +} + +const advances = ({ glyphMap }: { glyphMap: NanoGlyphMap }) => + Object.fromEntries( + Object.entries(glyphMap.i).map(([name, [adv]]) => [name, adv]) + ); + +test('a lower curveTolerance stores more curve segments', async () => { + const standard = await build({}); + const fine = await build({ curveTolerance: 1 / 8192 }); + expect(fine.ttfSize).toBeGreaterThan(standard.ttfSize); + expect(advances(fine)).toEqual(advances(standard)); +}); diff --git a/packages/react-native-nano-icons/cli/build.ts b/packages/react-native-nano-icons/cli/build.ts index c64ea8f0..bf45ddde 100644 --- a/packages/react-native-nano-icons/cli/build.ts +++ b/packages/react-native-nano-icons/cli/build.ts @@ -5,6 +5,7 @@ import { type PreparedSvgCache, type SvgWorkerPool, } from '../src/core/pipeline/index'; +import { QUADRATIC_ERROR_BOUND_EM } from '../src/core/font/quadratic'; import type { NanoLogger } from './logger'; import { fingerprintSvgDirSync } from '../src/utils/fingerPrint'; import { @@ -23,6 +24,8 @@ export type IconSetConfig = { upm?: number; /** Safe zone inside UPM for glyphs (default upm * 1020 / 1024, i.e. 1020 at the default upm). Must not exceed upm. */ safeZone?: number; + /** Max distance between the font's quadratic curves and the SVG curves, as a fraction of the em (default 1/512). Must be positive. */ + curveTolerance?: number; /** First Unicode codepoint for glyphs (default 0xe900). Hex string or number. */ startUnicode?: number | string; /** Linking type for the font (default 'static'). Static bundles the TTF, dynamic delivers it via OTA*/ @@ -155,6 +158,12 @@ export async function buildAllFonts( `[react-native-nano-icons] safeZone (${safeZone}) of "${fontFamily}" must not exceed upm (${upm}).` ); } + const curveTolerance = set.curveTolerance ?? QUADRATIC_ERROR_BOUND_EM; + if (!Number.isFinite(curveTolerance) || curveTolerance <= 0) { + throw new Error( + `[react-native-nano-icons] curveTolerance (${curveTolerance}) of "${fontFamily}" must be a positive number.` + ); + } const outputDir = set.outputDir ? path.resolve(projectRoot, set.outputDir) @@ -167,6 +176,7 @@ export async function buildAllFonts( fontFamily, upm, safeZone, + curveTolerance, startUnicode: set.startUnicode !== undefined ? typeof set.startUnicode === 'string' @@ -180,6 +190,7 @@ export async function buildAllFonts( const { hash: inputHash, svgHashByFile } = fingerprintSvgDirSync(inputDir, { upm: config.upm, safeZone: config.safeZone, + curveTolerance: config.curveTolerance, startUnicode: config.startUnicode, version, toolchain, diff --git a/packages/react-native-nano-icons/docs/FONT_INTEGRITY.md b/packages/react-native-nano-icons/docs/FONT_INTEGRITY.md index 083748a3..afd2fa8e 100644 --- a/packages/react-native-nano-icons/docs/FONT_INTEGRITY.md +++ b/packages/react-native-nano-icons/docs/FONT_INTEGRITY.md @@ -5,7 +5,7 @@ Legend: `✔` silent · `⚠` dev warning + issue recorded · `△` dev warning ## Build identity ``` -fingerprint = sha256( svg names + contents , upm , safeZone , startUnicode , library version , +fingerprint = sha256( svg names + contents , upm , safeZone , curveTolerance , startUnicode , library version , installed versions of @xmldom/xmldom, cubic2quad, fonteditor-core, pathkit-wasm, svg2ttf ) family = - → glyphMap.m.f == TTF name table files = .ttf / .glyphmap.json (unchanged) @@ -19,7 +19,7 @@ fingerprint vs stored m.h · linking vs m.l · web vs m.w · files present ├─ all equal ───────────── "X.ttf is up to date", family kept └─ any differs ─────────── rebuild, outputs overwritten in place (Metro picks it up) ├─ svg edited - ├─ upm / safeZone / startUnicode changed + ├─ upm / safeZone / curveTolerance / startUnicode changed ├─ linking or web toggled, .woff2 missing └─ library or font toolchain upgraded (a dependency resolved to a new version) build fails ────────────── outputs deleted, other sets still linked, exit 1 diff --git a/packages/react-native-nano-icons/plugin/src/types.ts b/packages/react-native-nano-icons/plugin/src/types.ts index 6e0fcbf9..d077b800 100644 --- a/packages/react-native-nano-icons/plugin/src/types.ts +++ b/packages/react-native-nano-icons/plugin/src/types.ts @@ -12,6 +12,8 @@ export interface IconSetConfig { upm?: number; /** Safe zone inside UPM for glyphs (default upm * 1020 / 1024, i.e. 1020 at the default upm). Must not exceed upm. */ safeZone?: number; + /** Max distance between the font's quadratic curves and the SVG curves, as a fraction of the em (default 1/512). Must be positive. */ + curveTolerance?: number; /** First Unicode codepoint for glyphs (default 0xe900). Hex string or number. */ startUnicode?: number | string; /** diff --git a/packages/react-native-nano-icons/src/core/pipeline/config.ts b/packages/react-native-nano-icons/src/core/pipeline/config.ts index 27c9e12f..85d1a39f 100644 --- a/packages/react-native-nano-icons/src/core/pipeline/config.ts +++ b/packages/react-native-nano-icons/src/core/pipeline/config.ts @@ -4,6 +4,7 @@ export type PipelineConfig = { fontFamily: string; upm: number; safeZone: number; + curveTolerance?: number; startUnicode: number; linking: 'static' | 'dynamic'; web?: boolean; diff --git a/packages/react-native-nano-icons/src/core/pipeline/prepareIcon.ts b/packages/react-native-nano-icons/src/core/pipeline/prepareIcon.ts index 2031162e..e45f3f8d 100644 --- a/packages/react-native-nano-icons/src/core/pipeline/prepareIcon.ts +++ b/packages/react-native-nano-icons/src/core/pipeline/prepareIcon.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { QUADRATIC_ERROR_BOUND_EM, toQuadraticPath } from '../font/quadratic'; +import { toQuadraticPath } from '../font/quadratic'; import { shouldSkipPath } from '../glyph/parse'; import { computePlacement, transformPathForFont } from '../glyph/placement'; import type { PathKitModule } from '../pathkit/types'; @@ -14,6 +14,7 @@ export type IconTask = { fontFamily: string; upm: number; safeZone: number; + curveTolerance: number; }; export type IconLog = [level: 'info' | 'warn' | 'fail', message: string]; @@ -95,7 +96,7 @@ export async function prepareIcon( }); result.layers.push({ fill: p.fill, - d: toQuadraticPath(placed, task.upm * QUADRATIC_ERROR_BOUND_EM), + d: toQuadraticPath(placed, task.upm * task.curveTolerance), }); } diff --git a/packages/react-native-nano-icons/src/core/pipeline/preparedSvgCache.ts b/packages/react-native-nano-icons/src/core/pipeline/preparedSvgCache.ts index c3e63b24..22623dff 100644 --- a/packages/react-native-nano-icons/src/core/pipeline/preparedSvgCache.ts +++ b/packages/react-native-nano-icons/src/core/pipeline/preparedSvgCache.ts @@ -23,7 +23,7 @@ export function preparedSvgCacheKey( if (task.kind === 'symbol') { return `symbol:${task.setName}:${task.file}:${task.prefix}:${contentHash}`; } - return `${task.file}:${task.upm}:${task.safeZone}:${contentHash}`; + return `${task.file}:${task.upm}:${task.safeZone}:${task.curveTolerance}:${contentHash}`; } export async function prepareIconsWithCache( diff --git a/packages/react-native-nano-icons/src/core/pipeline/runFontPipeline.ts b/packages/react-native-nano-icons/src/core/pipeline/runFontPipeline.ts index 4b22af46..1141026c 100644 --- a/packages/react-native-nano-icons/src/core/pipeline/runFontPipeline.ts +++ b/packages/react-native-nano-icons/src/core/pipeline/runFontPipeline.ts @@ -7,6 +7,7 @@ import { parseCompileTtfFromGlyphsError, type FontGlyph, } from '../font/compile'; +import { QUADRATIC_ERROR_BOUND_EM } from '../font/quadratic'; import { buildFontFamily } from '../../utils/fontIdentity'; import type { GlyphLayer, NanoGlyphMap, NanoLogger } from '../types'; import { @@ -86,6 +87,7 @@ export async function runFontPipeline( fontFamily: config.fontFamily, upm: config.upm, safeZone: config.safeZone, + curveTolerance: config.curveTolerance ?? QUADRATIC_ERROR_BOUND_EM, })), options?.concurrency ?? defaultConcurrency(), options?.preparedSvgCache, diff --git a/packages/react-native-nano-icons/src/utils/fingerPrint.ts b/packages/react-native-nano-icons/src/utils/fingerPrint.ts index 46c63c55..6870788a 100644 --- a/packages/react-native-nano-icons/src/utils/fingerPrint.ts +++ b/packages/react-native-nano-icons/src/utils/fingerPrint.ts @@ -5,6 +5,7 @@ const path = require('path'); export type FingerprintInputs = { upm: number; safeZone: number; + curveTolerance: number; startUnicode: number; version: string; toolchain: readonly string[]; @@ -35,6 +36,7 @@ export function fingerprintSvgDirSync( return hashSvgDir(dir, [ inputs.upm, inputs.safeZone, + inputs.curveTolerance, inputs.startUnicode, inputs.version, inputs.toolchain, From bee62bde4110a2fdee3aebe56f9f64d4914bfc1c Mon Sep 17 00:00:00 2001 From: Stanislaw Bialecki Date: Thu, 29 Oct 2026 21:11:37 +0100 Subject: [PATCH 2/3] fix(cli): quote a non-number curveTolerance in the validation error --- .../react-native-nano-icons/__tests__/build.unit.test.ts | 9 +++++++++ packages/react-native-nano-icons/cli/build.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/react-native-nano-icons/__tests__/build.unit.test.ts b/packages/react-native-nano-icons/__tests__/build.unit.test.ts index 204c6378..cb1ed23d 100644 --- a/packages/react-native-nano-icons/__tests__/build.unit.test.ts +++ b/packages/react-native-nano-icons/__tests__/build.unit.test.ts @@ -124,6 +124,15 @@ describe('buildAllFonts — skip/rebuild logic', () => { } ); + test('a curveTolerance string is quoted in the error', async () => { + await expect( + buildAllFonts( + [{ ...makeIconSet(), curveTolerance: '0.001' as unknown as number }], + os.tmpdir() + ) + ).rejects.toThrow(`curveTolerance ('0.001') of "${FONT_FAMILY}"`); + }); + test('runFontPipeline is not called when output files exist with matching hash', async () => { writeFakeOutputs(outputDir, FONT_FAMILY, inputHash); await buildAllFonts([makeIconSet()], os.tmpdir()); diff --git a/packages/react-native-nano-icons/cli/build.ts b/packages/react-native-nano-icons/cli/build.ts index bf45ddde..bdf5132d 100644 --- a/packages/react-native-nano-icons/cli/build.ts +++ b/packages/react-native-nano-icons/cli/build.ts @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs'; +import { inspect } from 'util'; import { runFontPipeline, type PreparedSvgCache, @@ -161,7 +162,7 @@ export async function buildAllFonts( const curveTolerance = set.curveTolerance ?? QUADRATIC_ERROR_BOUND_EM; if (!Number.isFinite(curveTolerance) || curveTolerance <= 0) { throw new Error( - `[react-native-nano-icons] curveTolerance (${curveTolerance}) of "${fontFamily}" must be a positive number.` + `[react-native-nano-icons] curveTolerance (${inspect(curveTolerance)}) of "${fontFamily}" must be a positive number.` ); } From f0282270a65fe377f217581e70f5ca6d28f30c6d Mon Sep 17 00:00:00 2001 From: Stanislaw Bialecki Date: Thu, 29 Oct 2026 21:11:43 +0100 Subject: [PATCH 3/3] docs(readme): describe curveTolerance as a fraction of the em --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a6480641..8f401c31 100644 --- a/README.md +++ b/README.md @@ -502,7 +502,7 @@ Each entry of `iconSets` (in the Expo plugin options or `.nanoicons.json`) gener | `outputDir` | `string` | No | Sibling `nanoicons` folder | Path where the `.ttf` and `.glyphmap.json` files are saved, relative to the folder of `.nanoicons.json` or the app config. Defaults to a `nanoicons` folder next to `inputDir`. | | `upm` | `number` | No | `1024` | Units Per Em. Defines the resolution of the font grid. | | `safeZone` | `number` | No | `upm` | Height, in font units, that each icon's `viewBox` is scaled to. The rest of the em square is padding. Must not exceed `upm`. | -| `curveTolerance` | `number` | No | `1/512` (≈ `0.002`) | How far a curve in the font may be from the curve in the SVG, as a fraction of the icon size. Fonts store curves as quadratic Béziers, so cubic curves are approximated. At `size={512}` the default allows 1 px. Lower values store more points. | +| `curveTolerance` | `number` | No | `1/512` (≈ `0.002`) | How far a curve in the font may be from the curve in the SVG, as a fraction of the em (the `size` prop). Fonts store curves as quadratic Béziers, so cubic curves are approximated. At `size={512}` the default allows 1 px. Lower values store more points. | | `startUnicode` | `string \| number` | No | `0xe900` | The first Unicode codepoint for the icon glyphs. A string is parsed as hex (`"e900"` or `"0xe900"`). | | `linking` | `'static' \| 'dynamic'` | No | `'static'` | Delivery mode for the generated TTF. `'static'` bundles it into the native app. `'dynamic'` excludes it from native linking so the host app can deliver it at runtime (e.g. via an OTA update). See [OTA Updates & Dynamic Linking](#%EF%B8%8F-ota-updates--dynamic-linking). | | `web` | `boolean` | No | `false` | Also emit `.woff2` into `outputDir`, rebuilt together with the `.ttf` on every change. It is never linked natively. On web, pass its URL to `createNanoIconSet`, see [Web](#-web). |