Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions .changeset/odd-moons-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@capsizecss/unpack": major
---

Create `server` entry point to isolate usage of node APIs without needing to polyfill.

### BREAKING CHANGES

Move `fromFile` to `server` entry point.

#### MIGRATION GUIDE

```diff
-import { fromFile } from '@capsizecss/unpack';
+import { fromFile } from '@capsizecss/unpack/server';
```
2 changes: 1 addition & 1 deletion .changeset/perfect-islands-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@capsizecss/unpack": patch
---

Reduces `@capsizecss/unpack` install size by using a lighter weight package for extracting font file metrics
Reduce install size by using a lighter weight package for extracting font file metrics
11 changes: 9 additions & 2 deletions .changeset/silver-buttons-bake.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@
"@capsizecss/unpack": major
---

This package is now ESM-only.
Convert to ESM-only package.

In most projects you can continue to use the package as before. CommonJS (CJS) projects using Node.js <20, should update to use a dynamic import:
### BREAKING CHANGES

As a result of migrating to a lighter weight package for extracting font file metrics, this package is now ESM-only.

#### MIGRATION GUIDE

In most projects you can continue to use the package as before.
CommonJS (CJS) projects using Node.js <20, should update to use a dynamic import:

```js
// For CJS projects before Node 20
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
- name: Install Dependencies
run: pnpm i

- name: Lint
run: pnpm lint

- name: Test
run: |
pnpm test
Expand Down
3 changes: 2 additions & 1 deletion packages/metrics/scripts/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import path from 'path';
import cliProgress from 'cli-progress';
import PQueue from 'p-queue';
import sortKeys from 'sort-keys';
import { Font, fromFile, fromUrl } from '@capsizecss/unpack';
import { Font, fromUrl } from '@capsizecss/unpack';
import { fromFile } from '@capsizecss/unpack/server';
import systemFonts from './source-data/systemFontsData';
import googleFonts from './source-data/googleFontsData.json';

Expand Down
2 changes: 1 addition & 1 deletion packages/unpack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const metrics = await fromUrl(url);
Takes a file path string and returns the resolved [font metrics](#font-metrics).

```ts
import { fromFile } from '@capsizecss/unpack';
import { fromFile } from '@capsizecss/unpack/server';

const metrics = await fromFile(filePath);
```
Expand Down
5 changes: 5 additions & 0 deletions packages/unpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"@capsizecss/src": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./server": {
"@capsizecss/src": "./src/server.ts",
"default": "./dist/server.mjs"
},
"./package.json": "./package.json"
},
"module": "./dist/index.mjs",
Expand Down Expand Up @@ -56,6 +60,7 @@
"publishConfig": {
"exports": {
".": "./dist/index.mjs",
"./server": "./dist/server.mjs",
"./package.json": "./package.json"
}
},
Expand Down
60 changes: 32 additions & 28 deletions packages/unpack/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import { fromUrl, fromBlob, fromBuffer } from '../index';
import { join } from 'node:path';
import { fromFile } from '../server';

const fontPath = join(__dirname, './__fixtures__/NotoSans-Regular.ttf');

const expectedMetrics = {
ascent: 1069,
Expand All @@ -25,41 +28,42 @@ const expectedMetrics = {
};

describe('unpack font metrics', () => {
describe('fromBuffer', () => {
it('should return the font metrics', async () => {
const fontPath = join(__dirname, './__fixtures__/NotoSans-Regular.ttf');
const font = await readFile(fontPath);
const metrics = await fromBuffer(font);
expect(metrics).toEqual(expectedMetrics);
});
it('fromBuffer', async () => {
const font = await readFile(fontPath);
const metrics = await fromBuffer(font);
expect(metrics).toEqual(expectedMetrics);
});

it('fromBlob', async () => {
const font = await readFile(fontPath);
const fontBlob = new Blob([new Uint8Array(font)]);

const metricsBlob = await fromBlob(fontBlob);
expect(metricsBlob).toEqual(expectedMetrics);

const metricsFont = await fromBuffer(font);
expect(metricsFont).toEqual(metricsBlob);
});

describe('fromBlob', () => {
it('should return the font metrics', async () => {
const fontPath = join(__dirname, './__fixtures__/NotoSans-Regular.ttf');
const font = await readFile(fontPath);
const fontBlob = new Blob([font]);
it('fromUrl', async () => {
const font = await readFile(fontPath);
const sampleFontUrl =
'https://github.com/notofonts/notofonts.github.io/raw/refs/heads/main/fonts/NotoSans/full/ttf/NotoSans-Regular.ttf';

const metricsBlob = await fromBlob(fontBlob);
expect(metricsBlob).toEqual(expectedMetrics);
const metricsUrl = await fromUrl(sampleFontUrl);
expect(metricsUrl).toEqual(expectedMetrics);

const metricsFont = await fromBuffer(font);
expect(metricsFont).toEqual(metricsBlob);
});
const metricsFont = await fromBuffer(font);
expect(metricsFont).toEqual(metricsUrl);
});

describe('fromUrl', () => {
it('should return the font metrics', async () => {
const fontPath = join(__dirname, './__fixtures__/NotoSans-Regular.ttf');
const font = await readFile(fontPath);
const sampleFontUrl =
'https://github.com/notofonts/notofonts.github.io/raw/refs/heads/main/fonts/NotoSans/full/ttf/NotoSans-Regular.ttf';
it('fromFile', async () => {
const font = await readFile(fontPath);

const metricsUrl = await fromUrl(sampleFontUrl);
expect(metricsUrl).toEqual(expectedMetrics);
const metricsUrl = await fromFile(fontPath);
expect(metricsUrl).toEqual(expectedMetrics);

const metricsFont = await fromBuffer(font);
expect(metricsFont).toEqual(metricsUrl);
});
const metricsFont = await fromBuffer(font);
expect(metricsFont).toEqual(metricsUrl);
});
});
197 changes: 8 additions & 189 deletions packages/unpack/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,189 +1,8 @@
import {
create,
type Font as FontKitFont,
type FontCollection,
} from 'fontkitten';
import { readFile } from 'node:fs/promises';

import weightings from './weightings';

export type SupportedSubsets = keyof typeof weightings;
export const supportedSubsets = Object.keys(weightings) as SupportedSubsets[];

const weightingForCharacter = (character: string, subset: SupportedSubsets) => {
if (!Object.keys(weightings[subset]).includes(character)) {
throw new Error(`No weighting specified for character: “${character}”`);
}
return weightings[subset][
character as keyof (typeof weightings)[SupportedSubsets]
];
};

const avgWidthForSubset = (font: FontKitFont, subset: SupportedSubsets) => {
const sampleString = Object.keys(weightings[subset]).join('');
const glyphs = font.glyphsForString(sampleString);
const weightedWidth = glyphs.reduce((sum, glyph, index) => {
const character = sampleString.charAt(index);

let charWidth = font['OS/2'].xAvgCharWidth;
try {
charWidth = glyph.advanceWidth;
} catch (e) {
console.warn(
`Couldn’t read 'advanceWidth' for character “${
character === ' ' ? '<space>' : character
}” from “${font.familyName}”. Falling back to “xAvgCharWidth”.`,
);
}

if (glyph.isMark) {
return sum;
}

return sum + charWidth * weightingForCharacter(character, subset);
}, 0);

return Math.round(weightedWidth);
};

const unpackMetricsFromFont = (font: FontKitFont) => {
const {
capHeight,
ascent,
descent,
lineGap,
unitsPerEm,
familyName,
fullName,
postscriptName,
xHeight,
} = font;

type SubsetLookup = Record<SupportedSubsets, { xWidthAvg: number }>;
const subsets: SubsetLookup = supportedSubsets.reduce(
(acc, subset) => ({
...acc,
[subset]: {
xWidthAvg: avgWidthForSubset(font, subset),
},
}),
{} as SubsetLookup,
);

return {
familyName,
fullName,
postscriptName,
capHeight,
ascent,
descent,
lineGap,
unitsPerEm,
xHeight,
xWidthAvg: subsets.latin.xWidthAvg,
subsets,
};
};

export type Font = ReturnType<typeof unpackMetricsFromFont>;

function handleCollectionErrors(
font: FontKitFont | FontCollection | null,
{
postscriptName,
apiName,
apiParamName,
}: { postscriptName?: string; apiName: string; apiParamName: string },
): asserts font is FontKitFont {
if (postscriptName && font === null) {
throw new Error(
[
`The provided \`postscriptName\` of “${postscriptName}” cannot be found in the provided font collection.\n`,
'Run the same command without specifying a `postscriptName` in the options to see the available names in the collection.',
'For example:',
'------------------------------------------',
`const metrics = await ${apiName}('<${apiParamName}>');`,
'------------------------------------------\n',
'',
].join('\n'),
);
}

if (font !== null && font.isCollection) {
const availableNames = font.fonts.map((f) => f.postscriptName);
throw new Error(
[
'Metrics cannot be unpacked from a font collection.\n',
'Provide either a single font or specify a `postscriptName` to extract from the collection via the options.',
'For example:',
'------------------------------------------',
`const metrics = await ${apiName}('<${apiParamName}>', {`,
` postscriptName: '${availableNames[0]}'`,
'});',
'------------------------------------------\n',
'Available `postscriptNames` in this font collection are:',
...availableNames.map((fontName) => ` - ${fontName}`),
'',
].join('\n'),
);
}
}

interface Options {
postscriptName?: string;
}

export const fromFile = async (
path: string,
options?: Options,
): Promise<Font> => {
const buffer = await readFile(path);
return _fromBuffer(buffer, 'fromFile', 'path', options);
};

const _fromBuffer = async (
buffer: Buffer,
apiName: string,
apiParamName: string,
options?: Options,
) => {
const { postscriptName } = options || {};

const fontkitFont = create(buffer, postscriptName);

handleCollectionErrors(fontkitFont, {
postscriptName,
apiName,
apiParamName,
});

return unpackMetricsFromFont(fontkitFont);
};

export const fromBuffer = async (
buffer: Buffer,
options?: Options,
): Promise<Font> => {
return _fromBuffer(buffer, 'fromBuffer', 'buffer', options);
};

export const fromBlob = async (
blob: Blob,
options?: Options,
): Promise<Font> => {
const arrayBuffer = await blob.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return _fromBuffer(buffer, 'fromBlob', 'blob', options);
};

export const fromUrl = async (
url: string,
options?: Options,
): Promise<Font> => {
const response = await fetch(url);

const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

return _fromBuffer(buffer, 'fromUrl', 'url', options);
};
export {
type Font,
type SupportedSubsets,
supportedSubsets,
fromBuffer,
fromBlob,
fromUrl,
} from './shared';
7 changes: 7 additions & 0 deletions packages/unpack/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { readFile } from 'node:fs/promises';
import { _fromBuffer, Options } from './shared';

export const fromFile = async (path: string, options?: Options) => {
const buffer = await readFile(path);
return _fromBuffer(buffer, 'fromFile', 'path', options);
};
Loading