Skip to content

Commit

Permalink
xWidthAvg: Add subset support for non-latin character sets (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeltaranto authored Mar 8, 2024
1 parent 9ac622e commit 879208b
Show file tree
Hide file tree
Showing 14 changed files with 29,048 additions and 1,654 deletions.
51 changes: 51 additions & 0 deletions .changeset/pink-plants-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@capsizecss/core': minor
'@capsizecss/metrics': minor
'@capsizecss/unpack': minor
---

xWidthAvg: Add `subset` support for non-latin character sets

Previously the `xWidthAvg` metric was calculated based on the character frequency as measured from English text only.
This resulted in the `xWidthAvg` metric being incorrect for languages that use a different unicode subset range, e.g. Thai.

Supporting Thai now enables adding support for other unicode ranges in the future.

### What's changed?

#### `@capsizecss/metrics`

The `subsets` field has been added to the metrics object, providing the `xWidthAvg` metric for each subset — calculated against the relevant character frequency data.

```diff
{
"familyName": "Abril Fatface",
...
+ "subsets": {
+ "latin": {
+ "xWidthAvg": 512
+ },
+ "thai": {
+ "xWidthAvg": 200
+ }
+ }
}
```

There are no changes to any of the other existing metrics.


#### `@capsizecss/core`

Fallback font stacks can now be generated per subset, allowing the correct `xWidthAvg` metric to be used for the relevant subset.

The `createFontStack` API now accepts `subset` as an option:

```ts
const { fontFamily, fontFaces } = createFontStack(
[lobster, arial],
{
subset: 'thai',
},
);
```
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,29 @@ This will result in the following additions to the declarations:
}
```

Worth noting, passing any of the metric override CSS properties will be ignored as they are calculated by Capsize. However, the `size-adjust` property is accepted to support fine-tuning the override for particular use cases. This can be used to finesse the adjustment for specific text, or to disable the adjustment by setting it to `100%`.
> [!NOTE]
> Passing any of the metric override CSS properties will be ignored as they are calculated by Capsize.
> However, the `size-adjust` property is accepted to support fine-tuning the override for particular use cases.
> This can be used to finesse the adjustment for specific text, or to disable the adjustment by setting it to `100%`.

#### Scaling for different character subsets

For languages that use different unicode subsets, e.g. Thai, the fallbacks need to be scaled accordingly, as the scaling is [based on character frequency in written language].

A fallback font stack can be generated for a supported subset by specifying `subset` as an option:

```ts
const { fontFamily, fontFaces } = createFontStack([lobster, arial], {
subset: 'thai',
});
```

> [!TIP]
> Need support for a different unicode subset?
> Either create an issue or follow the steps outlined in the [`generate-weightings` script] and open a PR.

[based on character frequency in written language]: packages/metrics/README.md#how-xwidthavg-is-calculated
[`generate-weightings` script]: packages/unpack/scripts/generate-weightings.ts

### precomputeValues

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"test": "jest",
"format": "prettier --write .",
"lint": "manypkg check && prettier --check . && tsc",
"dev": "pnpm %packages dev && pnpm generate",
"dev": "pnpm unpack:generate && pnpm %packages dev && pnpm metrics:generate",
"build": "pnpm %packages build && pnpm generate",
"generate": "pnpm unpack:generate && pnpm metrics:generate",
"copy-readme": "node scripts/copy-readme",
Expand Down
64 changes: 56 additions & 8 deletions packages/core/src/createFontStack.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AtRule } from 'csstype';
import { round } from './round';
import type { FontMetrics } from './types';
import type { FontMetrics, SupportedSubset } from './types';

const toPercentString = (value: number) => `${round(value * 100)}%`;

Expand All @@ -9,21 +9,48 @@ export const toCssProperty = (property: string) =>

type FontStackMetrics = Pick<
FontMetrics,
'familyName' | 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
| 'familyName'
| 'ascent'
| 'descent'
| 'lineGap'
| 'unitsPerEm'
| 'xWidthAvg'
| 'subsets'
>;

// Support old metrics pre-`subsets` alongside the newer core package with `subset` support.
const resolveXWidthAvg = (
metrics: FontStackMetrics,
subset: SupportedSubset,
) => {
if ('subsets' in metrics && metrics?.subsets?.[subset]) {
return metrics.subsets[subset].xWidthAvg;
}

if (subset !== 'latin') {
throw new Error(
`The subset "${subset}" is not available in the metrics provided for "${metrics.familyName}"`,
);
}

return metrics.xWidthAvg;
};

interface OverrideValuesParams {
metrics: FontStackMetrics;
fallbackMetrics: FontStackMetrics;
subset: SupportedSubset;
}
const calculateOverrideValues = ({
metrics,
fallbackMetrics,
subset,
}: OverrideValuesParams): AtRule.FontFace => {
// Calculate size adjust
const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm;
const preferredFontXAvgRatio =
resolveXWidthAvg(metrics, subset) / metrics.unitsPerEm;
const fallbackFontXAvgRatio =
fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm;
resolveXWidthAvg(fallbackMetrics, subset) / fallbackMetrics.unitsPerEm;

const sizeAdjust =
preferredFontXAvgRatio && fallbackFontXAvgRatio
Expand Down Expand Up @@ -131,6 +158,16 @@ type CreateFontStackOptions = {
* support explicit overrides.
*/
fontFaceProperties?: AdditionalFontFaceProperties;
/**
* The unicode subset to generate the fallback font for.
*
* The fallback font is scaled according to the average character width,
* calculated from weighted character frequencies in written text that
* uses the specified subset, e.g. `latin` from English, `thai` from Thai.
*
* Default: `latin`
*/
subset?: SupportedSubset;
};
type FontFaceFormatString = {
/**
Expand All @@ -145,6 +182,18 @@ type FontFaceFormatObject = {
fontFaceFormat?: 'styleObject';
};

const resolveOptions = (options: Parameters<typeof createFontStack>[1]) => {
const fontFaceFormat = options?.fontFaceFormat ?? 'styleString';
const subset = options?.subset ?? 'latin';
const fontFaceProperties = options?.fontFaceProperties ?? {};

return {
fontFaceFormat,
subset,
fontFaceProperties,
} as const;
};

export function createFontStack(
fontStackMetrics: FontStackMetrics[],
options?: CreateFontStackOptions & FontFaceFormatString,
Expand All @@ -157,10 +206,8 @@ export function createFontStack(
[metrics, ...fallbackMetrics]: FontStackMetrics[],
optionsArg: CreateFontStackOptions = {},
) {
const { fontFaceFormat, fontFaceProperties } = {
fontFaceFormat: 'styleString',
...optionsArg,
};
const { fontFaceFormat, fontFaceProperties, subset } =
resolveOptions(optionsArg);
const { familyName } = metrics;

const fontFamilies: string[] = [quoteIfNeeded(familyName)];
Expand All @@ -182,6 +229,7 @@ export function createFontStack(
...calculateOverrideValues({
metrics,
fallbackMetrics: fallback,
subset,
}),
...(fontFaceProperties?.sizeAdjust
? { sizeAdjust: fontFaceProperties.sizeAdjust }
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type weightings from '../../unpack/src/weightings';
export type SupportedSubset = keyof typeof weightings;

export interface FontMetrics {
/** The font family name as authored by font creator */
familyName: string;
Expand All @@ -19,8 +22,14 @@ export interface FontMetrics {
capHeight: number;
/** The height of the main body of lower case letters above baseline */
xHeight: number;
/** The average width of lowercase characters (currently derived from latin character frequencies in English language) */
/**
* The average width of character glyphs in the font for the Latin unicode subset.
*
* Calculated based on character frequencies in written text.
* */
xWidthAvg: number;
/** A lookup of the `xWidthAvg` metric by unicode subset */
subsets?: Record<SupportedSubset, { xWidthAvg: number }>;
}

export type ComputedValues = {
Expand Down
53 changes: 41 additions & 12 deletions packages/metrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,20 @@ const capsizeStyles = createStyleObject({

The font metrics object returned contains the following properties if available:

| Property | Type | Description |
| ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| familyName | string | The font family name as authored by font creator |
| category | string | The style of the font: serif, sans-serif, monospace, display, or handwriting. |
| capHeight | number | The height of capital letters above the baseline |
| ascent | number | The height of the ascenders above baseline |
| descent | number | The descent of the descenders below baseline |
| lineGap | number | The amount of space included between lines |
| unitsPerEm | number | The size of the font’s internal coordinate grid |
| xHeight | number | The height of the main body of lower case letters above baseline |
| xWidthAvg | number | The average width of character glyphs in the font. Calculated based on character frequencies in written text ([see below]), falling back to the built in [xAvgCharWidth] from the OS/2 table. |
| Property | Type | Description |
| ---------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| familyName | string | The font family name as authored by font creator |
| category | string | The style of the font: serif, sans-serif, monospace, display, or handwriting. |
| capHeight | number | The height of capital letters above the baseline |
| ascent | number | The height of the ascenders above baseline |
| descent | number | The descent of the descenders below baseline |
| lineGap | number | The amount of space included between lines |
| unitsPerEm | number | The size of the font’s internal coordinate grid |
| xHeight | number | The height of the main body of lower case letters above baseline |
| xWidthAvg | number | The average width of character glyphs in the font for the selected unicode subset. Calculated based on character frequencies in written text ([see below]), falling back to the built in [xAvgCharWidth] from the OS/2 table. |
| subsets | {<br/>[subset]: { xWidthAvg: number }<br/>} | A lookup of the `xWidthAvg` metric by subset (see [supported subsets below]) |

[supported subsets below]: #subsets

#### How `xWidthAvg` is calculated

Expand All @@ -61,15 +64,41 @@ The value takes a weighted average of character glyph widths in the font, fallin
The purpose of this metric is to support generating CSS metric overrides (e.g. [`ascent-override`], [`size-adjust`], etc) for fallback fonts, enabling inference of average line lengths so that a fallback font can be scaled to better align with a web font. This can be done either manually or using [`createFontStack`].

For this technique to be effective, the metric factors in a character frequency weightings as observed in written language, using “abstracts” from [Wikinews] articles as a data source.
Currently only supporting English ([source](https://en.wikinews.org/)).
Below is the source analysed for each supported subset:

| Subset | Language |
| ------- | -------------------------------------------- |
| `latin` | English ([source](https://en.wikinews.org/)) |
| `thai` | Thai ([source](https://th.wikinews.org/)) |

> [!TIP]
> Need support for a different unicode subset?
> Either create an issue or follow the steps outlined in the [`generate-weightings` script] in the `unpack` package and open a PR.
For more information on how to access the metrics for different subsets, see the [subsets](#subsets) section below.

[`generate-weightings` script]: ../unpack/scripts/generate-weightings.ts
[see below]: #how-xwidthavg-is-calculated
[xavgcharwidth]: https://learn.microsoft.com/en-us/typography/opentype/spec/os2#xavgcharwidth
[`ascent-override`]: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/ascent-override
[`size-adjust`]: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/size-adjust
[`createfontstack`]: ../core/README.md#createfontstack
[wikinews]: https://www.wikinews.org/

## Subsets

The top level `xWidthAvg` metric represents the average character width for the `latin` subset. However, the `xWidthAvg` for each supported subset is available explicitly within the `subsets` field.

For example:

```ts
import arial from '@capsizecss/metrics/arial';

const xWidthAvgDefault = arial.xWidthAvg;
const xWidthAvgLatin = arial.subsets.latin.xWidthAvg; // Same as above
const xWidthAvgThai = arial.subsets.thai.xWidthAvg;
```

## Supporting APIs

### `fontFamilyToCamelCase`
Expand Down
15 changes: 13 additions & 2 deletions packages/metrics/scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const buildFiles = async ({
unitsPerEm,
xHeight,
xWidthAvg,
subsets,
}: MetricsFont) => {
const fileName = fontFamilyToCamelCase(familyName);
const data = {
Expand All @@ -45,6 +46,7 @@ const buildFiles = async ({
unitsPerEm,
xHeight,
xWidthAvg,
subsets,
};

const typeName = `${fileName.charAt(0).toUpperCase()}${fileName.slice(
Expand Down Expand Up @@ -100,14 +102,23 @@ const buildFiles = async ({
xWidthAvg: number;`
: ''
}
subsets: {
${Object.keys(subsets).map(
(s) => `${s}: {
xWidthAvg: number;
}`,
).join(`,
`)}
}
}
export const fontMetrics: ${typeName};
export default fontMetrics;
`;
}\n
`;

await writeMetricsFile(`${fileName}.cjs`, cjsOutput);
await writeMetricsFile(`${fileName}.mjs`, mjsOutput);
await writeMetricsFile(`${fileName}.d.ts`, `${typesOutput}\n}\n`);
await writeMetricsFile(`${fileName}.d.ts`, typesOutput);
};

(async () => {
Expand Down
Loading

0 comments on commit 879208b

Please sign in to comment.