Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cold-carrots-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves the optimized fallback name generated by the experimental Fonts API
5 changes: 5 additions & 0 deletions .changeset/stupid-wasps-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves the quality of optimized fallbacks generated by the experimental Fonts API
58 changes: 56 additions & 2 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FontFaceMetrics } from './metrics.js';
import type { ResolvedRemoteFontFamily } from './types.js';

export const LOCAL_PROVIDER_NAME = 'local';
Expand All @@ -20,8 +21,61 @@ export const CACHE_DIR = './fonts/';

export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'] as const;

// Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json
export const SYSTEM_METRICS = {
'Times New Roman': {
ascent: 1825,
descent: -443,
lineGap: 87,
unitsPerEm: 2048,
xWidthAvg: 832,
},
Arial: {
ascent: 1854,
descent: -434,
lineGap: 67,
unitsPerEm: 2048,
xWidthAvg: 913,
},
'Courier New': {
ascent: 1705,
descent: -615,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 1229,
},
BlinkMacSystemFont: {
ascent: 1980,
descent: -432,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 853,
},
'Segoe UI': {
ascent: 2210,
descent: -514,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 908,
},
Roboto: {
ascent: 1900,
descent: -500,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 911,
},
'Helvetica Neue': {
ascent: 952,
descent: -213,
lineGap: 28,
unitsPerEm: 1000,
xWidthAvg: 450,
},
} satisfies Record<string, FontFaceMetrics>;

// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75
export const DEFAULT_FALLBACKS: Record<string, Array<string>> = {
export const DEFAULT_FALLBACKS = {
serif: ['Times New Roman'],
'sans-serif': ['Arial'],
monospace: ['Courier New'],
Expand All @@ -35,6 +89,6 @@ export const DEFAULT_FALLBACKS: Record<string, Array<string>> = {
emoji: [],
math: [],
fangsong: [],
};
} as const satisfies Record<string, Array<keyof typeof SYSTEM_METRICS>>;

export const FONTS_TYPES_FILE = 'fonts.d.ts';
56 changes: 20 additions & 36 deletions packages/astro/src/assets/fonts/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Font, fromBuffer } from '@capsizecss/unpack';
import { renderFontFace, renderFontSrc } from './utils.js';

export type FontFaceMetrics = Pick<
Font,
Expand Down Expand Up @@ -37,40 +38,25 @@ function toPercentage(value: number, fractionDigits = 4) {
return `${+percentage.toFixed(fractionDigits)}%`;
}

function toCSS(properties: Record<string, any>, indent = 2) {
return Object.entries(properties)
.map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`)
.join('\n');
}

export function generateFallbackFontFace(
metrics: FontFaceMetrics,
fallback: {
name: string;
font: string;
metrics?: FontFaceMetrics;
[key: string]: any;
},
) {
const {
name: fallbackName,
font: fallbackFontName,
metrics: fallbackMetrics,
...properties
} = fallback;

export function generateFallbackFontFace({
metrics,
fallbackMetrics,
name: fallbackName,
font: fallbackFontName,
properties = {},
}: {
metrics: FontFaceMetrics;
fallbackMetrics: FontFaceMetrics;
name: string;
font: string;
properties?: Record<string, string | undefined>;
}) {
// Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts

// Calculate size adjust
const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm;
const fallbackFontXAvgRatio = fallbackMetrics
? fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm
: 1;

const sizeAdjust =
fallbackMetrics && preferredFontXAvgRatio && fallbackFontXAvgRatio
? preferredFontXAvgRatio / fallbackFontXAvgRatio
: 1;
const fallbackFontXAvgRatio = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm;
const sizeAdjust = preferredFontXAvgRatio / fallbackFontXAvgRatio;

const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust;

Expand All @@ -79,15 +65,13 @@ export function generateFallbackFontFace(
const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare;
const lineGapOverride = metrics.lineGap / adjustedEmSquare;

const declaration = {
'font-family': JSON.stringify(fallbackName),
src: `local(${JSON.stringify(fallbackFontName)})`,
return renderFontFace({
'font-family': fallbackName,
src: renderFontSrc([{ name: fallbackFontName }]),
'size-adjust': toPercentage(sizeAdjust),
'ascent-override': toPercentage(ascentOverride),
'descent-override': toPercentage(descentOverride),
'line-gap-override': toPercentage(lineGapOverride),
...properties,
};

return `@font-face {\n${toCSS(declaration)}\n}\n`;
});
}
55 changes: 36 additions & 19 deletions packages/astro/src/assets/fonts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import type * as unifont from 'unifont';
import type { Storage } from 'unstorage';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { DEFAULT_FALLBACKS, FONT_TYPES, LOCAL_PROVIDER_NAME } from './constants.js';
import { DEFAULT_FALLBACKS, FONT_TYPES, LOCAL_PROVIDER_NAME, SYSTEM_METRICS } from './constants.js';
import type { FontFaceMetrics, generateFallbackFontFace } from './metrics.js';
import { type ResolveProviderOptions, resolveProvider } from './providers/utils.js';
import type {
Expand All @@ -15,26 +15,31 @@ import type {
ResolvedLocalFontFamily,
} from './types.js';

// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21
export function generateFontFace(family: string, font: unifont.FontFaceData) {
return [
'@font-face {',
` font-family: ${family};`,
` src: ${renderFontSrc(font.src)};`,
` font-display: ${font.display ?? 'swap'};`,
font.unicodeRange && ` unicode-range: ${font.unicodeRange};`,
font.weight &&
` font-weight: ${Array.isArray(font.weight) ? font.weight.join(' ') : font.weight};`,
font.style && ` font-style: ${font.style};`,
font.stretch && ` font-stretch: ${font.stretch};`,
font.featureSettings && ` font-feature-settings: ${font.featureSettings};`,
font.variationSettings && ` font-variation-settings: ${font.variationSettings};`,
`}`,
]
.filter(Boolean)
export function toCSS(properties: Record<string, string | undefined>, indent = 2) {
return Object.entries(properties)
.filter(([, value]) => Boolean(value))
.map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`)
.join('\n');
}

export function renderFontFace(properties: Record<string, string | undefined>) {
return `@font-face {\n\t${toCSS(properties)}\n}\n`;
}

export function generateFontFace(family: string, font: unifont.FontFaceData) {
return renderFontFace({
'font-family': family,
src: renderFontSrc(font.src),
'font-display': font.display ?? 'swap',
'unicode-range': font.unicodeRange?.join(','),
'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(),
'font-style': font.style,
'font-stretch': font.stretch,
'font-feature-settings': font.featureSettings,
'font-variation-settings': font.variationSettings,
});
}

// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81
export function renderFontSrc(sources: Exclude<unifont.FontFaceData['src'][number], string>[]) {
return sources
Expand All @@ -54,6 +59,12 @@ export function renderFontSrc(sources: Exclude<unifont.FontFaceData['src'][numbe
.join(', ');
}

const QUOTES_RE = /^["']|["']$/g;

export function withoutQuotes(str: string) {
return str.trim().replace(QUOTES_RE, '');
}

export function extractFontType(str: string): FontType {
// Extname includes a leading dot
const extension = extname(str).slice(1);
Expand Down Expand Up @@ -205,7 +216,13 @@ export async function generateFallbacksCSS({
fallbacks = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])];

for (const { font, name } of localFontsMappings) {
css += metrics.generateFontFace(foundMetrics, { font, name });
css += metrics.generateFontFace({
metrics: foundMetrics,
fallbackMetrics: SYSTEM_METRICS[font],
font,
name,
// TODO: forward some properties once we generate one fallback per font face data
});
}

return { css, fallbacks };
Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/assets/fonts/vite-plugin-fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import { loadFonts } from './load.js';
import { generateFallbackFontFace, readMetrics } from './metrics.js';
import type { ResolveMod } from './providers/utils.js';
import type { PreloadData, ResolvedFontFamily } from './types.js';
import { cache, extractFontType, resolveFontFamily, sortObjectByKey } from './utils.js';
import {
cache,
extractFontType,
resolveFontFamily,
sortObjectByKey,
withoutQuotes,
} from './utils.js';

interface Options {
settings: AstroSettings;
Expand Down Expand Up @@ -116,7 +122,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
root: settings.config.root,
resolveMod,
generateNameWithHash: (_family) =>
`${_family.name}-${h64ToString(JSON.stringify(sortObjectByKey(_family)))}`,
`${withoutQuotes(_family.name)}-${h64ToString(JSON.stringify(sortObjectByKey(_family)))}`,
}),
);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/test/units/assets/fonts/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
proxyURL,
renderFontSrc,
resolveFontFamily,
toCSS,
} from '../../../../dist/assets/fonts/utils.js';

/**
Expand Down Expand Up @@ -547,4 +548,10 @@ describe('fonts utils', () => {
);
});
});

it('toCSS', () => {
assert.deepStrictEqual(toCSS({}, 0), '');
assert.deepStrictEqual(toCSS({ foo: 'bar' }, 0), 'foo: bar;');
assert.deepStrictEqual(toCSS({ foo: 'bar', bar: undefined }, 0), 'foo: bar;');
});
});