// Generates the report nameplates (site/brand/nameplates/*.svg and index.json): the subject word // from site/site.ts (in the accent) and the Chinese title, set solid in Noto Sans SC Black // (SIL OFL 1.1) as SVG paths, so the nameplate is one static logotype on every system. The Latin // capitals are scaled to stand as tall as the Chinese glyphs, both centred on one line, and the spacing // is set by ink, pair by pair. // // Usage: node scripts/nameplates.ts <@fontsource/noto-sans-sc package directory> // (fetch it with `npm pack @fontsource/noto-sans-sc@5.3.0` and untar; it is not a dependency) import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import opentype from "opentype.js"; import { SITE } from "@aihot/site"; const pkg = process.argv[2]; if (!pkg) throw new Error("usage: node scripts/nameplates.ts "); /** Ink height of the Chinese glyphs, in the nameplate's units. */ const H = 220; /** Latin capitals a touch shorter than the ideographs, which read larger. */ const CAPS = H * 0.94; const MARGIN = 12; const isLatin = (ch: string) => /[A-Za-z]/.test(ch); /** Space between the inks of two neighbours. */ function gap(a: string, b: string): number { if (isLatin(a) && isLatin(b)) return H * 0.07; if (isLatin(a) !== isLatin(b)) return H * 0.17; return H * 0.085; } const S = SITE.subject; const NAMEPLATES: Record> = { daily: [ { text: S, accent: true }, { text: "日报", accent: false }, ], weekly: [ { text: S, accent: true }, { text: "周报", accent: false }, ], monthly: [ { text: S, accent: true }, { text: "月报", accent: false }, ], archive: [ { text: "日报", accent: false }, { text: "合订本", accent: true }, ], }; // Which woff slice of the 900 weight holds each character. const css = readFileSync(path.join(pkg, "900.css"), "utf8"); const faces = [...css.matchAll(/url\(\.\/files\/([\w-]+)\.woff2\)[^;]*;\s*unicode-range: ([^;]+);/g)].map((m) => ({ file: `${m[1]}.woff`, ranges: m[2]!.split(",").map((r) => r.trim().replace("U+", "").split("-").map((h) => parseInt(h, 16))), })); const fonts = new Map(); function fontFor(ch: string): opentype.Font { const cp = ch.codePointAt(0)!; const face = faces.find((f) => f.ranges.some(([a, b]) => cp >= a! && cp <= (b ?? a!))); if (!face) throw new Error(`no slice holds ${ch}`); if (!fonts.has(face.file)) { const buf = readFileSync(path.join(pkg, "files", face.file)); fonts.set(face.file, opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))); } return fonts.get(face.file)!; } // One scale for all the Chinese glyphs and one for the capitals, from their union ink at 1000 units; // both are centred on the same line. const chars = [...new Set(Object.values(NAMEPLATES).flatMap((parts) => parts.flatMap((p) => [...p.text])))]; const boxes = new Map(chars.map((ch) => [ch, fontFor(ch).getPath(ch, 0, 0, 1000).getBoundingBox()])); function fit(group: string[], height: number) { const top = Math.min(...group.map((ch) => boxes.get(ch)!.y1)); const bottom = Math.max(...group.map((ch) => boxes.get(ch)!.y2)); const size = (1000 * height) / (bottom - top); return { size, baseline: -((top + bottom) / 2) * (size / 1000) }; } const cjk = fit(chars.filter((ch) => !isLatin(ch)), H); const latin = chars.some(isLatin) ? fit(chars.filter(isLatin), CAPS) : cjk; const out: Record = {}; for (const [name, parts] of Object.entries(NAMEPLATES)) { const accent: string[] = []; const ink: string[] = []; let x = MARGIN; let prev: string | null = null; for (const part of parts) { for (const ch of part.text) { const { size, baseline } = isLatin(ch) ? latin : cjk; const box = boxes.get(ch)!; const scale = size / 1000; if (prev) x += gap(prev, ch); const origin = x - box.x1 * scale; (part.accent ? accent : ink).push(fontFor(ch).getPath(ch, origin, baseline, size).toPathData(1)); x = origin + box.x2 * scale; prev = ch; } } const half = Math.max(H, CAPS) / 2 + MARGIN; out[name] = { viewBox: `0 ${(-half).toFixed(1)} ${(x + MARGIN).toFixed(1)} ${(2 * half).toFixed(1)}`, accent: accent.join(" "), ink: ink.join(" ") }; } const target = "site/brand/nameplates"; mkdirSync(target, { recursive: true }); for (const [name, n] of Object.entries(out)) { writeFileSync(`${target}/${name}.svg`, `\n\n`); } writeFileSync(`${target}/index.json`, `${JSON.stringify(Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.viewBox])), null, 2)}\n`); console.log(target, Object.fromEntries(Object.entries(out).map(([k, v]) => [k, `${v.viewBox} · ${((v.accent.length + v.ink.length) / 1000).toFixed(1)} kB`])));