Skip to content

Commit

Permalink
Local font files adjust fallbacks (#41180)
Browse files Browse the repository at this point in the history
When using `@next/font/local` we can't have pre calculated metrics for the font. This PR uses fontkit to read metadata about the imported local font in order to generate a fallback.

Also removes some props from the api that's better done automatically in the future.

## 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 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 10, 2022
1 parent 6e4f1e2 commit 05498a0
Show file tree
Hide file tree
Showing 17 changed files with 386 additions and 288 deletions.
3 changes: 3 additions & 0 deletions packages/font/fontkit.js
@@ -0,0 +1,3 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { create } from 'fontkit'
export default create
12 changes: 9 additions & 3 deletions packages/font/package.json
Expand Up @@ -12,9 +12,15 @@
],
"license": "MIT",
"scripts": {
"build": "rm -rf dist && tsc -d -p tsconfig.json",
"build": "rm -rf dist && pnpm ncc-fontkit && tsc -d -p tsconfig.json",
"prepublishOnly": "cd ../../ && turbo run build",
"dev": "tsc -d -w -p tsconfig.json",
"typescript": "tsec --noEmit -p tsconfig.json"
"dev": "pnpm ncc-fontkit && tsc -d -w -p tsconfig.json",
"typescript": "tsec --noEmit -p tsconfig.json",
"ncc-fontkit": "ncc build ./fontkit.js -o dist/fontkit"
},
"devDependencies": {
"@types/fontkit": "2.0.0",
"@vercel/ncc": "0.34.0",
"fontkit": "2.0.2"
}
}
16 changes: 8 additions & 8 deletions packages/font/src/google/loader.ts
Expand Up @@ -2,7 +2,7 @@ import type { AdjustFontFallback, FontLoader } from 'next/font'
// @ts-ignore
import fetch from 'next/dist/compiled/node-fetch'
// @ts-ignore
import { calculateOverrideValues } from 'next/dist/server/font-utils'
import { calculateSizeAdjustValues } from 'next/dist/server/font-utils'
import {
fetchCSSFromGoogleFonts,
getFontAxes,
Expand Down Expand Up @@ -101,16 +101,16 @@ const downloadGoogleFonts: FontLoader = async ({
let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (adjustFontFallback) {
try {
const { ascent, descent, lineGap, fallbackFont } =
calculateOverrideValues(
fontFamily,
require('next/dist/server/google-font-metrics.json')
const { ascent, descent, lineGap, fallbackFont, sizeAdjust } =
calculateSizeAdjustValues(
require('next/dist/server/google-font-metrics.json')[fontFamily]
)
adjustFontFallbackMetrics = {
fallbackFont,
ascentOverride: ascent,
descentOverride: descent,
lineGapOverride: lineGap,
ascentOverride: `${ascent}%`,
descentOverride: `${descent}%`,
lineGapOverride: `${lineGap}%`,
sizeAdjust: `${sizeAdjust}%`,
}
} catch {
console.error(
Expand Down
6 changes: 3 additions & 3 deletions packages/font/src/local/index.ts
@@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { AdjustFontFallback, FontModule } from 'next/font'
import type { FontModule } from 'next/font'
type Display = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
type CssVariable = `--${string}`
type LocalFont = {
src: string | Array<{ file: string; unicodeRange: string }>
src: string
display?: Display
weight?: number
style?: string
Expand All @@ -20,7 +20,7 @@ type LocalFont = {
lineGapOverride?: string
sizeAdjust?: string

adjustFontFallback?: AdjustFontFallback
adjustFontFallback?: 'Arial' | 'Times New Roman' | false
}

export default function localFont(options: LocalFont): FontModule {
Expand Down
91 changes: 63 additions & 28 deletions packages/font/src/local/loader.ts
@@ -1,4 +1,9 @@
import type { FontLoader } from 'next/font'
// @ts-ignore
import { calculateSizeAdjustValues } from 'next/dist/server/font-utils'
// @ts-ignore
// eslint-disable-next-line import/no-extraneous-dependencies
import fontFromBuffer from '@next/font/dist/fontkit'
import type { AdjustFontFallback, FontLoader } from 'next/font'

import { promisify } from 'util'
import { validateData } from './utils'
Expand All @@ -12,7 +17,9 @@ const fetchFonts: FontLoader = async ({
}) => {
const {
family,
files,
src,
ext,
format,
display,
weight,
style,
Expand All @@ -28,45 +35,73 @@ const fetchFonts: FontLoader = async ({
adjustFontFallback,
} = validateData(functionName, data)

const fontFaces = await Promise.all(
files.map(async ({ file, ext, format, unicodeRange }) => {
const resolved = await resolve(file)
const fileBuffer = await promisify(fs.readFile)(resolved)
const resolved = await resolve(src)
const fileBuffer = await promisify(fs.readFile)(resolved)
const fontUrl = emitFontFile(fileBuffer, ext, preload)

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}`)
}

// Add fallback font
let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (fontMetadata && adjustFontFallback !== false) {
const {
ascent,
descent,
lineGap,
fallbackFont,
sizeAdjust: fallbackSizeAdjust,
} = calculateSizeAdjustValues({
category:
adjustFontFallback === 'Times New Roman' ? 'serif' : 'sans-serif',
ascent: fontMetadata.ascent,
descent: fontMetadata.descent,
lineGap: fontMetadata.lineGap,
unitsPerEm: fontMetadata.unitsPerEm,
xAvgCharWidth: (fontMetadata as any)['OS/2']?.xAvgCharWidth,
})
adjustFontFallbackMetrics = {
fallbackFont,
ascentOverride: `${ascent}%`,
descentOverride: `${descent}%`,
lineGapOverride: `${lineGap}%`,
sizeAdjust: `${fallbackSizeAdjust}%`,
}
}

const fontFaceProperties = [
['font-family', `'${family}'`],
['src', `url(${fontUrl}) format('${format}')`],
['font-display', display],
...(weight ? [['font-weight', weight]] : []),
...(style ? [['font-style', style]] : []),
...(ascentOverride ? [['ascent-override', ascentOverride]] : []),
...(descentOverride ? [['descent-override', descentOverride]] : []),
...(lineGapOverride ? [['line-gap-override', lineGapOverride]] : []),
...(fontStretch ? [['font-stretch', fontStretch]] : []),
...(fontFeatureSettings
? [['font-feature-settings', fontFeatureSettings]]
: []),
...(sizeAdjust ? [['size-adjust', sizeAdjust]] : []),
...(unicodeRange ? [['unicode-range', unicodeRange]] : ''),
]
const fontFaceProperties = [
['font-family', `'${fontMetadata?.familyName ?? family}'`],
['src', `url(${fontUrl}) format('${format}')`],
['font-display', display],
...(weight ? [['font-weight', weight]] : []),
...(style ? [['font-style', style]] : []),
...(ascentOverride ? [['ascent-override', ascentOverride]] : []),
...(descentOverride ? [['descent-override', descentOverride]] : []),
...(lineGapOverride ? [['line-gap-override', lineGapOverride]] : []),
...(fontStretch ? [['font-stretch', fontStretch]] : []),
...(fontFeatureSettings
? [['font-feature-settings', fontFeatureSettings]]
: []),
...(sizeAdjust ? [['size-adjust', sizeAdjust]] : []),
]

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

return {
css: fontFaces.join('\n'),
css,
fallbackFonts: fallback,
weight,
style,
variable,
adjustFontFallback,
adjustFontFallback: adjustFontFallbackMetrics,
}
}

Expand Down
50 changes: 14 additions & 36 deletions packages/font/src/local/utils.ts
@@ -1,5 +1,3 @@
import { AdjustFontFallback } from 'next/font'

const allowedDisplayValues = ['auto', 'block', 'swap', 'fallback', 'optional']

const formatValues = (values: string[]) =>
Expand All @@ -15,12 +13,9 @@ const extToFormat = {

type FontOptions = {
family: string
files: Array<{
file: string
ext: string
format: string
unicodeRange?: string
}>
src: string
ext: string
format: string
display: string
weight?: number
style?: string
Expand All @@ -35,7 +30,7 @@ type FontOptions = {
fontVariationSettings?: string
lineGapOverride?: string
sizeAdjust?: string
adjustFontFallback?: AdjustFontFallback
adjustFontFallback?: string | false
}
export function validateData(functionName: string, data: any): FontOptions {
if (functionName) {
Expand Down Expand Up @@ -68,39 +63,22 @@ export function validateData(functionName: string, data: any): FontOptions {
)
}

const srcArray = Array.isArray(src) ? src : [{ file: src }]

if (srcArray.length === 0) {
throw new Error('Src must contain one or more files')
if (!src) {
throw new Error('Missing required `src` property')
}

const files = srcArray.map(({ file, unicodeRange }) => {
if (!file) {
throw new Error('Src array objects must have a `file` property')
}
if (srcArray.length > 1 && !unicodeRange) {
throw new Error(
"Files must have a unicode-range if there's more than one"
)
}

const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(file)?.[1]
if (!ext) {
throw new Error(`Unexpected file \`${file}\``)
}
return {
file,
unicodeRange,
ext,
format: extToFormat[ext as 'woff' | 'woff2' | 'eot' | 'ttf' | 'otf'],
}
})
const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(src)?.[1]
if (!ext) {
throw new Error(`Unexpected file \`${src}\``)
}

const family = /.+\/(.+?)\./.exec(files[0].file)![1]
const family = /.+\/(.+?)\./.exec(src)![1]

return {
family,
files,
src,
ext,
format: extToFormat[ext as 'woff' | 'woff2' | 'eot' | 'ttf' | 'otf'],
display,
weight,
style,
Expand Down
19 changes: 9 additions & 10 deletions packages/next/server/font-utils.ts
Expand Up @@ -102,9 +102,8 @@ function formatOverrideValue(val: number) {
return Math.abs(val * 100).toFixed(2)
}

export function calculateOverrideValues(font: string, fontMetrics: any) {
const fontKey = font.trim()
let { category, ascent, descent, lineGap, unitsPerEm } = fontMetrics[fontKey]
export function calculateOverrideValues(fontMetrics: any) {
let { category, ascent, descent, lineGap, unitsPerEm } = fontMetrics
const fallbackFont =
category === 'serif' ? DEFAULT_SERIF_FONT : DEFAULT_SANS_SERIF_FONT
ascent = formatOverrideValue(ascent / unitsPerEm)
Expand All @@ -119,14 +118,15 @@ export function calculateOverrideValues(font: string, fontMetrics: any) {
}
}

export function calculateSizeAdjustValues(font: string, fontMetrics: any) {
const fontKey = font.trim()
export function calculateSizeAdjustValues(fontMetrics: any) {
let { category, ascent, descent, lineGap, unitsPerEm, xAvgCharWidth } =
fontMetrics[fontKey]
fontMetrics
const fallbackFont =
category === 'serif' ? DEFAULT_SERIF_FONT : DEFAULT_SANS_SERIF_FONT

let sizeAdjust = xAvgCharWidth / fallbackFont.xAvgCharWidth
let sizeAdjust = xAvgCharWidth
? xAvgCharWidth / fallbackFont.xAvgCharWidth
: 1

ascent = formatOverrideValue(ascent / (unitsPerEm * sizeAdjust))
descent = formatOverrideValue(descent / (unitsPerEm * sizeAdjust))
Expand All @@ -145,8 +145,7 @@ function calculateOverrideCSS(font: string, fontMetrics: any) {
const fontName = font.trim()

const { ascent, descent, lineGap, fallbackFont } = calculateOverrideValues(
font,
fontMetrics
fontMetrics[fontName]
)

return `
Expand All @@ -164,7 +163,7 @@ function calculateSizeAdjustCSS(font: string, fontMetrics: any) {
const fontName = font.trim()

const { ascent, descent, lineGap, fallbackFont, sizeAdjust } =
calculateSizeAdjustValues(font, fontMetrics)
calculateSizeAdjustValues(fontMetrics[fontName])

return `
@font-face {
Expand Down

0 comments on commit 05498a0

Please sign in to comment.