Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
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${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;');
});
});
Loading