Feat: Build readable Stain Regular font

This commit is contained in:
2025-11-08 22:50:25 -08:00
parent e010a0832a
commit 90860335a2

View File

@@ -2,152 +2,74 @@ import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import opentype from "opentype.js"; import opentype from "opentype.js";
import { buildGlyph, METRICS } from "../src/glyphs.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const distDir = path.join(__dirname, "..", "dist"); const distDir = path.join(__dirname, "..", "dist");
if (!fs.existsSync(distDir)) fs.mkdirSync(distDir, { recursive: true }); if (!fs.existsSync(distDir)) fs.mkdirSync(distDir, { recursive: true });
const upem = 1000; const FAMILY = "Stain";
const ascent = 800; const STYLE = "Regular";
const descent = -200;
const defaultWidth = 600;
// AI-style stub: deterministic parametric glyph generator const charset = [
// This is where future AI (you call me) can map characters -> vector instructions. " ",
function genGlyphPathForChar(ch) { "A","B","C","D","E","F","G","H","I","J","K","L","M",
"N","O","P","Q","R","S","T","U","V","W","X","Y","Z",
"0","1","2","3","4","5","6","7","8","9",
".",",",":",";","!","?","-","","—","@"
];
function buildNotdef() {
const w = 600;
const p = new opentype.Path(); const p = new opentype.Path();
const code = ch.charCodeAt(0); const m = 60;
p.moveTo(m, METRICS.descender);
const seed = (code * 73) % 997; p.lineTo(w - m, METRICS.descender);
const thickness = 40 + (seed % 60); p.lineTo(w - m, METRICS.ascender);
const inset = 80 + (seed % 120); p.lineTo(m, METRICS.ascender);
const cx = defaultWidth / 2;
// Vertical stem
p.moveTo(cx - thickness / 2, ascent);
p.lineTo(cx + thickness / 2, ascent);
p.lineTo(cx + thickness / 2, descent);
p.lineTo(cx - thickness / 2, descent);
p.close(); p.close();
p.moveTo(m + 60, METRICS.descender + 60);
// Top stain-like slab p.lineTo(w - m - 60, METRICS.descender + 60);
const slabW = 260 + (seed % 140); p.lineTo(w - m - 60, METRICS.ascender - 60);
const slabH = 80 + (seed % 50); p.lineTo(m + 60, METRICS.ascender - 60);
p.moveTo(cx - slabW / 2, ascent);
p.lineTo(cx + slabW / 2, ascent);
p.lineTo(cx + slabW / 2, ascent - slabH);
p.quadraticCurveTo(
cx,
ascent - slabH - (seed % 40),
cx - slabW / 2,
ascent - slabH
);
p.close(); p.close();
return new opentype.Glyph({
// Bottom irregular notch name: ".notdef",
const notchW = 120 + (seed % 80); unicode: 0,
const notchY = descent + 40 + (seed % 60); advanceWidth: w,
p.moveTo(cx - notchW / 2, notchY); path: p
p.lineTo(cx + notchW / 2, notchY); });
p.lineTo(cx + notchW / 2, descent);
p.quadraticCurveTo(
cx,
descent - (seed % 50),
cx - notchW / 2,
descent
);
p.close();
// Inner void
const inner = inset;
if (inner + thickness * 1.5 < defaultWidth / 2) {
const iw = defaultWidth - inner * 2;
const ih = ascent - inner - 200;
if (iw > 80 && ih > 80) {
p.moveTo(inner, ih);
p.lineTo(inner + iw, ih);
p.lineTo(inner + iw, inner + 80);
p.lineTo(inner, inner + 80);
p.close();
}
}
return p;
} }
function createGlyph(ch, advanceWidth = defaultWidth) { function createGlyph(ch) {
const unicode = ch.charCodeAt(0); const unicode = ch.codePointAt(0);
const pathObj = genGlyphPathForChar(ch); const { path, width } = buildGlyph(ch);
return new opentype.Glyph({ return new opentype.Glyph({
name: ch === " " ? "space" : `uni${unicode.toString(16).toUpperCase()}`, name: ch === " " ? "space" : `uni${unicode.toString(16).toUpperCase()}`,
unicode, unicode,
advanceWidth, advanceWidth: width,
path: pathObj path
}); });
} }
function buildFont() { function buildFont() {
const fontName = "StainFont Basic"; const glyphs = [buildNotdef()];
const chars = [ charset.forEach(ch => glyphs.push(createGlyph(ch)));
" ",
"A","B","C","D","E","F","G","H","I","J","K","L","M",
"N","O","P","Q","R","S","T","U","V","W","X","Y","Z",
"0","1","2","3","4","5","6","7","8","9",
".","!", "?", "@", "#", "-", "_", ":", ";", ","
];
const glyphs = [];
glyphs.push(new opentype.Glyph({
name: ".notdef",
unicode: 0,
advanceWidth: defaultWidth,
path: (() => {
const p = new opentype.Path();
p.moveTo(80, descent);
p.lineTo(defaultWidth - 80, descent);
p.lineTo(defaultWidth - 80, ascent);
p.lineTo(80, ascent);
p.close();
p.moveTo(140, descent + 60);
p.lineTo(defaultWidth - 140, descent + 60);
p.lineTo(defaultWidth - 140, ascent - 60);
p.lineTo(140, ascent - 60);
p.close();
return p;
})()
}));
chars.forEach(ch => {
if (ch === " ") {
glyphs.push(new opentype.Glyph({
name: "space",
unicode: 32,
advanceWidth: defaultWidth * 0.5,
path: new opentype.Path()
}));
} else {
glyphs.push(createGlyph(ch));
}
});
const font = new opentype.Font({ const font = new opentype.Font({
familyName: fontName, familyName: FAMILY,
styleName: "Regular", styleName: STYLE,
unitsPerEm: upem, unitsPerEm: METRICS.unitsPerEm,
ascender: ascent, ascender: METRICS.ascender,
descender: descent, descender: METRICS.descender,
glyphs glyphs
}); });
const ttfBuffer = Buffer.from(font.toArrayBuffer()); const ttf = Buffer.from(font.toArrayBuffer());
const out = path.join(distDir, "Stain-Regular.ttf");
const outTTF = path.join(distDir, "StainFont-Basic.ttf"); fs.writeFileSync(out, ttf);
fs.writeFileSync(outTTF, ttfBuffer); console.log(`Built: ${out}`);
console.log(`Built: ${outTTF}`);
} }
buildFont(); buildFont();