SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
19.1 KB · 432 lines javascript
Raw Blame History
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