Skip to content

Commit 4913379

Browse files
authored
feat!: size-adjust metric calculation (#181)
1 parent 7114a0e commit 4913379

File tree

8 files changed

+94
-41
lines changed

8 files changed

+94
-41
lines changed

playground/index.css

+13-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
@font-face {
88
font-family: 'Roboto';
99
font-display: swap;
10-
src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2)
10+
src: url('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2')
11+
format('woff2');
12+
}
13+
14+
@font-face {
15+
font-family: 'Inter';
16+
font-display: swap;
17+
src: url('https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2')
1118
format('woff2');
1219
}
1320

@@ -20,7 +27,7 @@ h1 {
2027
font-family: 'Poppins variant', sans-serif;
2128
}
2229

23-
h2 {
30+
.roboto {
2431
font-family: 'Roboto', Arial, Helvetica, sans-serif;
2532
}
2633

@@ -31,3 +38,7 @@ p {
3138
div {
3239
font-family: var(--someFont);
3340
}
41+
42+
.inter {
43+
font-family: Inter;
44+
}

playground/index.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
<body>
1212
<div>
1313
<h1>A headline</h1>
14-
<h2>A subheading</h2>
14+
<h2 class="roboto">A subheading</h2>
1515
<p>
1616
Id occaecat labore et adipisicing excepteur consequat et culpa pariatur quis qui officia non
1717
cillum. Adipisicing aliquip occaecat non est minim nulla esse. Mollit in ex esse Lorem
1818
consectetur elit consequat quis adipisicing enim et culpa. Irure nostrud laboris consequat
1919
veniam dolor quis ullamco sint.
2020
</p>
21-
<p>
21+
<p class="inter">
2222
Consequat elit anim ex mollit cillum eiusmod voluptate. Sunt dolor Lorem proident esse amet
2323
duis velit amet consectetur qui voluptate sint adipisicing. Voluptate nostrud non quis laborum
2424
veniam commodo duis laboris dolore veniam commodo amet. Officia cillum est sunt anim ullamco

src/css.ts

+40-25
Original file line numberDiff line numberDiff line change
@@ -36,51 +36,66 @@ export const generateFallbackName = (name: string) => {
3636

3737
export const withoutQuotes = (str: string) => str.trim().replace(QUOTES_RE, '')
3838

39-
interface GenerateOptions {
39+
interface FallbackOptions {
4040
name: string
41-
fallbacks: string[]
41+
font: string
42+
metrics: FontFaceMetrics
4243
[key: string]: any
4344
}
4445

4546
export type FontFaceMetrics = Pick<
4647
Font,
47-
'ascent' | 'descent' | 'lineGap' | 'unitsPerEm'
48+
'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
4849
>
50+
4951
export const generateFontFace = (
5052
metrics: FontFaceMetrics,
51-
options: GenerateOptions
53+
fallback: FallbackOptions
5254
) => {
53-
const { name, fallbacks, ...properties } = options
55+
const {
56+
name: fallbackName,
57+
font: fallbackFontName,
58+
metrics: fallbackMetrics,
59+
...properties
60+
} = fallback
61+
62+
// Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts
63+
64+
// Calculate size adjust
65+
const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm
66+
const fallbackFontXAvgRatio = fallbackMetrics
67+
? fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm
68+
: 1
69+
70+
const sizeAdjust =
71+
fallbackMetrics && preferredFontXAvgRatio && fallbackFontXAvgRatio
72+
? preferredFontXAvgRatio / fallbackFontXAvgRatio
73+
: 1
74+
75+
const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust
5476

55-
// TODO: implement size-adjust: 'width' of web font / 'width' of fallback font
56-
const sizeAdjust = 1
77+
// Calculate metric overrides for preferred font
78+
const ascentOverride = metrics.ascent / adjustedEmSquare
79+
const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare
80+
const lineGapOverride = metrics.lineGap / adjustedEmSquare
5781

5882
const declaration = {
59-
'font-family': JSON.stringify(name),
60-
src: fallbacks.map(f => `local(${JSON.stringify(f)})`),
61-
// 'size-adjust': toPercentage(sizeAdjust),
62-
'ascent-override': toPercentage(
63-
metrics.ascent / (metrics.unitsPerEm * sizeAdjust)
64-
),
65-
'descent-override': toPercentage(
66-
Math.abs(metrics.descent / (metrics.unitsPerEm * sizeAdjust))
67-
),
68-
'line-gap-override': toPercentage(
69-
metrics.lineGap / (metrics.unitsPerEm * sizeAdjust)
70-
),
83+
'font-family': JSON.stringify(fallbackName),
84+
src: `local(${JSON.stringify(fallbackFontName)})`,
85+
'size-adjust': toPercentage(sizeAdjust),
86+
'ascent-override': toPercentage(ascentOverride),
87+
'descent-override': toPercentage(descentOverride),
88+
'line-gap-override': toPercentage(lineGapOverride),
7189
...properties,
7290
}
7391

7492
return `@font-face {\n${toCSS(declaration)}\n}\n`
7593
}
7694

77-
const toPercentage = (value: number, fractionDigits = 8) => {
95+
// See: https://github.com/seek-oss/capsize/blob/master/packages/core/src/round.ts
96+
const toPercentage = (value: number, fractionDigits = 4) => {
7897
const percentage = value * 100
79-
return (
80-
(percentage % 1
81-
? percentage.toFixed(fractionDigits).replace(/0+$/, '')
82-
: percentage) + '%'
83-
)
98+
return +percentage.toFixed(fractionDigits) + '%'
8499
}
85100

86101
const toCSS = (properties: Record<string, any>, indent = 2) =>

src/metrics.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ const filterRequiredMetrics = ({
1212
descent,
1313
lineGap,
1414
unitsPerEm,
15-
}: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm'>) => ({
15+
xWidthAvg,
16+
}: Pick<
17+
Font,
18+
'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
19+
>) => ({
1620
ascent,
1721
descent,
1822
lineGap,
1923
unitsPerEm,
24+
xWidthAvg,
2025
})
2126

2227
export async function getMetricsForFamily(family: string) {

src/transform.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,23 @@ export const FontaineTransform = createUnplugin(
7373
(source &&
7474
(await readMetricsFromId(source, id).catch(() => null)))
7575

76-
if (metrics) {
76+
if (!metrics) {
77+
continue
78+
}
79+
80+
// Iterate backwards: Browsers will use the last working font-face in the stylesheet
81+
for (let i = options.fallbacks.length - 1; i >= 0; i--) {
82+
const fallback = options.fallbacks[i]
83+
const fallbackMetrics = await getMetricsForFamily(fallback)
84+
85+
if (!fallbackMetrics) {
86+
continue
87+
}
88+
7789
const fontFace = generateFontFace(metrics, {
7890
name: fallbackName(family),
79-
fallbacks: options.fallbacks,
91+
font: fallback,
92+
metrics: fallbackMetrics,
8093
})
8194
cssContext.value += fontFace
8295
s.appendLeft(match.index, fontFace)

test/e2e.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('fontaine', () => {
1616
// @ts-expect-error there must be a file or we _want_ a test failure
1717
const css = await readFile(join(assetsDir, cssFile), 'utf-8')
1818
expect(css.replace(/\.[\w]+\.woff2/g, '.woff2')).toMatchInlineSnapshot(`
19-
"@font-face{font-family:Poppins variant fallback;src:local(\\"Arial\\");ascent-override:105%;descent-override:35%;line-gap-override:10%}@font-face{font-family:Poppins variant;font-display:swap;src:url(/assets/font-707fdc5c.ttf) format(\\"truetype\\")}@font-face{font-family:Roboto fallback;src:local(\\"Arial\\");ascent-override:92.7734375%;descent-override:24.4140625%;line-gap-override:0%}@font-face{font-family:Roboto;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format(\\"woff2\\")}:root{--someFont: \\"Poppins variant\\", \\"Poppins variant fallback\\"}h1{font-family:Poppins variant,Poppins variant fallback,sans-serif}h2{font-family:Roboto,Roboto fallback,Arial,Helvetica,sans-serif}p{font-family:Poppins variant,Poppins variant fallback}div{font-family:var(--someFont)}
19+
"@font-face{font-family:Poppins variant fallback;src:local(\\"Segoe UI\\");size-adjust:113.4764%;ascent-override:92.5303%;descent-override:30.8434%;line-gap-override:8.8124%}@font-face{font-family:Poppins variant fallback;src:local(\\"Arial\\");size-adjust:113.7274%;ascent-override:92.326%;descent-override:30.7753%;line-gap-override:8.793%}@font-face{font-family:Poppins variant;font-display:swap;src:url(/assets/font-707fdc5c.ttf) format(\\"truetype\\")}@font-face{font-family:Roboto fallback;src:local(\\"Segoe UI\\");size-adjust:99.8896%;ascent-override:92.8759%;descent-override:24.441%;line-gap-override:0%}@font-face{font-family:Roboto fallback;src:local(\\"Arial\\");size-adjust:100.1106%;ascent-override:92.6709%;descent-override:24.3871%;line-gap-override:0%}@font-face{font-family:Roboto;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format(\\"woff2\\")}@font-face{font-family:Inter fallback;src:local(\\"Segoe UI\\");size-adjust:107.1644%;ascent-override:90.3985%;descent-override:22.5334%;line-gap-override:0%}@font-face{font-family:Inter fallback;src:local(\\"Arial\\");size-adjust:107.4014%;ascent-override:90.199%;descent-override:22.4836%;line-gap-override:0%}@font-face{font-family:Inter;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2) format(\\"woff2\\")}:root{--someFont: \\"Poppins variant\\", \\"Poppins variant fallback\\"}h1{font-family:Poppins variant,Poppins variant fallback,sans-serif}.roboto{font-family:Roboto,Roboto fallback,Arial,Helvetica,sans-serif}p{font-family:Poppins variant,Poppins variant fallback}div{font-family:var(--someFont)}.inter{font-family:Inter,Inter fallback}
2020
"
2121
`)
2222
})

test/index.spec.ts

+16-7
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ describe('generateFontFace', () => {
1919
// @ts-expect-error if metrics is not defined the test should throw
2020
const result = generateFontFace(metrics, {
2121
name: 'example fallback',
22-
fallbacks: ['fallback'],
22+
font: 'fallback',
2323
'font-weight': 'bold',
2424
})
2525
expect(result).toMatchInlineSnapshot(`
2626
"@font-face {
2727
font-family: \\"example fallback\\";
2828
src: local(\\"fallback\\");
29+
size-adjust: 100%;
2930
ascent-override: 105%;
3031
descent-override: 35%;
3132
line-gap-override: 10%;
@@ -51,6 +52,7 @@ describe('getMetricsForFamily', () => {
5152
"descent": -546,
5253
"lineGap": 0,
5354
"unitsPerEm": 2000,
55+
"xWidthAvg": 936,
5456
}
5557
`)
5658
// Test cache
@@ -60,14 +62,16 @@ describe('getMetricsForFamily', () => {
6062
// eslint-disable-next-line
6163
generateFontFace(metrics!, {
6264
name: 'Merriweather Sans fallback',
63-
fallbacks: ['Arial'],
65+
font: 'Arial',
66+
metrics: (await getMetricsForFamily('Arial'))!,
6467
})
6568
).toMatchInlineSnapshot(`
6669
"@font-face {
6770
font-family: \\"Merriweather Sans fallback\\";
6871
src: local(\\"Arial\\");
69-
ascent-override: 98.4%;
70-
descent-override: 27.3%;
72+
size-adjust: 106.0248%;
73+
ascent-override: 92.8085%;
74+
descent-override: 25.7487%;
7175
line-gap-override: 0%;
7276
}
7377
"
@@ -82,6 +86,7 @@ describe('getMetricsForFamily', () => {
8286
"descent": -275,
8387
"lineGap": 0,
8488
"unitsPerEm": 1000,
89+
"xWidthAvg": 600,
8590
}
8691
`)
8792
// Test cache
@@ -91,14 +96,16 @@ describe('getMetricsForFamily', () => {
9196
// eslint-disable-next-line
9297
generateFontFace(metrics!, {
9398
name: 'IBM Plex Mono fallback',
94-
fallbacks: ['Arial'],
99+
font: 'Arial',
100+
metrics: (await getMetricsForFamily('Arial'))!,
95101
})
96102
).toMatchInlineSnapshot(`
97103
"@font-face {
98104
font-family: \\"IBM Plex Mono fallback\\";
99105
src: local(\\"Arial\\");
100-
ascent-override: 102.5%;
101-
descent-override: 27.5%;
106+
size-adjust: 135.9292%;
107+
ascent-override: 75.4069%;
108+
descent-override: 20.2311%;
102109
line-gap-override: 0%;
103110
}
104111
"
@@ -127,6 +134,7 @@ describe('readMetrics', () => {
127134
"descent": -350,
128135
"lineGap": 100,
129136
"unitsPerEm": 1000,
137+
"xWidthAvg": 502,
130138
}
131139
`)
132140
})
@@ -145,6 +153,7 @@ describe('readMetrics', () => {
145153
"descent": -350,
146154
"lineGap": 100,
147155
"unitsPerEm": 1000,
156+
"xWidthAvg": 502,
148157
}
149158
`)
150159
server.close()

test/vite.config.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default defineConfig({
66
root: '../playground',
77
plugins: [
88
FontaineTransform.vite({
9-
fallbacks: ['Arial'],
9+
fallbacks: ['Arial', 'Segoe UI'],
1010
// resolve absolute URL -> file
1111
resolvePath: id =>
1212
new URL(join('../playground', '.' + id), import.meta.url),

0 commit comments

Comments
 (0)