From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/scripts/generate-font-sprites.ts | 562 +++++++++++++++--------------- 1 file changed, 281 insertions(+), 281 deletions(-) (limited to 'apps/web/scripts') diff --git a/apps/web/scripts/generate-font-sprites.ts b/apps/web/scripts/generate-font-sprites.ts index 144d68ec..f0d98bc8 100644 --- a/apps/web/scripts/generate-font-sprites.ts +++ b/apps/web/scripts/generate-font-sprites.ts @@ -1,281 +1,281 @@ -/** - * Generates font sprite atlas for the font picker. - * - * Downloads Google Fonts from Fontsource, renders each font name as a sprite, - * packs them into chunk images, and outputs a JSON atlas + AVIF images. - * - * Run: npx tsx scripts/generate-font-sprites.ts - * Deps: @napi-rs/canvas sharp (install as devDependencies) - */ - -import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; -import sharp from "sharp"; -import { mkdir, writeFile, readFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const FONT_SIZE = 24; -const ROW_HEIGHT = 40; -const CANVAS_WIDTH = 1200; -const MAX_CHUNK_HEIGHT = 800; -const PADDING_X = 14; -const WIDTH_BUFFER = 8; -const CONCURRENT_DOWNLOADS = 30; - -const OUTPUT_DIR = join(__dirname, "..", "public", "fonts"); -const CACHE_DIR = join(__dirname, "..", ".font-cache"); -const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts"; - -interface FontsourceFont { - id: string; - family: string; - subsets: string[]; - weights: number[]; - styles: string[]; - category: string; - type: string; -} - -interface MeasuredFont { - family: string; - width: number; - styles: string[]; -} - -interface PackedFont { - family: string; - x: number; - y: number; - w: number; -} - -interface AtlasEntry { - x: number; - y: number; - w: number; - ch: number; - s: string[]; -} - -async function fetchFontList(): Promise { - console.log("Fetching font list from Fontsource..."); - const response = await fetch(FONTSOURCE_API); - if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`); - - const data: FontsourceFont[] = await response.json(); - const filtered = data.filter( - (font) => - font.type === "google" && - font.subsets.includes("latin") && - font.weights.includes(400), - ); - - console.log( - ` ${filtered.length} Google fonts with Latin subset + 400 weight`, - ); - return filtered; -} - -async function downloadFont({ id }: { id: string }): Promise { - const cachePath = join(CACHE_DIR, `${id}.woff2`); - - if (existsSync(cachePath)) { - return readFile(cachePath); - } - - // Fontsource CDN serves woff2 for all Google fonts - const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`; - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const buffer = Buffer.from(await response.arrayBuffer()); - await writeFile(cachePath, buffer); - return buffer; - } catch { - return null; - } -} - -async function downloadAllFonts({ - fonts, - concurrency, -}: { - fonts: FontsourceFont[]; - concurrency: number; -}): Promise> { - console.log( - `Downloading ${fonts.length} font files (${concurrency} concurrent)...`, - ); - const results = new Map(); - let nextIndex = 0; - let completed = 0; - - async function worker() { - while (nextIndex < fonts.length) { - const index = nextIndex++; - const font = fonts[index]; - const buffer = await downloadFont({ id: font.id }); - if (buffer) results.set(font.id, buffer); - completed++; - if (completed % 100 === 0 || completed === fonts.length) { - process.stdout.write(`\r ${completed}/${fonts.length}`); - } - } - } - - await Promise.all(Array.from({ length: concurrency }, () => worker())); - console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`); - return results; -} - -function measureFonts({ - fonts, - fontBuffers, -}: { - fonts: FontsourceFont[]; - fontBuffers: Map; -}): MeasuredFont[] { - console.log("Registering fonts and measuring text..."); - const measured: MeasuredFont[] = []; - const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT); - const ctx = canvas.getContext("2d"); - - for (const font of fonts) { - const buffer = fontBuffers.get(font.id); - if (!buffer) continue; - - try { - const ok = GlobalFonts.register(buffer, font.family); - if (!ok) continue; - - ctx.font = `${FONT_SIZE}px "${font.family}"`; - const metrics = ctx.measureText(font.family); - const width = Math.ceil(metrics.width) + WIDTH_BUFFER; - - const styles: string[] = []; - for (const weight of font.weights) { - if (font.styles.includes("normal")) styles.push(String(weight)); - if (font.styles.includes("italic")) styles.push(`${weight}i`); - } - - measured.push({ family: font.family, width, styles }); - } catch { - // skip fonts that fail to register - } - } - - measured.sort((a, b) => a.family.localeCompare(b.family)); - console.log(` ${measured.length} fonts measured`); - return measured; -} - -function packIntoChunks({ measured }: { measured: MeasuredFont[] }): { - atlas: Record; - chunks: PackedFont[][]; -} { - console.log("Packing into sprite chunks..."); - const atlas: Record = {}; - const chunks: PackedFont[][] = [[]]; - let chunkIndex = 0; - let cursorX = 0; - let cursorY = 0; - - for (const font of measured) { - // New row if doesn't fit horizontally - if (cursorX + font.width > CANVAS_WIDTH) { - cursorX = 0; - cursorY += ROW_HEIGHT; - } - - // New chunk if doesn't fit vertically - if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) { - chunkIndex++; - chunks.push([]); - cursorX = 0; - cursorY = 0; - } - - // Same data goes to BOTH the atlas and the chunk render list - const x = cursorX; - const y = cursorY; - - atlas[font.family] = { - x, - y, - w: font.width, - ch: chunkIndex, - s: font.styles, - }; - chunks[chunkIndex].push({ family: font.family, x, y, w: font.width }); - - cursorX += font.width + PADDING_X; - } - - console.log(` ${chunks.length} chunks`); - return { atlas, chunks }; -} - -async function renderChunks({ - chunks, -}: { - chunks: PackedFont[][]; -}): Promise { - console.log("Rendering sprite chunks..."); - - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT; - - const canvas = createCanvas(CANVAS_WIDTH, chunkHeight); - const ctx = canvas.getContext("2d"); - - for (const font of chunk) { - ctx.font = `${FONT_SIZE}px "${font.family}"`; - ctx.fillStyle = "#000000"; - ctx.textBaseline = "middle"; - ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2); - } - - const pngBuffer = canvas.toBuffer("image/png"); - await sharp(pngBuffer) - .avif({ quality: 80 }) - .toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`)); - - console.log( - ` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`, - ); - } -} - -async function main() { - await mkdir(OUTPUT_DIR, { recursive: true }); - await mkdir(CACHE_DIR, { recursive: true }); - - const fonts = await fetchFontList(); - const fontBuffers = await downloadAllFonts({ - fonts, - concurrency: CONCURRENT_DOWNLOADS, - }); - const measured = measureFonts({ fonts, fontBuffers }); - const { atlas, chunks } = packIntoChunks({ measured }); - await renderChunks({ chunks }); - - // Write atlas JSON (compact for smaller file size) - await writeFile( - join(OUTPUT_DIR, "font-atlas.json"), - JSON.stringify({ fonts: atlas }), - ); - - const totalFonts = Object.keys(atlas).length; - console.log( - `\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`, - ); -} - -main().catch((error) => { - console.error("Failed:", error); - process.exit(1); -}); +/** + * Generates font sprite atlas for the font picker. + * + * Downloads Google Fonts from Fontsource, renders each font name as a sprite, + * packs them into chunk images, and outputs a JSON atlas + AVIF images. + * + * Run: npx tsx scripts/generate-font-sprites.ts + * Deps: @napi-rs/canvas sharp (install as devDependencies) + */ + +import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; +import sharp from "sharp"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const FONT_SIZE = 24; +const ROW_HEIGHT = 40; +const CANVAS_WIDTH = 1200; +const MAX_CHUNK_HEIGHT = 800; +const PADDING_X = 14; +const WIDTH_BUFFER = 8; +const CONCURRENT_DOWNLOADS = 30; + +const OUTPUT_DIR = join(__dirname, "..", "public", "fonts"); +const CACHE_DIR = join(__dirname, "..", ".font-cache"); +const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts"; + +interface FontsourceFont { + id: string; + family: string; + subsets: string[]; + weights: number[]; + styles: string[]; + category: string; + type: string; +} + +interface MeasuredFont { + family: string; + width: number; + styles: string[]; +} + +interface PackedFont { + family: string; + x: number; + y: number; + w: number; +} + +interface AtlasEntry { + x: number; + y: number; + w: number; + ch: number; + s: string[]; +} + +async function fetchFontList(): Promise { + console.log("Fetching font list from Fontsource..."); + const response = await fetch(FONTSOURCE_API); + if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`); + + const data: FontsourceFont[] = await response.json(); + const filtered = data.filter( + (font) => + font.type === "google" && + font.subsets.includes("latin") && + font.weights.includes(400), + ); + + console.log( + ` ${filtered.length} Google fonts with Latin subset + 400 weight`, + ); + return filtered; +} + +async function downloadFont({ id }: { id: string }): Promise { + const cachePath = join(CACHE_DIR, `${id}.woff2`); + + if (existsSync(cachePath)) { + return readFile(cachePath); + } + + // Fontsource CDN serves woff2 for all Google fonts + const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(cachePath, buffer); + return buffer; + } catch { + return null; + } +} + +async function downloadAllFonts({ + fonts, + concurrency, +}: { + fonts: FontsourceFont[]; + concurrency: number; +}): Promise> { + console.log( + `Downloading ${fonts.length} font files (${concurrency} concurrent)...`, + ); + const results = new Map(); + let nextIndex = 0; + let completed = 0; + + async function worker() { + while (nextIndex < fonts.length) { + const index = nextIndex++; + const font = fonts[index]; + const buffer = await downloadFont({ id: font.id }); + if (buffer) results.set(font.id, buffer); + completed++; + if (completed % 100 === 0 || completed === fonts.length) { + process.stdout.write(`\r ${completed}/${fonts.length}`); + } + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`); + return results; +} + +function measureFonts({ + fonts, + fontBuffers, +}: { + fonts: FontsourceFont[]; + fontBuffers: Map; +}): MeasuredFont[] { + console.log("Registering fonts and measuring text..."); + const measured: MeasuredFont[] = []; + const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT); + const ctx = canvas.getContext("2d"); + + for (const font of fonts) { + const buffer = fontBuffers.get(font.id); + if (!buffer) continue; + + try { + const ok = GlobalFonts.register(buffer, font.family); + if (!ok) continue; + + ctx.font = `${FONT_SIZE}px "${font.family}"`; + const metrics = ctx.measureText(font.family); + const width = Math.ceil(metrics.width) + WIDTH_BUFFER; + + const styles: string[] = []; + for (const weight of font.weights) { + if (font.styles.includes("normal")) styles.push(String(weight)); + if (font.styles.includes("italic")) styles.push(`${weight}i`); + } + + measured.push({ family: font.family, width, styles }); + } catch { + // skip fonts that fail to register + } + } + + measured.sort((a, b) => a.family.localeCompare(b.family)); + console.log(` ${measured.length} fonts measured`); + return measured; +} + +function packIntoChunks({ measured }: { measured: MeasuredFont[] }): { + atlas: Record; + chunks: PackedFont[][]; +} { + console.log("Packing into sprite chunks..."); + const atlas: Record = {}; + const chunks: PackedFont[][] = [[]]; + let chunkIndex = 0; + let cursorX = 0; + let cursorY = 0; + + for (const font of measured) { + // New row if doesn't fit horizontally + if (cursorX + font.width > CANVAS_WIDTH) { + cursorX = 0; + cursorY += ROW_HEIGHT; + } + + // New chunk if doesn't fit vertically + if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) { + chunkIndex++; + chunks.push([]); + cursorX = 0; + cursorY = 0; + } + + // Same data goes to BOTH the atlas and the chunk render list + const x = cursorX; + const y = cursorY; + + atlas[font.family] = { + x, + y, + w: font.width, + ch: chunkIndex, + s: font.styles, + }; + chunks[chunkIndex].push({ family: font.family, x, y, w: font.width }); + + cursorX += font.width + PADDING_X; + } + + console.log(` ${chunks.length} chunks`); + return { atlas, chunks }; +} + +async function renderChunks({ + chunks, +}: { + chunks: PackedFont[][]; +}): Promise { + console.log("Rendering sprite chunks..."); + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT; + + const canvas = createCanvas(CANVAS_WIDTH, chunkHeight); + const ctx = canvas.getContext("2d"); + + for (const font of chunk) { + ctx.font = `${FONT_SIZE}px "${font.family}"`; + ctx.fillStyle = "#000000"; + ctx.textBaseline = "middle"; + ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2); + } + + const pngBuffer = canvas.toBuffer("image/png"); + await sharp(pngBuffer) + .avif({ quality: 80 }) + .toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`)); + + console.log( + ` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`, + ); + } +} + +async function main() { + await mkdir(OUTPUT_DIR, { recursive: true }); + await mkdir(CACHE_DIR, { recursive: true }); + + const fonts = await fetchFontList(); + const fontBuffers = await downloadAllFonts({ + fonts, + concurrency: CONCURRENT_DOWNLOADS, + }); + const measured = measureFonts({ fonts, fontBuffers }); + const { atlas, chunks } = packIntoChunks({ measured }); + await renderChunks({ chunks }); + + // Write atlas JSON (compact for smaller file size) + await writeFile( + join(OUTPUT_DIR, "font-atlas.json"), + JSON.stringify({ fonts: atlas }), + ); + + const totalFonts = Object.keys(atlas).length; + console.log( + `\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`, + ); +} + +main().catch((error) => { + console.error("Failed:", error); + process.exit(1); +}); -- cgit v1.3.1