Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 `<fontFamily>.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). |
Expand Down
32 changes: 32 additions & 0 deletions packages/react-native-nano-icons/__tests__/build.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -101,6 +102,37 @@ 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('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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const SAMPLE_SVG = '<svg viewBox="0 0 24 24"><path d="M0 0L24 24"/></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'],
Expand Down Expand Up @@ -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' }],
[
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PipelineConfig>) {
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));
});
12 changes: 12 additions & 0 deletions packages/react-native-nano-icons/cli/build.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import path from 'path';
import fs from 'fs';
import { inspect } from 'util';
import {
runFontPipeline,
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 {
Expand All @@ -23,6 +25,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*/
Expand Down Expand Up @@ -155,6 +159,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 (${inspect(curveTolerance)}) of "${fontFamily}" must be a positive number.`
);
}

const outputDir = set.outputDir
? path.resolve(projectRoot, set.outputDir)
Expand All @@ -167,6 +177,7 @@ export async function buildAllFonts(
fontFamily,
upm,
safeZone,
curveTolerance,
startUnicode:
set.startUnicode !== undefined
? typeof set.startUnicode === 'string'
Expand All @@ -180,6 +191,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,
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-nano-icons/docs/FONT_INTEGRITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <fontFamily>-<hash8> → glyphMap.m.f == TTF name table
files = <fontFamily>.ttf / .glyphmap.json (unchanged)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-nano-icons/plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type PipelineConfig = {
fontFamily: string;
upm: number;
safeZone: number;
curveTolerance?: number;
startUnicode: number;
linking: 'static' | 'dynamic';
web?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +14,7 @@ export type IconTask = {
fontFamily: string;
upm: number;
safeZone: number;
curveTolerance: number;
};

export type IconLog = [level: 'info' | 'warn' | 'fail', message: string];
Expand Down Expand Up @@ -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),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends SvgPrepareTask>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-nano-icons/src/utils/fingerPrint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const path = require('path');
export type FingerprintInputs = {
upm: number;
safeZone: number;
curveTolerance: number;
startUnicode: number;
version: string;
toolchain: readonly string[];
Expand Down Expand Up @@ -35,6 +36,7 @@ export function fingerprintSvgDirSync(
return hashSvgDir(dir, [
inputs.upm,
inputs.safeZone,
inputs.curveTolerance,
inputs.startUnicode,
inputs.version,
inputs.toolchain,
Expand Down
Loading