diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/scripts | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/scripts')
| -rw-r--r-- | apps/web/scripts/generate-font-sprites.ts | 562 |
1 files changed, 281 insertions, 281 deletions
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<FontsourceFont[]> { - 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<Buffer | null> { - 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<Map<string, Buffer>> { - console.log( - `Downloading ${fonts.length} font files (${concurrency} concurrent)...`, - ); - const results = new Map<string, Buffer>(); - 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<string, Buffer>; -}): 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<string, AtlasEntry>; - chunks: PackedFont[][]; -} { - console.log("Packing into sprite chunks..."); - const atlas: Record<string, AtlasEntry> = {}; - 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<void> { - 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<FontsourceFont[]> {
+ 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<Buffer | null> {
+ 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<Map<string, Buffer>> {
+ console.log(
+ `Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
+ );
+ const results = new Map<string, Buffer>();
+ 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<string, Buffer>;
+}): 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<string, AtlasEntry>;
+ chunks: PackedFont[][];
+} {
+ console.log("Packing into sprite chunks...");
+ const atlas: Record<string, AtlasEntry> = {};
+ 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<void> {
+ 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);
+});
|
