#!/usr/bin/env node /** * Company Atlas — brand asset build. * * From the single mark geometry (`src/components/brand/geometry.ts`, imported with Node's type stripping) and the Geist * TTFs shipped by the `geist` package, writes: * * src/app/icon.svg favicon (tiny detail level, hard-coded colours) → served at /icon.svg * public/logo-mark.svg the mark alone, dark plate * public/logo.svg lockup on its own dark card (works on any background) * public/logo-light.svg lockup for light backgrounds (dark plate, dark text), transparent * public/logo-dark.svg lockup for dark backgrounds (light plate, light text), transparent * public/favicon.ico real multi-size ICO (16 / 32 / 48, PNG-encoded entries) * public/icon-192.png, icon-512.png, icon-512-maskable.png, apple-touch-icon.png * * The wordmark in the standalone SVGs is real vector text: glyph outlines are read from Geist-Regular / Geist-SemiBold * (TrueType `glyf`), and glyph positions (advances + kerning) are measured by Chromium with the same font files, so the * exported logo needs no font installed. PNGs are rendered by Playwright at 1× (no sub-pixel hairlines: stroke weights * come from the geometry's detail levels). * * Run from apps/web: node scripts/build-icons.mjs (env PLAYWRIGHT_MODULE overrides the Playwright import path) */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const WEB = path.resolve(HERE, '..'); const PUBLIC = path.join(WEB, 'public'); const APP = path.join(WEB, 'src', 'app'); const PLAYWRIGHT = process.env.PLAYWRIGHT_MODULE ?? '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; const { chromium } = await import(PLAYWRIGHT); const geometry = await import('../src/components/brand/geometry.ts'); const { MARK_DARK, MARK_LIGHT, MARK_VIEWBOX, markShapes, markSvgString } = geometry; const require = createRequire(import.meta.url); // `geist` only exports its entry points, so locate dist/ through `geist/font` (→ dist/font.js) and walk to the TTFs. const GEIST_DIR = path.join(path.dirname(require.resolve('geist/font')), 'fonts', 'geist-sans'); const FONT_REGULAR = path.join(GEIST_DIR, 'Geist-Regular.ttf'); const FONT_SEMIBOLD = path.join(GEIST_DIR, 'Geist-SemiBold.ttf'); // Wordmark palette (mirrors --ink / --ink-2 of each theme in globals.css). const TEXT_ON_LIGHT = { company: '#4a5160', atlas: '#0f1419' }; const TEXT_ON_DARK = { company: '#a3a9b8', atlas: '#e8ebf1' }; /* ----------------------------------------------------------------------------------------------------------------- */ /* Minimal TrueType reader: cmap (format 4/12) → glyph id, glyf outlines (simple + composite) → SVG path data. */ /* ----------------------------------------------------------------------------------------------------------------- */ function parseTtf(file) { const buf = readFileSync(file); const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); const tables = {}; const n = dv.getUint16(4); for (let i = 0; i < n; i++) { const o = 12 + i * 16; tables[buf.toString('ascii', o, o + 4)] = { offset: dv.getUint32(o + 8), length: dv.getUint32(o + 12) }; } 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)`); const unitsPerEm = dv.getUint16(tables.head.offset + 18); const longLoca = dv.getInt16(tables.head.offset + 50) === 1; const numGlyphs = dv.getUint16(tables.maxp.offset + 4); const loca = new Array(numGlyphs + 1); 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; const os2 = tables['OS/2']; const capHeight = os2 && dv.getUint16(os2.offset) >= 2 ? dv.getInt16(os2.offset + 88) : Math.round(unitsPerEm * 0.7); const ascender = dv.getInt16(tables.hhea.offset + 4); const descender = dv.getInt16(tables.hhea.offset + 6); // cmap: prefer a Unicode subtable (platform 3/1 or 0/x), formats 4 and 12 const cmap = tables.cmap.offset; const subtables = dv.getUint16(cmap + 2); let best = null; for (let i = 0; i < subtables; i++) { const pid = dv.getUint16(cmap + 4 + i * 8); const eid = dv.getUint16(cmap + 6 + i * 8); const off = dv.getUint32(cmap + 8 + i * 8); const fmt = dv.getUint16(cmap + off); const score = fmt === 12 ? 3 : fmt === 4 ? 2 : 0; if ((pid === 3 && (eid === 1 || eid === 10)) || pid === 0) if (score && (!best || score > best.score)) best = { off: cmap + off, fmt, score }; } if (!best) throw new Error('no usable cmap subtable'); function glyphId(cp) { const o = best.off; if (best.fmt === 12) { const groups = dv.getUint32(o + 12); for (let g = 0; g < groups; g++) { const s = dv.getUint32(o + 16 + g * 12); const e = dv.getUint32(o + 20 + g * 12); if (cp >= s && cp <= e) return dv.getUint32(o + 24 + g * 12) + (cp - s); } return 0; } const segX2 = dv.getUint16(o + 6); const seg = segX2 / 2; const endP = o + 14; const startP = endP + segX2 + 2; const deltaP = startP + segX2; const rangeP = deltaP + segX2; for (let i = 0; i < seg; i++) { const end = dv.getUint16(endP + i * 2); if (cp > end) continue; const start = dv.getUint16(startP + i * 2); if (cp < start) return 0; const delta = dv.getInt16(deltaP + i * 2); const rangeOff = dv.getUint16(rangeP + i * 2); if (rangeOff === 0) return (cp + delta) & 0xffff; const addr = rangeP + i * 2 + rangeOff + (cp - start) * 2; const gid = dv.getUint16(addr); return gid === 0 ? 0 : (gid + delta) & 0xffff; } return 0; } const numHMetrics = dv.getUint16(tables.hhea.offset + 34); function advance(gid) { const i = Math.min(gid, numHMetrics - 1); return dv.getUint16(tables.hmtx.offset + i * 4); } /** Contours of one glyph as arrays of {x, y, on} in font units (y up); composites are flattened. */ function contours(gid, depth = 0) { const start = tables.glyf.offset + loca[gid]; const end = tables.glyf.offset + loca[gid + 1]; if (end <= start) return []; const nc = dv.getInt16(start); if (nc >= 0) { let p = start + 10; const endPts = []; for (let i = 0; i < nc; i++) endPts.push(dv.getUint16(p + i * 2)); p += nc * 2; const nPts = nc ? endPts[nc - 1] + 1 : 0; const instrLen = dv.getUint16(p); p += 2 + instrLen; const flags = new Array(nPts); for (let i = 0; i < nPts; ) { const f = dv.getUint8(p++); flags[i++] = f; if (f & 8) { let r = dv.getUint8(p++); while (r-- > 0 && i < nPts) flags[i++] = f; } } const xs = new Array(nPts); let v = 0; for (let i = 0; i < nPts; i++) { const f = flags[i]; if (f & 2) { const d = dv.getUint8(p++); v += f & 16 ? d : -d; } else if (!(f & 16)) { v += dv.getInt16(p); p += 2; } xs[i] = v; } const ys = new Array(nPts); v = 0; for (let i = 0; i < nPts; i++) { const f = flags[i]; if (f & 4) { const d = dv.getUint8(p++); v += f & 32 ? d : -d; } else if (!(f & 32)) { v += dv.getInt16(p); p += 2; } ys[i] = v; } const out = []; let s = 0; for (let c = 0; c < nc; c++) { const pts = []; for (let i = s; i <= endPts[c]; i++) pts.push({ x: xs[i], y: ys[i], on: (flags[i] & 1) === 1 }); out.push(pts); s = endPts[c] + 1; } return out; } // composite glyph if (depth > 4) return []; const out = []; let p = start + 10; for (;;) { const flags = dv.getUint16(p); const cg = dv.getUint16(p + 2); p += 4; let dx; let dy; if (flags & 1) { dx = dv.getInt16(p); dy = dv.getInt16(p + 2); p += 4; } else { dx = dv.getInt8(p); dy = dv.getInt8(p + 1); p += 2; } let a = 1; let b = 0; let c = 0; let d = 1; const f2 = (o) => dv.getInt16(o) / 16384; if (flags & 8) { a = d = f2(p); p += 2; } else if (flags & 0x40) { a = f2(p); d = f2(p + 2); p += 4; } else if (flags & 0x80) { a = f2(p); b = f2(p + 2); c = f2(p + 4); d = f2(p + 6); p += 8; } if (!(flags & 2)) { dx = 0; dy = 0; } // point-matching placement is not needed for Latin letters 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 }))); if (!(flags & 0x20)) break; } return out; } /** SVG path data for a glyph, transformed: X = ox + x·s, Y = oy − y·s (font y-up → SVG y-down). */ function glyphPath(gid, ox, oy, s) { const f = (v) => (Math.round(v * 100) / 100).toString(); const P = (q) => `${f(ox + q.x * s)} ${f(oy - q.y * s)}`; const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); let d = ''; for (const pts of contours(gid)) { const n = pts.length; if (!n) continue; let si = pts.findIndex((q) => q.on); let startPt; if (si === -1) { startPt = mid(pts[0], pts[1 % n]); si = 0; } else startPt = pts[si]; d += `M${P(startPt)}`; let prevOff = null; for (let k = 1; k <= n; k++) { const cur = pts[(si + k) % n]; const isStart = k === n; const target = isStart ? startPt : cur; if (isStart && pts[si].on === false) break; if (cur.on || isStart) { d += prevOff ? `Q${P(prevOff)} ${P(target)}` : `L${P(target)}`; prevOff = null; } else if (prevOff) { d += `Q${P(prevOff)} ${P(mid(prevOff, cur))}`; prevOff = cur; } else prevOff = cur; } if (prevOff) d += `Q${P(prevOff)} ${P(startPt)}`; d += 'Z'; } return d; } return { unitsPerEm, capHeight, ascender, descender, glyphId, advance, glyphPath, file }; } /* ----------------------------------------------------------------------------------------------------------------- */ /* Wordmark: glyph positions measured by Chromium (kerning applied) at font-size = unitsPerEm → font units. */ /* ----------------------------------------------------------------------------------------------------------------- */ async function measureRuns(page, runs) { const faces = runs.map((r, i) => ({ name: `CA${i}`, data: readFileSync(r.font.file).toString('base64'), weight: r.weight })); 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'); await page.setContent(`${faces.map((f) => `x`).join('')}`); return page.evaluate( async ({ runs, faces }) => { await Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 100px "${f.name}"`))); const ctx = document.getElementById('c').getContext('2d'); return runs.map((r, i) => { ctx.font = `${faces[i].weight} ${r.upem}px "${faces[i].name}"`; ctx.fontKerning = 'normal'; ctx.letterSpacing = `${r.tracking * r.upem}px`; const xs = []; for (let k = 0; k <= r.text.length; k++) xs.push(ctx.measureText(r.text.slice(0, k)).width); return xs; // xs[k] = pen position before glyph k (font units); xs[len] = total advance }); }, { runs: runs.map((r) => ({ text: r.text, upem: r.font.unitsPerEm, tracking: r.tracking })), faces: faces.map((f) => ({ name: f.name, weight: f.weight })) }, ); } /** Returns { paths: [{d, fill}], width } for "Company Atlas" at the given font size, pen starting at (x, baselineY). */ function wordmarkPaths({ regular, semibold, measured, x, baselineY, fontSize, colors }) { const runs = [ { text: 'Company ', font: regular, fill: colors.company, xs: measured[0] }, { text: 'Atlas', font: semibold, fill: colors.atlas, xs: measured[1] }, ]; const paths = []; let pen = x; for (const r of runs) { const s = fontSize / r.font.unitsPerEm; let d = ''; for (let k = 0; k < r.text.length; k++) { const ch = r.text[k]; if (ch === ' ') continue; const gid = r.font.glyphId(ch.codePointAt(0)); d += r.font.glyphPath(gid, pen + r.xs[k] * s, baselineY, s); } paths.push({ d, fill: r.fill }); pen += r.xs[r.text.length] * s; } return { paths, width: pen - x }; } /* ----------------------------------------------------------------------------------------------------------------- */ /* SVG documents */ /* ----------------------------------------------------------------------------------------------------------------- */ const attrs = (o) => Object.entries(o) .map(([k, v]) => `${k}="${v}"`) .join(' '); const shapesToSvg = (shapes) => shapes.map((s) => `<${s.tag} ${attrs(s.attrs)}/>`).join(''); /** Lockup: mark (size M) + wordmark; `card` wraps everything in a rounded dark card with padding. */ function lockupSvg({ markColors, textColors, fonts, measured, card = null }) { const M = 40; // mark size const GAP = 13; const F = 30; // font size → cap height ≈ 21 px, optically balanced with the 40 px plate const cap = (fonts.regular.capHeight / fonts.regular.unitsPerEm) * F; const baseline = M / 2 + cap / 2; const pad = card ? 18 : 0; const { paths, width } = wordmarkPaths({ regular: fonts.regular, semibold: fonts.semibold, measured, x: pad + M + GAP, baselineY: pad + baseline, fontSize: F, colors: textColors }); const W = Math.ceil(pad + M + GAP + width + pad); const H = M + pad * 2; const mark = markShapes(markColors, { level: 'full' }) .map((s) => ({ ...s })) .map((s) => `<${s.tag} ${attrs(s.attrs)}/>`) .join(''); const scale = M / MARK_VIEWBOX; return [ ``, `Company Atlas`, card ? `` : '', `${mark}`, ...paths.map((p) => ``), ``, ].join(''); } /* ----------------------------------------------------------------------------------------------------------------- */ /* Rasters + ICO */ /* ----------------------------------------------------------------------------------------------------------------- */ async function renderPng(browser, svg, px) { const page = await browser.newPage({ viewport: { width: px, height: px }, deviceScaleFactor: 1 }); await page.setContent(``); await page.waitForFunction(() => document.images[0]?.complete); const png = await page.screenshot({ omitBackground: true, type: 'png' }); await page.close(); return png; } /** ICO container by hand: 6-byte header, 16-byte directory entries, then PNG-encoded images (valid since Vista). */ function packIco(entries) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserved header.writeUInt16LE(1, 2); // type: icon header.writeUInt16LE(entries.length, 4); const dir = Buffer.alloc(16 * entries.length); let offset = header.length + dir.length; entries.forEach((e, i) => { const o = i * 16; dir.writeUInt8(e.size >= 256 ? 0 : e.size, o); // width (0 = 256) dir.writeUInt8(e.size >= 256 ? 0 : e.size, o + 1); // height dir.writeUInt8(0, o + 2); // colour palette dir.writeUInt8(0, o + 3); // reserved dir.writeUInt16LE(1, o + 4); // colour planes dir.writeUInt16LE(32, o + 6); // bits per pixel dir.writeUInt32LE(e.png.length, o + 8); dir.writeUInt32LE(offset, o + 12); offset += e.png.length; }); return Buffer.concat([header, dir, ...entries.map((e) => e.png)]); } /* ----------------------------------------------------------------------------------------------------------------- */ async function main() { mkdirSync(PUBLIC, { recursive: true }); const fonts = { regular: parseTtf(FONT_REGULAR), semibold: parseTtf(FONT_SEMIBOLD) }; const browser = await chromium.launch(); try { const page = await browser.newPage(); const measured = await measureRuns(page, [ { text: 'Company ', font: fonts.regular, weight: 400, tracking: -0.02 }, { text: 'Atlas', font: fonts.semibold, weight: 600, tracking: -0.02 }, ]); await page.close(); // --- SVGs ------------------------------------------------------------------------------------------------------- const written = []; const put = (file, data) => { writeFileSync(file, data); written.push(`${path.relative(WEB, file)} (${(data.length / 1024).toFixed(1)} KB)`); }; put(path.join(APP, 'icon.svg'), markSvgString(MARK_DARK, { level: 'tiny' })); put(path.join(PUBLIC, 'logo-mark.svg'), markSvgString(MARK_DARK, { level: 'full' })); put(path.join(PUBLIC, 'logo-light.svg'), lockupSvg({ markColors: MARK_DARK, textColors: TEXT_ON_LIGHT, fonts, measured })); put(path.join(PUBLIC, 'logo-dark.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured })); put(path.join(PUBLIC, 'logo.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured, card: { fill: '#0a0d12', radius: 14 } })); // --- PNGs ------------------------------------------------------------------------------------------------------- const fav = []; for (const [px, level] of [ [16, 'tiny'], [32, 'small'], [48, 'full'], ]) { fav.push({ size: px, png: await renderPng(browser, markSvgString(MARK_DARK, { level }), px) }); } put(path.join(PUBLIC, 'favicon.ico'), packIco(fav)); put(path.join(PUBLIC, 'icon-192.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 192)); put(path.join(PUBLIC, 'icon-512.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 512)); // maskable / Apple: full-bleed plate (the OS applies its own corner mask), globe inset stays inside the safe zone put(path.join(PUBLIC, 'icon-512-maskable.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 512)); put(path.join(PUBLIC, 'apple-touch-icon.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 180)); console.log(written.map((w) => ` wrote ${w}`).join('\n')); } finally { await browser.close(); } } await main();