Skip to content

Commit 879208b

Browse files
xWidthAvg: Add subset support for non-latin character sets (#177)
1 parent 9ac622e commit 879208b

File tree

14 files changed

+29048
-1654
lines changed

14 files changed

+29048
-1654
lines changed

.changeset/pink-plants-develop.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@capsizecss/core': minor
3+
'@capsizecss/metrics': minor
4+
'@capsizecss/unpack': minor
5+
---
6+
7+
xWidthAvg: Add `subset` support for non-latin character sets
8+
9+
Previously the `xWidthAvg` metric was calculated based on the character frequency as measured from English text only.
10+
This resulted in the `xWidthAvg` metric being incorrect for languages that use a different unicode subset range, e.g. Thai.
11+
12+
Supporting Thai now enables adding support for other unicode ranges in the future.
13+
14+
### What's changed?
15+
16+
#### `@capsizecss/metrics`
17+
18+
The `subsets` field has been added to the metrics object, providing the `xWidthAvg` metric for each subset — calculated against the relevant character frequency data.
19+
20+
```diff
21+
{
22+
"familyName": "Abril Fatface",
23+
...
24+
+ "subsets": {
25+
+ "latin": {
26+
+ "xWidthAvg": 512
27+
+ },
28+
+ "thai": {
29+
+ "xWidthAvg": 200
30+
+ }
31+
+ }
32+
}
33+
```
34+
35+
There are no changes to any of the other existing metrics.
36+
37+
38+
#### `@capsizecss/core`
39+
40+
Fallback font stacks can now be generated per subset, allowing the correct `xWidthAvg` metric to be used for the relevant subset.
41+
42+
The `createFontStack` API now accepts `subset` as an option:
43+
44+
```ts
45+
const { fontFamily, fontFaces } = createFontStack(
46+
[lobster, arial],
47+
{
48+
subset: 'thai',
49+
},
50+
);
51+
```

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,29 @@ This will result in the following additions to the declarations:
309309
}
310310
```
311311

312-
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%`.
312+
> [!NOTE]
313+
> Passing any of the metric override CSS properties will be ignored as they are calculated by Capsize.
314+
> However, the `size-adjust` property is accepted to support fine-tuning the override for particular use cases.
315+
> This can be used to finesse the adjustment for specific text, or to disable the adjustment by setting it to `100%`.
316+
317+
#### Scaling for different character subsets
318+
319+
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].
320+
321+
A fallback font stack can be generated for a supported subset by specifying `subset` as an option:
322+
323+
```ts
324+
const { fontFamily, fontFaces } = createFontStack([lobster, arial], {
325+
subset: 'thai',
326+
});
327+
```
328+
329+
> [!TIP]
330+
> Need support for a different unicode subset?
331+
> Either create an issue or follow the steps outlined in the [`generate-weightings` script] and open a PR.
332+
333+
[based on character frequency in written language]: packages/metrics/README.md#how-xwidthavg-is-calculated
334+
[`generate-weightings` script]: packages/unpack/scripts/generate-weightings.ts
313335

314336
### precomputeValues
315337

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"test": "jest",
1111
"format": "prettier --write .",
1212
"lint": "manypkg check && prettier --check . && tsc",
13-
"dev": "pnpm %packages dev && pnpm generate",
13+
"dev": "pnpm unpack:generate && pnpm %packages dev && pnpm metrics:generate",
1414
"build": "pnpm %packages build && pnpm generate",
1515
"generate": "pnpm unpack:generate && pnpm metrics:generate",
1616
"copy-readme": "node scripts/copy-readme",

packages/core/src/createFontStack.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AtRule } from 'csstype';
22
import { round } from './round';
3-
import type { FontMetrics } from './types';
3+
import type { FontMetrics, SupportedSubset } from './types';
44

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

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

1010
type FontStackMetrics = Pick<
1111
FontMetrics,
12-
'familyName' | 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
12+
| 'familyName'
13+
| 'ascent'
14+
| 'descent'
15+
| 'lineGap'
16+
| 'unitsPerEm'
17+
| 'xWidthAvg'
18+
| 'subsets'
1319
>;
1420

21+
// Support old metrics pre-`subsets` alongside the newer core package with `subset` support.
22+
const resolveXWidthAvg = (
23+
metrics: FontStackMetrics,
24+
subset: SupportedSubset,
25+
) => {
26+
if ('subsets' in metrics && metrics?.subsets?.[subset]) {
27+
return metrics.subsets[subset].xWidthAvg;
28+
}
29+
30+
if (subset !== 'latin') {
31+
throw new Error(
32+
`The subset "${subset}" is not available in the metrics provided for "${metrics.familyName}"`,
33+
);
34+
}
35+
36+
return metrics.xWidthAvg;
37+
};
38+
1539
interface OverrideValuesParams {
1640
metrics: FontStackMetrics;
1741
fallbackMetrics: FontStackMetrics;
42+
subset: SupportedSubset;
1843
}
1944
const calculateOverrideValues = ({
2045
metrics,
2146
fallbackMetrics,
47+
subset,
2248
}: OverrideValuesParams): AtRule.FontFace => {
2349
// Calculate size adjust
24-
const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm;
50+
const preferredFontXAvgRatio =
51+
resolveXWidthAvg(metrics, subset) / metrics.unitsPerEm;
2552
const fallbackFontXAvgRatio =
26-
fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm;
53+
resolveXWidthAvg(fallbackMetrics, subset) / fallbackMetrics.unitsPerEm;
2754

2855
const sizeAdjust =
2956
preferredFontXAvgRatio && fallbackFontXAvgRatio
@@ -131,6 +158,16 @@ type CreateFontStackOptions = {
131158
* support explicit overrides.
132159
*/
133160
fontFaceProperties?: AdditionalFontFaceProperties;
161+
/**
162+
* The unicode subset to generate the fallback font for.
163+
*
164+
* The fallback font is scaled according to the average character width,
165+
* calculated from weighted character frequencies in written text that
166+
* uses the specified subset, e.g. `latin` from English, `thai` from Thai.
167+
*
168+
* Default: `latin`
169+
*/
170+
subset?: SupportedSubset;
134171
};
135172
type FontFaceFormatString = {
136173
/**
@@ -145,6 +182,18 @@ type FontFaceFormatObject = {
145182
fontFaceFormat?: 'styleObject';
146183
};
147184

185+
const resolveOptions = (options: Parameters<typeof createFontStack>[1]) => {
186+
const fontFaceFormat = options?.fontFaceFormat ?? 'styleString';
187+
const subset = options?.subset ?? 'latin';
188+
const fontFaceProperties = options?.fontFaceProperties ?? {};
189+
190+
return {
191+
fontFaceFormat,
192+
subset,
193+
fontFaceProperties,
194+
} as const;
195+
};
196+
148197
export function createFontStack(
149198
fontStackMetrics: FontStackMetrics[],
150199
options?: CreateFontStackOptions & FontFaceFormatString,
@@ -157,10 +206,8 @@ export function createFontStack(
157206
[metrics, ...fallbackMetrics]: FontStackMetrics[],
158207
optionsArg: CreateFontStackOptions = {},
159208
) {
160-
const { fontFaceFormat, fontFaceProperties } = {
161-
fontFaceFormat: 'styleString',
162-
...optionsArg,
163-
};
209+
const { fontFaceFormat, fontFaceProperties, subset } =
210+
resolveOptions(optionsArg);
164211
const { familyName } = metrics;
165212

166213
const fontFamilies: string[] = [quoteIfNeeded(familyName)];
@@ -182,6 +229,7 @@ export function createFontStack(
182229
...calculateOverrideValues({
183230
metrics,
184231
fallbackMetrics: fallback,
232+
subset,
185233
}),
186234
...(fontFaceProperties?.sizeAdjust
187235
? { sizeAdjust: fontFaceProperties.sizeAdjust }

packages/core/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type weightings from '../../unpack/src/weightings';
2+
export type SupportedSubset = keyof typeof weightings;
3+
14
export interface FontMetrics {
25
/** The font family name as authored by font creator */
36
familyName: string;
@@ -19,8 +22,14 @@ export interface FontMetrics {
1922
capHeight: number;
2023
/** The height of the main body of lower case letters above baseline */
2124
xHeight: number;
22-
/** The average width of lowercase characters (currently derived from latin character frequencies in English language) */
25+
/**
26+
* The average width of character glyphs in the font for the Latin unicode subset.
27+
*
28+
* Calculated based on character frequencies in written text.
29+
* */
2330
xWidthAvg: number;
31+
/** A lookup of the `xWidthAvg` metric by unicode subset */
32+
subsets?: Record<SupportedSubset, { xWidthAvg: number }>;
2433
}
2534

2635
export type ComputedValues = {

packages/metrics/README.md

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,20 @@ const capsizeStyles = createStyleObject({
4141

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

44-
| Property | Type | Description |
45-
| ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46-
| familyName | string | The font family name as authored by font creator |
47-
| category | string | The style of the font: serif, sans-serif, monospace, display, or handwriting. |
48-
| capHeight | number | The height of capital letters above the baseline |
49-
| ascent | number | The height of the ascenders above baseline |
50-
| descent | number | The descent of the descenders below baseline |
51-
| lineGap | number | The amount of space included between lines |
52-
| unitsPerEm | number | The size of the font’s internal coordinate grid |
53-
| xHeight | number | The height of the main body of lower case letters above baseline |
54-
| 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. |
44+
| Property | Type | Description |
45+
| ---------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46+
| familyName | string | The font family name as authored by font creator |
47+
| category | string | The style of the font: serif, sans-serif, monospace, display, or handwriting. |
48+
| capHeight | number | The height of capital letters above the baseline |
49+
| ascent | number | The height of the ascenders above baseline |
50+
| descent | number | The descent of the descenders below baseline |
51+
| lineGap | number | The amount of space included between lines |
52+
| unitsPerEm | number | The size of the font’s internal coordinate grid |
53+
| xHeight | number | The height of the main body of lower case letters above baseline |
54+
| 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. |
55+
| subsets | {<br/>[subset]: { xWidthAvg: number }<br/>} | A lookup of the `xWidthAvg` metric by subset (see [supported subsets below]) |
56+
57+
[supported subsets below]: #subsets
5558

5659
#### How `xWidthAvg` is calculated
5760

@@ -61,15 +64,41 @@ The value takes a weighted average of character glyph widths in the font, fallin
6164
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`].
6265

6366
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.
64-
Currently only supporting English ([source](https://en.wikinews.org/)).
67+
Below is the source analysed for each supported subset:
6568

69+
| Subset | Language |
70+
| ------- | -------------------------------------------- |
71+
| `latin` | English ([source](https://en.wikinews.org/)) |
72+
| `thai` | Thai ([source](https://th.wikinews.org/)) |
73+
74+
> [!TIP]
75+
> Need support for a different unicode subset?
76+
> Either create an issue or follow the steps outlined in the [`generate-weightings` script] in the `unpack` package and open a PR.
77+
78+
For more information on how to access the metrics for different subsets, see the [subsets](#subsets) section below.
79+
80+
[`generate-weightings` script]: ../unpack/scripts/generate-weightings.ts
6681
[see below]: #how-xwidthavg-is-calculated
6782
[xavgcharwidth]: https://learn.microsoft.com/en-us/typography/opentype/spec/os2#xavgcharwidth
6883
[`ascent-override`]: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/ascent-override
6984
[`size-adjust`]: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/size-adjust
7085
[`createfontstack`]: ../core/README.md#createfontstack
7186
[wikinews]: https://www.wikinews.org/
7287

88+
## Subsets
89+
90+
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.
91+
92+
For example:
93+
94+
```ts
95+
import arial from '@capsizecss/metrics/arial';
96+
97+
const xWidthAvgDefault = arial.xWidthAvg;
98+
const xWidthAvgLatin = arial.subsets.latin.xWidthAvg; // Same as above
99+
const xWidthAvgThai = arial.subsets.thai.xWidthAvg;
100+
```
101+
73102
## Supporting APIs
74103

75104
### `fontFamilyToCamelCase`

packages/metrics/scripts/generate.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const buildFiles = async ({
3333
unitsPerEm,
3434
xHeight,
3535
xWidthAvg,
36+
subsets,
3637
}: MetricsFont) => {
3738
const fileName = fontFamilyToCamelCase(familyName);
3839
const data = {
@@ -45,6 +46,7 @@ const buildFiles = async ({
4546
unitsPerEm,
4647
xHeight,
4748
xWidthAvg,
49+
subsets,
4850
};
4951

5052
const typeName = `${fileName.charAt(0).toUpperCase()}${fileName.slice(
@@ -100,14 +102,23 @@ const buildFiles = async ({
100102
xWidthAvg: number;`
101103
: ''
102104
}
105+
subsets: {
106+
${Object.keys(subsets).map(
107+
(s) => `${s}: {
108+
xWidthAvg: number;
109+
}`,
110+
).join(`,
111+
`)}
112+
}
103113
}
104114
export const fontMetrics: ${typeName};
105115
export default fontMetrics;
106-
`;
116+
}\n
117+
`;
107118

108119
await writeMetricsFile(`${fileName}.cjs`, cjsOutput);
109120
await writeMetricsFile(`${fileName}.mjs`, mjsOutput);
110-
await writeMetricsFile(`${fileName}.d.ts`, `${typesOutput}\n}\n`);
121+
await writeMetricsFile(`${fileName}.d.ts`, typesOutput);
111122
};
112123

113124
(async () => {

0 commit comments

Comments
 (0)