spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1#!/usr/bin/env node2/**3 * Company Atlas — brand asset build.4 *5 * From the single mark geometry (`src/components/brand/geometry.ts`, imported with Node's type stripping) and the Geist6 * TTFs shipped by the `geist` package, writes:7 *8 * src/app/icon.svg favicon (tiny detail level, hard-coded colours) → served at /icon.svg9 * public/logo-mark.svg the mark alone, dark plate10 * public/logo.svg lockup on its own dark card (works on any background)11 * public/logo-light.svg lockup for light backgrounds (dark plate, dark text), transparent12 * public/logo-dark.svg lockup for dark backgrounds (light plate, light text), transparent13 * public/favicon.ico real multi-size ICO (16 / 32 / 48, PNG-encoded entries)14 * public/icon-192.png, icon-512.png, icon-512-maskable.png, apple-touch-icon.png15 *16 * The wordmark in the standalone SVGs is real vector text: glyph outlines are read from Geist-Regular / Geist-SemiBold17 * (TrueType `glyf`), and glyph positions (advances + kerning) are measured by Chromium with the same font files, so the18 * exported logo needs no font installed. PNGs are rendered by Playwright at 1× (no sub-pixel hairlines: stroke weights19 * come from the geometry's detail levels).20 *21 * Run from apps/web: node scripts/build-icons.mjs (env PLAYWRIGHT_MODULE overrides the Playwright import path)22 */23import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';24import { createRequire } from 'node:module';25import path from 'node:path';26import { fileURLToPath } from 'node:url';2728const HERE = path.dirname(fileURLToPath(import.meta.url));29const WEB = path.resolve(HERE, '..');30const PUBLIC = path.join(WEB, 'public');31const APP = path.join(WEB, 'src', 'app');32const PLAYWRIGHT = process.env.PLAYWRIGHT_MODULE ?? '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';3334const { chromium } = await import(PLAYWRIGHT);35const geometry = await import('../src/components/brand/geometry.ts');36const { MARK_DARK, MARK_LIGHT, MARK_VIEWBOX, markShapes, markSvgString } = geometry;3738const require = createRequire(import.meta.url);39// `geist` only exports its entry points, so locate dist/ through `geist/font` (→ dist/font.js) and walk to the TTFs.40const GEIST_DIR = path.join(path.dirname(require.resolve('geist/font')), 'fonts', 'geist-sans');41const FONT_REGULAR = path.join(GEIST_DIR, 'Geist-Regular.ttf');42const FONT_SEMIBOLD = path.join(GEIST_DIR, 'Geist-SemiBold.ttf');4344// Wordmark palette (mirrors --ink / --ink-2 of each theme in globals.css).45const TEXT_ON_LIGHT = { company: '#4a5160', atlas: '#0f1419' };46const TEXT_ON_DARK = { company: '#a3a9b8', atlas: '#e8ebf1' };4748/* ----------------------------------------------------------------------------------------------------------------- */49/* Minimal TrueType reader: cmap (format 4/12) → glyph id, glyf outlines (simple + composite) → SVG path data. */50/* ----------------------------------------------------------------------------------------------------------------- */51function parseTtf(file) {52 const buf = readFileSync(file);53 const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);54 const tables = {};55 const n = dv.getUint16(4);56 for (let i = 0; i < n; i++) {57 const o = 12 + i * 16;58 tables[buf.toString('ascii', o, o + 4)] = { offset: dv.getUint32(o + 8), length: dv.getUint32(o + 12) };59 }60 for (const t of ['head', 'maxp', 'loca', 'glyf', 'cmap', 'hhea', 'hmtx']) if (!tables[t]) throw new Error(`${path.basename(file)}: missing ${t} table (not a TrueType-flavoured font)`);61 const unitsPerEm = dv.getUint16(tables.head.offset + 18);62 const longLoca = dv.getInt16(tables.head.offset + 50) === 1;63 const numGlyphs = dv.getUint16(tables.maxp.offset + 4);64 const loca = new Array(numGlyphs + 1);65 for (let i = 0; i <= numGlyphs; i++) loca[i] = longLoca ? dv.getUint32(tables.loca.offset + i * 4) : dv.getUint16(tables.loca.offset + i * 2) * 2;66 const os2 = tables['OS/2'];67 const capHeight = os2 && dv.getUint16(os2.offset) >= 2 ? dv.getInt16(os2.offset + 88) : Math.round(unitsPerEm * 0.7);68 const ascender = dv.getInt16(tables.hhea.offset + 4);69 const descender = dv.getInt16(tables.hhea.offset + 6);7071 // cmap: prefer a Unicode subtable (platform 3/1 or 0/x), formats 4 and 1272 const cmap = tables.cmap.offset;73 const subtables = dv.getUint16(cmap + 2);74 let best = null;75 for (let i = 0; i < subtables; i++) {76 const pid = dv.getUint16(cmap + 4 + i * 8);77 const eid = dv.getUint16(cmap + 6 + i * 8);78 const off = dv.getUint32(cmap + 8 + i * 8);79 const fmt = dv.getUint16(cmap + off);80 const score = fmt === 12 ? 3 : fmt === 4 ? 2 : 0;81 if ((pid === 3 && (eid === 1 || eid === 10)) || pid === 0) if (score && (!best || score > best.score)) best = { off: cmap + off, fmt, score };82 }83 if (!best) throw new Error('no usable cmap subtable');84 function glyphId(cp) {85 const o = best.off;86 if (best.fmt === 12) {87 const groups = dv.getUint32(o + 12);88 for (let g = 0; g < groups; g++) {89 const s = dv.getUint32(o + 16 + g * 12);90 const e = dv.getUint32(o + 20 + g * 12);91 if (cp >= s && cp <= e) return dv.getUint32(o + 24 + g * 12) + (cp - s);92 }93 return 0;94 }95 const segX2 = dv.getUint16(o + 6);96 const seg = segX2 / 2;97 const endP = o + 14;98 const startP = endP + segX2 + 2;99 const deltaP = startP + segX2;100 const rangeP = deltaP + segX2;101 for (let i = 0; i < seg; i++) {102 const end = dv.getUint16(endP + i * 2);103 if (cp > end) continue;104 const start = dv.getUint16(startP + i * 2);105 if (cp < start) return 0;106 const delta = dv.getInt16(deltaP + i * 2);107 const rangeOff = dv.getUint16(rangeP + i * 2);108 if (rangeOff === 0) return (cp + delta) & 0xffff;109 const addr = rangeP + i * 2 + rangeOff + (cp - start) * 2;110 const gid = dv.getUint16(addr);111 return gid === 0 ? 0 : (gid + delta) & 0xffff;112 }113 return 0;114 }115116 const numHMetrics = dv.getUint16(tables.hhea.offset + 34);117 function advance(gid) {118 const i = Math.min(gid, numHMetrics - 1);119 return dv.getUint16(tables.hmtx.offset + i * 4);120 }121122 /** Contours of one glyph as arrays of {x, y, on} in font units (y up); composites are flattened. */123 function contours(gid, depth = 0) {124 const start = tables.glyf.offset + loca[gid];125 const end = tables.glyf.offset + loca[gid + 1];126 if (end <= start) return [];127 const nc = dv.getInt16(start);128 if (nc >= 0) {129 let p = start + 10;130 const endPts = [];131 for (let i = 0; i < nc; i++) endPts.push(dv.getUint16(p + i * 2));132 p += nc * 2;133 const nPts = nc ? endPts[nc - 1] + 1 : 0;134 const instrLen = dv.getUint16(p);135 p += 2 + instrLen;136 const flags = new Array(nPts);137 for (let i = 0; i < nPts; ) {138 const f = dv.getUint8(p++);139 flags[i++] = f;140 if (f & 8) {141 let r = dv.getUint8(p++);142 while (r-- > 0 && i < nPts) flags[i++] = f;143 }144 }145 const xs = new Array(nPts);146 let v = 0;147 for (let i = 0; i < nPts; i++) {148 const f = flags[i];149 if (f & 2) {150 const d = dv.getUint8(p++);151 v += f & 16 ? d : -d;152 } else if (!(f & 16)) {153 v += dv.getInt16(p);154 p += 2;155 }156 xs[i] = v;157 }158 const ys = new Array(nPts);159 v = 0;160 for (let i = 0; i < nPts; i++) {161 const f = flags[i];162 if (f & 4) {163 const d = dv.getUint8(p++);164 v += f & 32 ? d : -d;165 } else if (!(f & 32)) {166 v += dv.getInt16(p);167 p += 2;168 }169 ys[i] = v;170 }171 const out = [];172 let s = 0;173 for (let c = 0; c < nc; c++) {174 const pts = [];175 for (let i = s; i <= endPts[c]; i++) pts.push({ x: xs[i], y: ys[i], on: (flags[i] & 1) === 1 });176 out.push(pts);177 s = endPts[c] + 1;178 }179 return out;180 }181 // composite glyph182 if (depth > 4) return [];183 const out = [];184 let p = start + 10;185 for (;;) {186 const flags = dv.getUint16(p);187 const cg = dv.getUint16(p + 2);188 p += 4;189 let dx;190 let dy;191 if (flags & 1) {192 dx = dv.getInt16(p);193 dy = dv.getInt16(p + 2);194 p += 4;195 } else {196 dx = dv.getInt8(p);197 dy = dv.getInt8(p + 1);198 p += 2;199 }200 let a = 1;201 let b = 0;202 let c = 0;203 let d = 1;204 const f2 = (o) => dv.getInt16(o) / 16384;205 if (flags & 8) {206 a = d = f2(p);207 p += 2;208 } else if (flags & 0x40) {209 a = f2(p);210 d = f2(p + 2);211 p += 4;212 } else if (flags & 0x80) {213 a = f2(p);214 b = f2(p + 2);215 c = f2(p + 4);216 d = f2(p + 6);217 p += 8;218 }219 if (!(flags & 2)) {220 dx = 0;221 dy = 0;222 } // point-matching placement is not needed for Latin letters223 for (const ct of contours(cg, depth + 1)) out.push(ct.map((q) => ({ x: a * q.x + c * q.y + dx, y: b * q.x + d * q.y + dy, on: q.on })));224 if (!(flags & 0x20)) break;225 }226 return out;227 }228229 /** SVG path data for a glyph, transformed: X = ox + x·s, Y = oy − y·s (font y-up → SVG y-down). */230 function glyphPath(gid, ox, oy, s) {231 const f = (v) => (Math.round(v * 100) / 100).toString();232 const P = (q) => `${f(ox + q.x * s)} ${f(oy - q.y * s)}`;233 const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });234 let d = '';235 for (const pts of contours(gid)) {236 const n = pts.length;237 if (!n) continue;238 let si = pts.findIndex((q) => q.on);239 let startPt;240 if (si === -1) {241 startPt = mid(pts[0], pts[1 % n]);242 si = 0;243 } else startPt = pts[si];244 d += `M${P(startPt)}`;245 let prevOff = null;246 for (let k = 1; k <= n; k++) {247 const cur = pts[(si + k) % n];248 const isStart = k === n;249 const target = isStart ? startPt : cur;250 if (isStart && pts[si].on === false) break;251 if (cur.on || isStart) {252 d += prevOff ? `Q${P(prevOff)} ${P(target)}` : `L${P(target)}`;253 prevOff = null;254 } else if (prevOff) {255 d += `Q${P(prevOff)} ${P(mid(prevOff, cur))}`;256 prevOff = cur;257 } else prevOff = cur;258 }259 if (prevOff) d += `Q${P(prevOff)} ${P(startPt)}`;260 d += 'Z';261 }262 return d;263 }264265 return { unitsPerEm, capHeight, ascender, descender, glyphId, advance, glyphPath, file };266}267268/* ----------------------------------------------------------------------------------------------------------------- */269/* Wordmark: glyph positions measured by Chromium (kerning applied) at font-size = unitsPerEm → font units. */270/* ----------------------------------------------------------------------------------------------------------------- */271async function measureRuns(page, runs) {272 const faces = runs.map((r, i) => ({ name: `CA${i}`, data: readFileSync(r.font.file).toString('base64'), weight: r.weight }));273 const css = faces.map((f) => `@font-face{font-family:"${f.name}";src:url(data:font/ttf;base64,${f.data}) format("truetype");font-weight:${f.weight};}`).join('\n');274 await page.setContent(`<html><head><style>${css}</style></head><body><canvas id="c"></canvas>${faces.map((f) => `<span style="font-family:'${f.name}'">x</span>`).join('')}</body></html>`);275 return page.evaluate(276 async ({ runs, faces }) => {277 await Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 100px "${f.name}"`)));278 const ctx = document.getElementById('c').getContext('2d');279 return runs.map((r, i) => {280 ctx.font = `${faces[i].weight} ${r.upem}px "${faces[i].name}"`;281 ctx.fontKerning = 'normal';282 ctx.letterSpacing = `${r.tracking * r.upem}px`;283 const xs = [];284 for (let k = 0; k <= r.text.length; k++) xs.push(ctx.measureText(r.text.slice(0, k)).width);285 return xs; // xs[k] = pen position before glyph k (font units); xs[len] = total advance286 });287 },288 { runs: runs.map((r) => ({ text: r.text, upem: r.font.unitsPerEm, tracking: r.tracking })), faces: faces.map((f) => ({ name: f.name, weight: f.weight })) },289 );290}291292/** Returns { paths: [{d, fill}], width } for "Company Atlas" at the given font size, pen starting at (x, baselineY). */293function wordmarkPaths({ regular, semibold, measured, x, baselineY, fontSize, colors }) {294 const runs = [295 { text: 'Company ', font: regular, fill: colors.company, xs: measured[0] },296 { text: 'Atlas', font: semibold, fill: colors.atlas, xs: measured[1] },297 ];298 const paths = [];299 let pen = x;300 for (const r of runs) {301 const s = fontSize / r.font.unitsPerEm;302 let d = '';303 for (let k = 0; k < r.text.length; k++) {304 const ch = r.text[k];305 if (ch === ' ') continue;306 const gid = r.font.glyphId(ch.codePointAt(0));307 d += r.font.glyphPath(gid, pen + r.xs[k] * s, baselineY, s);308 }309 paths.push({ d, fill: r.fill });310 pen += r.xs[r.text.length] * s;311 }312 return { paths, width: pen - x };313}314315/* ----------------------------------------------------------------------------------------------------------------- */316/* SVG documents */317/* ----------------------------------------------------------------------------------------------------------------- */318const attrs = (o) =>319 Object.entries(o)320 .map(([k, v]) => `${k}="${v}"`)321 .join(' ');322const shapesToSvg = (shapes) => shapes.map((s) => `<${s.tag} ${attrs(s.attrs)}/>`).join('');323324/** Lockup: mark (size M) + wordmark; `card` wraps everything in a rounded dark card with padding. */325function lockupSvg({ markColors, textColors, fonts, measured, card = null }) {326 const M = 40; // mark size327 const GAP = 13;328 const F = 30; // font size → cap height ≈ 21 px, optically balanced with the 40 px plate329 const cap = (fonts.regular.capHeight / fonts.regular.unitsPerEm) * F;330 const baseline = M / 2 + cap / 2;331 const pad = card ? 18 : 0;332 const { paths, width } = wordmarkPaths({ regular: fonts.regular, semibold: fonts.semibold, measured, x: pad + M + GAP, baselineY: pad + baseline, fontSize: F, colors: textColors });333 const W = Math.ceil(pad + M + GAP + width + pad);334 const H = M + pad * 2;335 const mark = markShapes(markColors, { level: 'full' })336 .map((s) => ({ ...s }))337 .map((s) => `<${s.tag} ${attrs(s.attrs)}/>`)338 .join('');339 const scale = M / MARK_VIEWBOX;340 return [341 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" fill="none" role="img" aria-label="Company Atlas">`,342 `<title>Company Atlas</title>`,343 card ? `<rect x="0" y="0" width="${W}" height="${H}" rx="${card.radius}" fill="${card.fill}"/>` : '',344 `<g transform="translate(${pad} ${pad}) scale(${scale})">${mark}</g>`,345 ...paths.map((p) => `<path d="${p.d}" fill="${p.fill}"/>`),346 `</svg>`,347 ].join('');348}349350/* ----------------------------------------------------------------------------------------------------------------- */351/* Rasters + ICO */352/* ----------------------------------------------------------------------------------------------------------------- */353async function renderPng(browser, svg, px) {354 const page = await browser.newPage({ viewport: { width: px, height: px }, deviceScaleFactor: 1 });355 await page.setContent(`<html><body style="margin:0;background:transparent"><img src="data:image/svg+xml;utf8,${encodeURIComponent(svg)}" width="${px}" height="${px}" style="display:block"></body></html>`);356 await page.waitForFunction(() => document.images[0]?.complete);357 const png = await page.screenshot({ omitBackground: true, type: 'png' });358 await page.close();359 return png;360}361362/** ICO container by hand: 6-byte header, 16-byte directory entries, then PNG-encoded images (valid since Vista). */363function packIco(entries) {364 const header = Buffer.alloc(6);365 header.writeUInt16LE(0, 0); // reserved366 header.writeUInt16LE(1, 2); // type: icon367 header.writeUInt16LE(entries.length, 4);368 const dir = Buffer.alloc(16 * entries.length);369 let offset = header.length + dir.length;370 entries.forEach((e, i) => {371 const o = i * 16;372 dir.writeUInt8(e.size >= 256 ? 0 : e.size, o); // width (0 = 256)373 dir.writeUInt8(e.size >= 256 ? 0 : e.size, o + 1); // height374 dir.writeUInt8(0, o + 2); // colour palette375 dir.writeUInt8(0, o + 3); // reserved376 dir.writeUInt16LE(1, o + 4); // colour planes377 dir.writeUInt16LE(32, o + 6); // bits per pixel378 dir.writeUInt32LE(e.png.length, o + 8);379 dir.writeUInt32LE(offset, o + 12);380 offset += e.png.length;381 });382 return Buffer.concat([header, dir, ...entries.map((e) => e.png)]);383}384385/* ----------------------------------------------------------------------------------------------------------------- */386async function main() {387 mkdirSync(PUBLIC, { recursive: true });388 const fonts = { regular: parseTtf(FONT_REGULAR), semibold: parseTtf(FONT_SEMIBOLD) };389 const browser = await chromium.launch();390 try {391 const page = await browser.newPage();392 const measured = await measureRuns(page, [393 { text: 'Company ', font: fonts.regular, weight: 400, tracking: -0.02 },394 { text: 'Atlas', font: fonts.semibold, weight: 600, tracking: -0.02 },395 ]);396 await page.close();397398 // --- SVGs -------------------------------------------------------------------------------------------------------399 const written = [];400 const put = (file, data) => {401 writeFileSync(file, data);402 written.push(`${path.relative(WEB, file)} (${(data.length / 1024).toFixed(1)} KB)`);403 };404 put(path.join(APP, 'icon.svg'), markSvgString(MARK_DARK, { level: 'tiny' }));405 put(path.join(PUBLIC, 'logo-mark.svg'), markSvgString(MARK_DARK, { level: 'full' }));406 put(path.join(PUBLIC, 'logo-light.svg'), lockupSvg({ markColors: MARK_DARK, textColors: TEXT_ON_LIGHT, fonts, measured }));407 put(path.join(PUBLIC, 'logo-dark.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured }));408 put(path.join(PUBLIC, 'logo.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured, card: { fill: '#0a0d12', radius: 14 } }));409410 // --- PNGs -------------------------------------------------------------------------------------------------------411 const fav = [];412 for (const [px, level] of [413 [16, 'tiny'],414 [32, 'small'],415 [48, 'full'],416 ]) {417 fav.push({ size: px, png: await renderPng(browser, markSvgString(MARK_DARK, { level }), px) });418 }419 put(path.join(PUBLIC, 'favicon.ico'), packIco(fav));420 put(path.join(PUBLIC, 'icon-192.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 192));421 put(path.join(PUBLIC, 'icon-512.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 512));422 // maskable / Apple: full-bleed plate (the OS applies its own corner mask), globe inset stays inside the safe zone423 put(path.join(PUBLIC, 'icon-512-maskable.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 512));424 put(path.join(PUBLIC, 'apple-touch-icon.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 180));425 console.log(written.map((w) => ` wrote ${w}`).join('\n'));426 } finally {427 await browser.close();428 }429}430431await main();432