Files
2026-07-13 13:32:05 +08:00

154 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* One-off generator for the README hero assets.
*
* Produces 4 SVGs under `assets/hero/`:
* - wordmark-light.svg / wordmark-dark.svg (DeepEval icon + "DeepEval.")
* - tagline-light.svg / tagline-dark.svg ("The LLM Evaluation Framework")
*
* The README references them via <picture> + prefers-color-scheme so GitHub
* swaps the right variant for each viewer's theme. Each SVG embeds CSS
* keyframe animations (logo scale-in, letter stagger fade, tagline fade-up)
* that GitHub's image proxy preserves for animated SVGs.
*
* Quicksand SemiBold is downloaded from the Google Fonts CDN and cached
* locally under `docs/scripts/fonts/`. Glyphs are converted to outline
* <path> elements via opentype.js so no font dependency exists at render
* time — the SVG is pixel-identical in every renderer (GitHub camo,
* Safari, etc.) regardless of font availability.
*
* Run: yarn build-readme-hero (from docs/)
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import opentype from 'opentype.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '../..');
const OUT_DIR = path.join(REPO_ROOT, 'assets/hero');
const FONT_DIR = path.join(__dirname, 'fonts');
const FONT_PATH = path.join(FONT_DIR, 'Quicksand-SemiBold.ttf');
// google-webfonts-helper packages each Google Font weight as a static TTF
// (Google's own repo only ships the variable Quicksand[wght].ttf, whose
// default instance is Light/300 — opentype.js can't reliably interpolate
// that to SemiBold without bundling the full variable-axis math). The
// gwfh zip contains a single static `quicksand-v37-latin-600.ttf` with
// the SemiBold outlines we want.
const FONT_ZIP_URL =
'https://gwfh.mranftl.com/api/fonts/quicksand?download=zip&subsets=latin&variants=600&formats=ttf';
const ACCENT = '#4400FF'; // DeepEval brand purple
const LIGHT_FG = '#0A0A0A';
const DARK_FG = '#FAFAFA';
async function ensureFont() {
try {
await fs.access(FONT_PATH);
return;
} catch {}
await fs.mkdir(FONT_DIR, { recursive: true });
console.log('Downloading Quicksand SemiBold…');
const res = await fetch(FONT_ZIP_URL);
if (!res.ok) throw new Error(`Font download failed: ${res.status}`);
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'quicksand-'));
const zipPath = path.join(tmp, 'q.zip');
await fs.writeFile(zipPath, Buffer.from(await res.arrayBuffer()));
execFileSync('unzip', ['-q', '-o', zipPath, '-d', tmp]);
const entries = await fs.readdir(tmp);
const ttfName = entries.find((n) => n.endsWith('.ttf'));
if (!ttfName) throw new Error('No TTF inside font zip');
await fs.copyFile(path.join(tmp, ttfName), FONT_PATH);
await fs.rm(tmp, { recursive: true, force: true });
}
/**
* Build the wordmark SVG: just "DeepEval." with the period in the brand
* purple. Each letter sits in its own <g> so we can stagger the entrance
* animation per-letter.
*/
async function buildWordmark(font, mode) {
const fg = mode === 'dark' ? DARK_FG : LIGHT_FG;
const text = 'DeepEval.';
const fontSize = 96;
// Lay out each glyph individually so we can animate them separately.
// We use opentype's per-glyph advance widths so kerning is preserved.
const glyphs = [];
let cursor = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const p = font.getPath(ch, cursor, 0, fontSize);
glyphs.push({ ch, d: p.toPathData(2), x: cursor });
const advance =
(font.charToGlyph(ch).advanceWidth / font.unitsPerEm) * fontSize;
if (i < text.length - 1) {
const kern = font.getKerningValue(
font.charToGlyph(ch),
font.charToGlyph(text[i + 1]),
);
cursor += advance + (kern / font.unitsPerEm) * fontSize;
} else {
cursor += advance;
}
}
const wholePath = font.getPath(text, 0, 0, fontSize);
const bbox = wholePath.getBoundingBox();
const textWidth = bbox.x2 - bbox.x1;
const textHeight = bbox.y2 - bbox.y1;
const padX = 4;
const padY = 12;
const totalW = textWidth + padX * 2;
const totalH = textHeight + padY * 2;
// Translate so the text's visual bbox sits inside the padded canvas:
// text glyphs are positioned with baseline at y=0, so we translate by
// (padY - bbox.y1) on Y to drop the top of the bbox at padY.
const tx = padX - bbox.x1;
const ty = padY - bbox.y1;
let letterGs = '';
glyphs.forEach((g, i) => {
const delay = (i * 45 + 100).toFixed(0);
const fill = g.ch === '.' ? ACCENT : fg;
letterGs += `<g class="hero-letter" style="animation-delay:${delay}ms" fill="${fill}"><path d="${g.d}" transform="translate(${tx.toFixed(2)},${ty.toFixed(2)})"/></g>`;
});
const css = `
.hero-letter { opacity: 0; transform: translateY(8px); animation: heroLetterIn 500ms cubic-bezier(.2,.8,.2,1) forwards; }
@keyframes heroLetterIn { to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.hero-letter { opacity: 1 !important; transform: none !important; animation: none !important; }
}
`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${Math.ceil(totalW)} ${Math.ceil(totalH)}" width="${Math.ceil(totalW)}" height="${Math.ceil(totalH)}" role="img" aria-label="DeepEval.">
<style>${css}</style>
${letterGs}
</svg>
`;
}
async function main() {
await ensureFont();
await fs.mkdir(OUT_DIR, { recursive: true });
const font = opentype.parse((await fs.readFile(FONT_PATH)).buffer);
for (const mode of ['light', 'dark']) {
const wordmark = await buildWordmark(font, mode);
await fs.writeFile(path.join(OUT_DIR, `wordmark-${mode}.svg`), wordmark);
console.log(`wrote wordmark-${mode}.svg`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});