Skip to content

Commit

Permalink
Refactor and add more tests to next/font/local (#46627)
Browse files Browse the repository at this point in the history
Refactor the logic of picking the font file to use for the font fallback
generation. Add additional tests that checks that the correct font file
is picked. Also adds more comments explaining the logic and assumptions
behind the functions.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ]
[e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
hanneslund committed Mar 1, 2023
1 parent 63d81de commit 7bf5dcb
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 94 deletions.
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest-setup-after-env.ts'],
verbose: true,
rootDir: 'test',
roots: ['<rootDir>', '<rootDir>/../packages/next/src/'],
roots: [
'<rootDir>',
'<rootDir>/../packages/next/src/',
'<rootDir>/../packages/font/src/',
],
modulePaths: ['<rootDir>/lib'],
transformIgnorePatterns: ['/next[/\\\\]dist/', '/\\.next/'],
globals: {
Expand Down
110 changes: 17 additions & 93 deletions packages/font/src/local/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,10 @@ import type { AdjustFontFallback, FontLoader } from 'next/font'

import { promisify } from 'util'
import { validateData } from './utils'
import { calculateFallbackFontValues, nextFontError } from '../utils'
import { calculateFallbackFontValues } from '../utils'
import { pickFontFileForFallbackGeneration } from './pick-font-file-for-fallback-generation'

const NORMAL_WEIGHT = 400
const BOLD_WEIGHT = 700

function getWeightNumber(weight: string) {
// Weight can be 'normal', 'bold' or a number https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight
return weight === 'normal'
? NORMAL_WEIGHT
: weight === 'bold'
? BOLD_WEIGHT
: Number(weight)
}
function getDistanceFromNormalWeight(weight?: string) {
if (!weight) return 0

const [firstWeight, secondWeight] = weight
.trim()
.split(/ +/)
.map(getWeightNumber)

if (Number.isNaN(firstWeight) || Number.isNaN(secondWeight)) {
nextFontError(
`Invalid weight value in src array: \`${weight}\`.\nExpected \`normal\`, \`bold\` or a number.`
)
}

// Not a variable font
if (!secondWeight) {
return firstWeight - NORMAL_WEIGHT
}

// Normal weight is within variable font range
if (firstWeight <= NORMAL_WEIGHT && secondWeight >= NORMAL_WEIGHT) {
return 0
}

// Return the distance of normal weight to the variable font range
const firstWeightDistance = firstWeight - NORMAL_WEIGHT
const secondWeightDistance = secondWeight - NORMAL_WEIGHT
if (Math.abs(firstWeightDistance) < Math.abs(secondWeightDistance)) {
return firstWeightDistance
}
return secondWeightDistance
}

const fetchFonts: FontLoader = async ({
const nextFontLocalFontLoader: FontLoader = async ({
functionName,
variableName,
data,
Expand All @@ -71,6 +28,8 @@ const fetchFonts: FontLoader = async ({
style: defaultStyle,
} = validateData(functionName, data[0])

// Load all font files and emit them to the .next output directory
// Also generate a @font-face CSS for each font file
const fontFiles = await Promise.all(
src.map(async ({ path, style, weight, ext, format }) => {
const resolved = await resolve(path)
Expand All @@ -82,13 +41,16 @@ const fetchFonts: FontLoader = async ({
typeof adjustFontFallback === 'undefined' || !!adjustFontFallback
)

// Try to load font metadata from the font file using fontkit.
// The data is used to calculate the fallback font override values.
let fontMetadata: any
try {
fontMetadata = fontFromBuffer(fileBuffer)
} catch (e) {
console.error(`Failed to load font file: ${resolved}\n${e}`)
}

// Get all values that should be added to the @font-face declaration
const fontFaceProperties = [
...(declarations
? declarations.map(({ prop, value }) => [prop, value])
Expand All @@ -104,62 +66,24 @@ const fetchFonts: FontLoader = async ({
: []),
]

// Generate the @font-face CSS from the font-face properties
const css = `@font-face {\n${fontFaceProperties
.map(([property, value]) => `${property}: ${value};`)
.join('\n')}\n}\n`

return {
css: `@font-face {
${fontFaceProperties
.map(([property, value]) => `${property}: ${value};`)
.join('\n')}
}\n`,
css,
fontMetadata,
weight,
style,
}
})
)

// Add fallback font
// Calculate the fallback font override values using the font file metadata
let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (adjustFontFallback !== false) {
// Pick the font file to generate a fallback font from.
// Prefer the file closest to normal weight, this will typically make up most of the text on a page.
const fallbackFontFile = fontFiles.reduce(
(usedFontFile, currentFontFile) => {
if (!usedFontFile) return currentFontFile

const usedFontDistance = getDistanceFromNormalWeight(
usedFontFile.weight
)
const currentFontDistance = getDistanceFromNormalWeight(
currentFontFile.weight
)

// Prefer normal style if they have the same weight
if (
usedFontDistance === currentFontDistance &&
(typeof currentFontFile.style === 'undefined' ||
currentFontFile.style === 'normal')
) {
return currentFontFile
}

const absUsedDistance = Math.abs(usedFontDistance)
const absCurrentDistance = Math.abs(currentFontDistance)

// Use closest absolute distance to normal weight
if (absCurrentDistance < absUsedDistance) return currentFontFile

// Prefer the thinner font if both are the same absolute distance from normal weight
if (
absUsedDistance === absCurrentDistance &&
currentFontDistance < usedFontDistance
) {
return currentFontFile
}

return usedFontFile
}
)

const fallbackFontFile = pickFontFileForFallbackGeneration(fontFiles)
if (fallbackFontFile.fontMetadata) {
adjustFontFallbackMetrics = calculateFallbackFontValues(
fallbackFontFile.fontMetadata,
Expand All @@ -178,4 +102,4 @@ ${fontFaceProperties
}
}

export default fetchFonts
export default nextFontLocalFontLoader
116 changes: 116 additions & 0 deletions packages/font/src/local/pick-font-file-for-fallback-generation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { pickFontFileForFallbackGeneration } from './pick-font-file-for-fallback-generation'

describe('pickFontFileForFallbackGeneration', () => {
it('should pick the weight closest to 400', () => {
expect(
pickFontFileForFallbackGeneration([
{
weight: '300',
},
{
weight: '600',
},
])
).toEqual({
weight: '300',
})

expect(
pickFontFileForFallbackGeneration([
{ weight: '200' },
{
weight: '500',
},
])
).toEqual({
weight: '500',
})

expect(
pickFontFileForFallbackGeneration([
{
weight: 'normal',
},
{
weight: '700',
},
])
).toEqual({
weight: 'normal',
})

expect(
pickFontFileForFallbackGeneration([
{
weight: 'bold',
},
{
weight: '900',
},
])
).toEqual({
weight: 'bold',
})
})

it('should pick the thinner weight if both have the same distance to 400', () => {
expect(
pickFontFileForFallbackGeneration([
{
weight: '300',
},
{
weight: '500',
},
])
).toEqual({
weight: '300',
})
})

it('should pick variable range closest to 400', () => {
expect(
pickFontFileForFallbackGeneration([
{
weight: '100 300',
},
{
weight: '600 900',
},
])
).toEqual({
weight: '100 300',
})

expect(
pickFontFileForFallbackGeneration([
{ weight: '100 200' },
{
weight: '500 800',
},
])
).toEqual({
weight: '500 800',
})

expect(
pickFontFileForFallbackGeneration([
{ weight: '100 900' },
{
weight: '300 399',
},
])
).toEqual({
weight: '100 900',
})
})

it('should prefer normal style over italic', () => {
expect(
pickFontFileForFallbackGeneration([
{ weight: '400', style: 'normal' },
{ weight: '400', style: 'italic' },
])
).toEqual({ weight: '400', style: 'normal' })
})
})
104 changes: 104 additions & 0 deletions packages/font/src/local/pick-font-file-for-fallback-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { nextFontError } from '../utils'

const NORMAL_WEIGHT = 400
const BOLD_WEIGHT = 700

/**
* Convert the weight string to a number so it can be used for comparison.
* Weights can be defined as a number, 'normal' or 'bold'. https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight
*/
function getWeightNumber(weight: string) {
return weight === 'normal'
? NORMAL_WEIGHT
: weight === 'bold'
? BOLD_WEIGHT
: Number(weight)
}

/**
* Get the distance from normal (400) weight for the provided weight.
* If it's not a variable font we can just return the distance.
* If it's a variable font we need to compare its weight range to 400.
*/
function getDistanceFromNormalWeight(weight?: string) {
if (!weight) return 0

// If it's a variable font the weight is defined with two numbers "100 900", rather than just one "400"
const [firstWeight, secondWeight] = weight
.trim()
.split(/ +/)
.map(getWeightNumber)

if (Number.isNaN(firstWeight) || Number.isNaN(secondWeight)) {
nextFontError(
`Invalid weight value in src array: \`${weight}\`.\nExpected \`normal\`, \`bold\` or a number.`
)
}

// If the weight doesn't have have a second value, it's not a variable font
// If that's the case, just return the distance from normal weight
if (!secondWeight) {
return firstWeight - NORMAL_WEIGHT
}

// Normal weight is within variable font range
if (firstWeight <= NORMAL_WEIGHT && secondWeight >= NORMAL_WEIGHT) {
return 0
}

// Normal weight is outside variable font range
// Return the distance of normal weight to the variable font range
const firstWeightDistance = firstWeight - NORMAL_WEIGHT
const secondWeightDistance = secondWeight - NORMAL_WEIGHT
if (Math.abs(firstWeightDistance) < Math.abs(secondWeightDistance)) {
return firstWeightDistance
}
return secondWeightDistance
}

/**
* If multiple font files are provided for a font family, we need to pick one to use for the automatic fallback generation.
* This function returns the font file that is most likely to be used for the bulk of the text on a page.
*
* There are some assumptions here about the text on a page when picking the font file:
* - Most of the text will have normal weight, use the one closest to 400
* - Most of the text will have normal style, prefer normal over italic
* - If two font files have the same distance from normal weight, the thinner one will most likely be the bulk of the text
*/
export function pickFontFileForFallbackGeneration<
T extends { style?: string; weight?: string }
>(fontFiles: T[]): T {
return fontFiles.reduce((usedFontFile, currentFontFile) => {
if (!usedFontFile) return currentFontFile

const usedFontDistance = getDistanceFromNormalWeight(usedFontFile.weight)
const currentFontDistance = getDistanceFromNormalWeight(
currentFontFile.weight
)

// Prefer normal style if they have the same weight
if (
usedFontDistance === currentFontDistance &&
(typeof currentFontFile.style === 'undefined' ||
currentFontFile.style === 'normal')
) {
return currentFontFile
}

const absUsedDistance = Math.abs(usedFontDistance)
const absCurrentDistance = Math.abs(currentFontDistance)

// Use closest absolute distance to normal weight
if (absCurrentDistance < absUsedDistance) return currentFontFile

// Prefer the thinner font if both have the same absolute distance from normal weight
if (
absUsedDistance === absCurrentDistance &&
currentFontDistance < usedFontDistance
) {
return currentFontFile
}

return usedFontFile
})
}

0 comments on commit 7bf5dcb

Please sign in to comment.