From c7ae4f214b43136413fc348c15fb94ef6d6423d2 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 9 Nov 2025 08:46:53 -0800 Subject: [PATCH] Feat: Generate Stain font ttf in dist --- scripts/build-font.js | 290 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 scripts/build-font.js diff --git a/scripts/build-font.js b/scripts/build-font.js new file mode 100644 index 0000000..9a52998 --- /dev/null +++ b/scripts/build-font.js @@ -0,0 +1,290 @@ +import fs from "fs"; +import path from "path"; +import url from "url"; +import opentype from "opentype.js"; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const distDir = path.join(__dirname, "..", "dist"); +if (!fs.existsSync(distDir)) fs.mkdirSync(distDir, { recursive: true }); + +const UPM = 1000; +const ASC = 750; +const DSC = -250; +const STROKE = 72; +const CURVE = 0.44; +const LIGHT = 0.085; +const ANG = 9 * Math.PI / 180; + +const makeGlyph = ({ + name, + unicode, + advanceWidth, + path: makePath +}) => new opentype.Glyph({ + name, + unicode, + advanceWidth, + path: makePath() +}); + +const slant = x => x + Math.tan(ANG) * 0.12 * UPM; +const mix = (a, b, t) => a + (b - a) * t; +const curvePt = (x1, y1, x2, y2, t) => ({ + x: mix(x1, x2, t), + y: mix(y1, y2, t) +}); + +const baseMetrics = { + ascender: ASC, + descender: DSC, + unitsPerEm: UPM +}; + +const stainCommon = { + familyName: "Stain", + styleName: "Regular", + ...baseMetrics +}; + +const pA = () => { + const p = new opentype.Path(); + const w = 640; + const barH = 320; + const apexX = slant(120); + const apexY = ASC; + const leftBaseX = slant(80); + const rightBaseX = slant(w - 40); + const baseY = DSC * LIGHT; + p.moveTo(rightBaseX, baseY); + p.lineTo(rightBaseX - STROKE * 0.32, baseY); + p.lineTo(apexX + 36, apexY); + p.quadraticCurveTo(apexX, apexY - 18, apexX - 36, apexY); + p.lineTo(leftBaseX + STROKE * 0.28, baseY); + p.lineTo(leftBaseX, baseY); + p.close(); + const ib = 0.35; + const iLeft = mix(leftBaseX, rightBaseX, ib); + const iRight = mix(leftBaseX, rightBaseX, ib + 0.36); + const iTop = barH + 22; + const iBot = barH - 22; + p.moveTo(iLeft, iTop); + p.lineTo(iRight, iTop); + p.lineTo(iRight, iBot); + p.lineTo(iLeft, iBot); + p.close(); + return p; +}; + +const pB = () => { + const p = new opentype.Path(); + const xL = slant(80); + const xStemR = xL + STROKE * 0.9; + const yTop = ASC; + const yBot = DSC * LIGHT; + const midY = (yTop + yBot) / 2 + 40; + const o = 120; + const w = 640; + const cxR = slant(w - 40); + p.moveTo(xStemR, yBot); + p.lineTo(xL, yBot); + p.lineTo(xL, yTop); + p.lineTo(xStemR, yTop); + p.close(); + p.moveTo(xStemR, yTop); + p.curveTo( + mix(xStemR, cxR, CURVE), yTop, + cxR, mix(yTop, midY + 26, CURVE), + cxR, midY + 26 + ); + p.curveTo( + cxR, mix(midY + 26, midY - 8, CURVE), + mix(xStemR + o, cxR, CURVE), midY - 8, + xStemR + o, midY - 8 + ); + p.lineTo(xStemR, midY - 8); + p.close(); + p.moveTo(xStemR, midY - 26); + p.curveTo( + mix(xStemR, cxR, CURVE), midY - 26, + cxR, mix(midY - 26, yBot + 40, CURVE), + cxR, yBot + 40 + ); + p.curveTo( + cxR, mix(yBot + 40, yBot + 8, CURVE), + mix(xStemR + o, cxR, CURVE), yBot + 8, + xStemR + o, yBot + 8 + ); + p.lineTo(xStemR, yBot + 8); + p.close(); + return p; +}; + +const pC = () => { + const p = new opentype.Path(); + const cx = slant(360); + const cy = ASC * 0.55; + const rx = 290; + const ry = 260; + const i = 0.56; + const rxI = rx * i; + const ryI = ry * i; + const startAng = Math.PI * 0.22; + const endAng = Math.PI * 1.78; + const arc = (R, rY, rev) => { + const dir = rev ? -1 : 1; + const steps = 6; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const a = mix(startAng, endAng, t); + const x = cx + Math.cos(a) * R; + const y = cy + Math.sin(a) * rY; + if (i === 0 && !rev) p.moveTo(x, y); + else p.lineTo(x, y); + } + }; + arc(rx, ry, false); + arc(rxI, ryI, true); + p.close(); + const cutW = 80; + const cutX = cx + rx * 0.76; + const cutY1 = cy + 40; + const cutY2 = cy - 40; + p.moveTo(cutX, cutY1); + p.lineTo(cutX + cutW, cutY1 + 16); + p.lineTo(cutX + cutW, cutY2 - 16); + p.lineTo(cutX, cutY2); + p.close(); + return p; +}; + +const pa = () => { + const p = new opentype.Path(); + const x = slant(120); + const yBase = 0; + const h = 500; + const rx = 180; + const ry = 210; + const cx = slant(260); + const cy = h * 0.54; + const steps = 5; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const a = Math.PI * (0.15 + 1.85 * t); + const xx = cx + Math.cos(a) * rx; + const yy = cy + Math.sin(a) * ry; + if (!i) p.moveTo(xx, yy); + else p.lineTo(xx, yy); + } + const rxI = rx * 0.56; + const ryI = ry * 0.56; + for (let i = steps; i >= 0; i--) { + const t = i / steps; + const a = Math.PI * (0.18 + 1.6 * t); + const xx = cx + Math.cos(a) * rxI; + const yy = cy + Math.sin(a) * ryI; + p.lineTo(xx, yy); + } + p.close(); + const stemX = slant(360); + const top = h + 14; + p.moveTo(stemX, yBase - 10); + p.lineTo(stemX + STROKE * 0.7, yBase); + p.lineTo(stemX + STROKE * 0.7, top); + p.lineTo(stemX, top - 14); + p.close(); + return p; +}; + +const pb = () => { + const p = new opentype.Path(); + const x = slant(120); + const yBase = 0; + const top = ASC; + p.moveTo(x, yBase - 10); + p.lineTo(x + STROKE * 0.8, yBase); + p.lineTo(x + STROKE * 0.8, top); + p.lineTo(x, top - 16); + p.close(); + const cx = slant(340); + const cy = 360; + const rx = 180; + const ry = 210; + const steps = 6; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const a = Math.PI * (0.1 + 1.9 * t); + const xx = cx + Math.cos(a) * rx; + const yy = cy + Math.sin(a) * ry; + if (!i) p.moveTo(xx, yy); + else p.lineTo(xx, yy); + } + const rxI = rx * 0.55; + const ryI = ry * 0.55; + for (let i = steps; i >= 0; i--) { + const t = i / steps; + const a = Math.PI * (0.2 + 1.6 * t); + const xx = cx + Math.cos(a) * rxI; + const yy = cy + Math.sin(a) * ryI; + p.lineTo(xx, yy); + } + p.close(); + return p; +}; + +const pc = () => { + const p = new opentype.Path(); + const cx = slant(260); + const cy = 320; + const rx = 210; + const ry = 210; + const rxI = rx * 0.56; + const ryI = ry * 0.56; + const steps = 6; + const s = Math.PI * 0.18; + const e = Math.PI * 1.82; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const a = mix(s, e, t); + const x = cx + Math.cos(a) * rx; + const y = cy + Math.sin(a) * ry; + if (!i) p.moveTo(x, y); + else p.lineTo(x, y); + } + for (let i = steps; i >= 0; i--) { + const t = i / steps; + const a = mix(s + 0.08, e - 0.22, t); + const x = cx + Math.cos(a) * rxI; + const y = cy + Math.sin(a) * ryI; + p.lineTo(x, y); + } + p.close(); + return p; +}; + +const glyphs = [ + new opentype.Glyph({ name: ".notdef", advanceWidth: 600, path: new opentype.Path() }), + new opentype.Glyph({ name: "space", unicode: 32, advanceWidth: 260, path: new opentype.Path() }), + makeGlyph({ name: "A", unicode: 65, advanceWidth: 640, path: pA }), + makeGlyph({ name: "B", unicode: 66, advanceWidth: 640, path: pB }), + makeGlyph({ name: "C", unicode: 67, advanceWidth: 640, path: pC }), + makeGlyph({ name: "a", unicode: 97, advanceWidth: 540, path: pa }), + makeGlyph({ name: "b", unicode: 98, advanceWidth: 540, path: pb }), + makeGlyph({ name: "c", unicode: 99, advanceWidth: 520, path: pc }) +]; + +const font = new opentype.Font({ + ...stainCommon, + glyphs, + version: "0.3", + designer: "multipleof4", + license: "OFL-1.1", + manufacturer: "multipleof4", + description: "Stain: a Candara-inspired humanist sans prototype.", + trademark: "Stain" +}); + +const outPath = path.join(distDir, "Stain-Regular.ttf"); +const buf = Buffer.from(font.toArrayBuffer()); +fs.writeFileSync(outPath, buf); +console.log("Built", outPath);