Files
Compass/media-creator/scripts/fetch-fonts.mjs
MartinBraquet fab7c5e07b Add script for fetching web fonts for OG card rendering
- Introduced `fetch-fonts.mjs` to download brand web fonts from Google Fonts into `public/fonts/`.
- Integrated font fetching into the `still:og` script to ensure rendering dependencies are prepared automatically.
- Updated `package.json`, README, and font-related modules to document usage and improve reproducibility.
- Ensured rendering fails cleanly if fonts are missing or invalid, maintaining visual consistency.
2026-07-24 19:03:48 +02:00

135 lines
4.6 KiB
JavaScript

/**
* Downloads the brand web faces into public/fonts/ for the OG card render.
*
* The files are not committed (see .gitignore) — the same call the repo makes for the hero clips
* and for public/logo.svg. They are build *inputs* though, not outputs, so they come from Google
* Fonts rather than from R2: no credentials, and nothing to upload when a face changes.
*
* Resolved through the css2 API rather than hardcoded gstatic URLs on purpose. Those URLs carry a
* family version (`/newsreader/v26/`) that Google rotates, so a hardcoded list rots silently; the
* API always answers with the current one.
*
* npm run fonts # no-op if the files are already there
* npm run fonts -- --force # re-download, e.g. to pick up a new family version
*
* `npm run still:og` runs this first, so rendering the card needs no separate step. Run it by hand
* before `npm run studio` if you want to preview the card there.
*
* Only Newsreader, DM Sans and Cormorant Garamond are fetched, and only their `latin` subset —
* that is all the card sets. The video scenes deliberately use the system stack instead.
*/
import {mkdir, readFile, writeFile} from 'node:fs/promises'
import {dirname, join} from 'node:path'
import {fileURLToPath} from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const FONT_DIR = join(HERE, '../public/fonts')
// A desktop UA is what makes the API answer with woff2; older agents get ttf.
const UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
/**
* Filenames are what src/components/BrandFonts.ts loads — keep the two in sync.
*
* Every family here is variable, so one file per style covers the whole weight range and the
* weights in the query only decide which axes Google keeps.
*/
const FONTS = [
{
file: 'Newsreader-latin.woff2',
family: 'Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;0,6..72,700',
style: 'normal',
},
{
file: 'Newsreader-Italic-latin.woff2',
family: 'Newsreader:ital,opsz,wght@1,6..72,400',
style: 'italic',
},
{
file: 'DMSans-latin.woff2',
family: 'DM+Sans:opsz,wght@9..40,400;9..40,500',
style: 'normal',
},
{
file: 'CormorantGaramond-500-latin.woff2',
family: 'Cormorant+Garamond:wght@500',
style: 'normal',
},
]
const force = process.argv.includes('--force')
const exists = async (path) => {
try {
return (await readFile(path)).byteLength > 0
} catch {
return false
}
}
/**
* Pulls the `latin` @font-face of the requested style out of a css2 response.
*
* The API returns one block per subset, each preceded by a `/* subset *\/` comment — that comment
* is the only thing identifying them, so the parse keys off it.
*/
function findLatinSrc(css, style) {
const blocks = [...css.matchAll(/\/\*\s*([\w-]+)\s*\*\/\s*@font-face\s*\{([^}]*)\}/g)]
for (const [, subset, body] of blocks) {
if (subset !== 'latin') continue
const blockStyle = body.match(/font-style:\s*([\w]+)/)?.[1] ?? 'normal'
if (blockStyle !== style) continue
const url = body.match(/url\((https:\/\/[^)]+\.woff2)\)/)?.[1]
if (url) return url
}
return null
}
async function fetchFont({file, family, style}) {
const target = join(FONT_DIR, file)
if (!force && (await exists(target))) {
console.log(`[fonts] ${file} — already present`)
return
}
const cssUrl = `https://fonts.googleapis.com/css2?family=${family}&display=swap`
const cssRes = await fetch(cssUrl, {headers: {'User-Agent': UA}})
if (!cssRes.ok) {
throw new Error(`${cssRes.status} ${cssRes.statusText} for ${cssUrl}`)
}
const src = findLatinSrc(await cssRes.text(), style)
if (!src) {
throw new Error(`No latin ${style} woff2 in the css2 response for ${family}`)
}
const fontRes = await fetch(src, {headers: {'User-Agent': UA}})
if (!fontRes.ok) {
throw new Error(`${fontRes.status} ${fontRes.statusText} for ${src}`)
}
const bytes = Buffer.from(await fontRes.arrayBuffer())
// Guard against a redirect or an error page landing on disk as a .woff2: the render would fail
// much further away from the cause.
if (bytes.subarray(0, 4).toString('ascii') !== 'wOF2') {
throw new Error(`${src} did not return a woff2 file`)
}
await writeFile(target, bytes)
console.log(`[fonts] ${file} (${(bytes.byteLength / 1024).toFixed(0)} KB)`)
}
async function main() {
await mkdir(FONT_DIR, {recursive: true})
for (const font of FONTS) await fetchFont(font)
}
main().catch((err) => {
console.error(`[fonts] ${err.message}`)
console.error('[fonts] The OG card cannot render without these — see media-creator/README.md.')
process.exit(1)
})