Skip to content

Commit

Permalink
Multiple local font weights and styles (#42232)
Browse files Browse the repository at this point in the history
Allows you to specify multiple weights and styles for a
`@next/font/local` font family.

When generating a fallback font family it will pick the font file with
the weight closest to normal, this will typically make up most of the
text on a page.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `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`
- [ ] Integration 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`

## 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
Hannes Bornö committed Oct 31, 2022
1 parent cc0c85e commit ca1946b
Show file tree
Hide file tree
Showing 13 changed files with 558 additions and 69 deletions.
8 changes: 7 additions & 1 deletion packages/font/src/local/index.ts
Expand Up @@ -3,7 +3,13 @@ import type { FontModule } from 'next/font'
type Display = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
type CssVariable = `--${string}`
type LocalFont = {
src: string
src:
| string
| Array<{
path: string
weight?: string
style?: string
}>
display?: Display
weight?: string
style?: string
Expand Down
173 changes: 135 additions & 38 deletions packages/font/src/local/loader.ts
Expand Up @@ -7,6 +7,50 @@ import { promisify } from 'util'
import { validateData } from './utils'
import { calculateFallbackFontValues } from '../utils'

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)) {
throw new Error(
`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 ({
functionName,
data,
Expand All @@ -17,60 +61,113 @@ const fetchFonts: FontLoader = async ({
const {
family,
src,
ext,
format,
display,
weight,
style,
fallback,
preload,
variable,
adjustFontFallback,
declarations,
} = validateData(functionName, data)
weight: defaultWeight,
style: defaultStyle,
} = validateData(functionName, data[0])

const resolved = await resolve(src)
const fileBuffer = await promisify(fs.readFile)(resolved)
const fontUrl = emitFontFile(fileBuffer, ext, preload)
const fontFiles = await Promise.all(
src.map(async ({ path, style, weight, ext, format }) => {
const resolved = await resolve(path)
const fileBuffer = await promisify(fs.readFile)(resolved)
const fontUrl = emitFontFile(fileBuffer, ext, preload)

let fontMetadata: any
try {
fontMetadata = fontFromBuffer(fileBuffer)
} catch (e) {
console.error(`Failed to load font file: ${resolved}\n${e}`)
}
let fontMetadata: any
try {
fontMetadata = fontFromBuffer(fileBuffer)
} catch (e) {
console.error(`Failed to load font file: ${resolved}\n${e}`)
}

// Add fallback font
let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (fontMetadata && adjustFontFallback !== false) {
adjustFontFallbackMetrics = calculateFallbackFontValues(
fontMetadata,
adjustFontFallback === 'Times New Roman' ? 'serif' : 'sans-serif'
)
}
const fontFaceProperties = [
...(declarations
? declarations.map(({ prop, value }) => [prop, value])
: []),
['font-family', `'${fontMetadata?.familyName ?? family}'`],
['src', `url(${fontUrl}) format('${format}')`],
['font-display', display],
...(weight ?? defaultWeight
? [['font-weight', weight ?? defaultWeight]]
: []),
...(style ?? defaultStyle
? [['font-style', style ?? defaultStyle]]
: []),
]

const fontFaceProperties = [
...(declarations
? declarations.map(({ prop, value }) => [prop, value])
: []),
['font-family', `'${fontMetadata?.familyName ?? family}'`],
['src', `url(${fontUrl}) format('${format}')`],
['font-display', display],
...(weight ? [['font-weight', weight]] : []),
...(style ? [['font-style', style]] : []),
]

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

// Add fallback font
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
}
)

if (fallbackFontFile.fontMetadata) {
adjustFontFallbackMetrics = calculateFallbackFontValues(
fallbackFontFile.fontMetadata,
adjustFontFallback === 'Times New Roman' ? 'serif' : 'sans-serif'
)
}
}

return {
css,
css: fontFiles.map(({ css }) => css).join('\n'),
fallbackFonts: fallback,
weight,
style,
weight: src.length === 1 ? src[0].weight : undefined,
style: src.length === 1 ? src[0].style : undefined,
variable,
adjustFontFallback: adjustFontFallbackMetrics,
}
Expand Down
44 changes: 32 additions & 12 deletions packages/font/src/local/utils.ts
Expand Up @@ -13,9 +13,13 @@ const extToFormat = {

type FontOptions = {
family: string
src: string
ext: string
format: string
src: Array<{
path: string
weight?: string
style?: string
ext: string
format: string
}>
display: string
weight?: string
style?: string
Expand All @@ -25,7 +29,7 @@ type FontOptions = {
adjustFontFallback?: string | false
declarations?: Array<{ prop: string; value: string }>
}
export function validateData(functionName: string, data: any): FontOptions {
export function validateData(functionName: string, fontData: any): FontOptions {
if (functionName) {
throw new Error(`@next/font/local has no named exports`)
}
Expand All @@ -39,7 +43,7 @@ export function validateData(functionName: string, data: any): FontOptions {
variable,
adjustFontFallback,
declarations,
} = data[0] || ({} as any)
} = fontData || ({} as any)

if (!allowedDisplayValues.includes(display)) {
throw new Error(
Expand All @@ -53,12 +57,30 @@ export function validateData(functionName: string, data: any): FontOptions {
throw new Error('Missing required `src` property')
}

const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(src)?.[1]
if (!ext) {
throw new Error(`Unexpected file \`${src}\``)
if (!Array.isArray(src)) {
src = [{ path: src, weight, style }]
} else {
if (src.length === 0) {
throw new Error('Unexpected empty `src` array.')
}
}

const family = /(.*\/)?(.+?)\.(woff|woff2|eot|ttf|otf)$/.exec(src)![2]
let family: string | undefined
src = src.map((fontFile: any) => {
const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(fontFile.path)?.[1]
if (!ext) {
throw new Error(`Unexpected file \`${fontFile.path}\``)
}
if (!family) {
family = /(.*\/)?(.+?)\.(woff|woff2|eot|ttf|otf)$/.exec(fontFile.path)![2]
}

return {
...fontFile,
ext,
format: extToFormat[ext as 'woff' | 'woff2' | 'eot' | 'ttf' | 'otf'],
}
})

if (Array.isArray(declarations)) {
declarations.forEach((declaration) => {
Expand All @@ -77,10 +99,8 @@ export function validateData(functionName: string, data: any): FontOptions {
}

return {
family,
family: family!,
src,
ext,
format: extToFormat[ext as 'woff' | 'woff2' | 'eot' | 'ttf' | 'otf'],
display,
weight,
style,
Expand Down
Expand Up @@ -42,14 +42,8 @@ const postcssFontLoaderPlugn = ({
continue
}

const currentFamily = normalizeFamily(familyNode.value)

if (!fontFamily) {
fontFamily = currentFamily
} else if (fontFamily !== currentFamily) {
throw new Error(
`Font family mismatch, expected ${fontFamily} but got ${currentFamily}`
)
fontFamily = normalizeFamily(familyNode.value)
}

familyNode.value = formatFamily(fontFamily)
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 comments on commit ca1946b

Please sign in to comment.