Skip to content

Instantly share code, notes, and snippets.

@rsms
Last active June 28, 2026 00:08
Show Gist options
  • Select an option

  • Save rsms/a8ad736ba3d448100577de2b88e826de to your computer and use it in GitHub Desktop.

Select an option

Save rsms/a8ad736ba3d448100577de2b88e826de to your computer and use it in GitHub Desktop.
// Find the latest version of this script here:
// https://gist.github.com/rsms/a8ad736ba3d448100577de2b88e826de
//
const EM = 2048
interface FontInfo {
familyName :string
styleName :string
unitsPerEm :int
ascender :int
descender :int
baseline :int
figptPerEm :number // height of glyphs; pt/em
glyphs :GlyphInfo[]
}
interface GlyphInfo {
paths: ReadonlyArray<VectorPath>
name: string
width: number
offsetx: number // ~ left side bearing
offsety: number
unicodes: int[]
}
let otworker = createWindow({title:"Font",width:500}, async w => {
let opentype = await w.import("opentype.js")
const round = Math.round
const fontData = await w.recv<FontInfo>()
const EM = fontData.unitsPerEm
const scale = EM / fontData.figptPerEm
const glyphs = [new opentype.Glyph({
name: '.notdef',
unicode: 0,
advanceWidth: EM,
path: new opentype.Path(),
})]
// for each glyph
for (let gd of fontData.glyphs) {
const path = new opentype.Path()
const commands = genGlyphCommands(fontData, gd)
path.extend(commands)
const g = new opentype.Glyph({
name: gd.name,
unicode: gd.unicodes[0],
advanceWidth: round(gd.width * scale),
path: path
})
// note: setting Glyph.unicodes prop does not seem to work
for (let i = 1; i < gd.unicodes.length; i++) {
g.addUnicode(gd.unicodes[i])
}
glyphs.push(g)
}
const font = new opentype.Font({
familyName: fontData.familyName,
styleName: fontData.styleName,
unitsPerEm: fontData.unitsPerEm,
ascender: fontData.ascender,
descender: -Math.abs(fontData.descender),
glyphs: glyphs
})
print("otworker finished making a font", font)
let fontBlob = new w.Blob([font.toArrayBuffer()],{type:'font/otf'})
let fontURL = w.URL.createObjectURL(fontBlob)
let style = w.createElement('style')
style.innerHTML = `
@font-face {
font-family: ${JSON.stringify(fontData.familyName)};
font-style: normal;
font-weight: 400;
font-display: block;
src: url("${fontURL}") format("opentype");
}
:root {
font-size:14px;
font-family: ${JSON.stringify(fontData.familyName)};
}
body { padding:0; margin:0; }
button { position: fixed; bottom: 1em; left: 1em; }
p {
font-size:64px;
padding: 1em 1em 3em 1em;
margin:0;
outline: none;
position: absolute;
top:0; left:0; right:0;
min-height: 100%;
white-space: pre-wrap;
}
`
w.document.head.appendChild(style)
w.document.body.innerHTML = `
<p contenteditable></p>
<button>Save font file...</button>
`;
const textarea = w.document.querySelector('p[contenteditable]') as any
textarea.spellcheck = false;
textarea.focus()
let sampleText = fontData.glyphs.filter(g => g.paths.length > 0).map(g =>
g.unicodes.map(uc =>
String.fromCodePoint(uc))).join("")
w.document.execCommand("insertText", false, sampleText)
w.document.querySelector('button')!.onclick = () => {
font.download()
}
// Dump as base64:
//console.log(btoa(String.fromCharCode(...new Uint8Array(font.toArrayBuffer()))))
//font.download()
// w.send("DONE")
// w.close()
function assert(cond) { if (!cond) throw new Error("assertion") }
type PathCommand = CPathCommand | LPathCommand | MPathCommand | ZPathCommand
interface CPathCommand {
type: "C";
x: number;
y: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
interface LPathCommand { type: "L"; x: number; y: number; }
interface MPathCommand { type: "M"; x: number; y: number; }
interface ZPathCommand { type: "Z"; }
function logPath(cmds :PathCommand[]) {
// debug helper
for (let c of cmds) {
let p = {...c}
delete p.type
switch (c.type) {
case "Z": console.log(c.type) ; break
case "C": console.log(c.type, c.x, c.y, {x1:c.x1, y1:c.y1, x2:c.x2, y2:c.y2}) ; break
default: console.log(c.type, c.x, c.y) ; break
}
}
}
function isCCWWindingOrder(cmds :PathCommand[], start :number, end :number = cmds.length) :boolean {
// sum edges, e.g. (x2 − x1)(y2 + y1)
// point[0] = (5,0) edge[0]: (6-5)(4+0) = 4
// point[1] = (6,4) edge[1]: (4-6)(5+4) = -18
// point[2] = (4,5) edge[2]: (1-4)(5+5) = -30
// point[3] = (1,5) edge[3]: (1-1)(0+5) = 0
// point[4] = (1,0) edge[4]: (5-1)(0+0) = 0
if (end <= start || end - start < 2 || cmds[start].type == "Z")
return false
let edgesum = 0
for (let i = start; i < end; i++) {
let p1 = cmds[i], p2 = cmds[i + 1]
if (p1.type == "Z")
break
if (p2.type == "Z")
p2 = cmds[start] as MPathCommand
let edge = (p2.x - p1.x) * (p2.y + p1.y)
edgesum += edge
}
return edgesum < 0
}
function reverseWindingOrder(cmds :PathCommand[], start :number, end :number = cmds.length) :void {
// swap ending Z with starting M "move to"
// TODO: swap xN & yN for type=C.
if (end <= start)
return
// rewrite starting M and ending Z
let M = cmds[start] as MPathCommand
if (M.type == "M") (M as any).type = "L"
let Z = cmds[end - 1]
if (Z.type == "Z") {
let n = Z as any as MPathCommand
n.x = M.x
n.y = M.y
}
// fixup curves
// C contains the end point of a curve; its start point is the previous node
for (let i = start + 1; i < end; i++) {
let c = cmds[i]
if (c.type != 'C')
continue
let endnode = cmds[i - 1]
if (!endnode || endnode.type == "Z")
break
//print(c.x, c.y, "<>", endnode.type, endnode.x, endnode.y)
let startx = c.x, starty = c.y
c.x = endnode.x ; c.y = endnode.y
endnode.x = startx ; endnode.y = starty
// swap handles
let x1 = c.x1, y1 = c.y1
c.x1 = c.x2 ; c.y1 = c.y2
c.x2 = x1 ; c.y2 = y1
// swap positions
cmds[i] = endnode
cmds[i - 1] = c
}
if (Z.type == "Z")
(Z as any as MPathCommand).type = "M"
for (let l = start, r = end - 1; r > l; l++, r--) {
// swap positions
let rcmd = cmds[r]
let lcmd = cmds[l]
cmds[r] = lcmd
cmds[l] = rcmd
}
}
function genGlyphCommands(font :FontInfo, gd: GlyphInfo) :PathCommand[] {
let paths = gd.paths
let height = font.figptPerEm
let offsetx = gd.offsetx
let offsety = gd.offsety + (height - font.baseline) // +8
let cmds :PathCommand[] = []
let scale = EM / height
// if (paths.length > 0)
// console.log("\n—————\n" + gd.name, {paths})
for (let path of paths) {
let startIndex = cmds.length
let closedPath = false
let contourIndex = 0
let contourStartIndex = 0
// console.log(`start path`, path.windingRule)
function endPathEvenOdd() :void {
let isCCW = isCCWWindingOrder(cmds, contourStartIndex)
if (contourIndex == 0 && !isCCW) {
// Outer contour should wind counter-clockwise.
// Only sometimes does Figma generate CW ordered paths. Strange.
// console.log("correct outer contour winding order to CCW")
// logPath(cmds.slice(contourStartIndex))
// console.log("——")
reverseWindingOrder(cmds, contourStartIndex)
// logPath(cmds.slice(contourStartIndex))
} else if (contourIndex > 0 && isCCW) {
// inner contours should wind clockwise
// console.log("correct inner contour winding order to CW")
// logPath(cmds.slice(contourStartIndex))
// console.log("——")
reverseWindingOrder(cmds, contourStartIndex)
// logPath(cmds.slice(contourStartIndex))
}
}
function endPath() {
//console.log(`end contour #${contourIndex}`)
if (path.windingRule == "EVENODD")
endPathEvenOdd()
}
function closePath() {
if (cmds.length > contourStartIndex) {
cmds.push({ type: 'Z' })
endPath()
closedPath = true
contourIndex++
}
}
function closePathIfNeeded() {
// automatically close path if there was no finishing "Z" command
if (!closedPath && startIndex != cmds.length)
closePath()
}
parseSVGPath(path.data, {
moveTo(x :number, y :number) {
closePathIfNeeded()
contourStartIndex = cmds.length
cmds.push({
type: 'M',
x: round((offsetx + x) * scale),
y: round(height * scale - (y + offsety) * scale)
})
//console.log(`start contour #${contourIndex} (${path.windingRule})` , {x,y})
},
lineTo(x :number, y :number) {
let cmd :LPathCommand = {
type: 'L',
x: round((offsetx + x) * scale),
y: round(height * scale - (y + offsety) * scale)
}
// avoid redundant points by skipping L x y preceeded by matching M x y
let startcmd = cmds[contourStartIndex]
if (startcmd.type != "M" || startcmd.x != cmd.x || startcmd.y != cmd.y)
cmds.push(cmd)
},
cubicCurveTo(x1 :number, y1 :number, x2 :number, y2 :number, x :number, y :number) {
cmds.push({
type: 'C',
x1: round((offsetx + x1) * scale),
y1: round(height * scale - (y1 + offsety) * scale),
x2: round((offsetx + x2) * scale),
y2: round(height * scale - (y2 + offsety) * scale),
x: round((offsetx + x) * scale),
y: round(height * scale - (y + offsety) * scale),
})
},
quadCurveTo(cx :number, cy :number, x :number, y :number) {
// ignored as figma doesn't generates quadratic curves
},
closePath,
})
closePathIfNeeded()
}
return cmds
}
// Simple SVG-path parser
interface SVGPathParserParams {
moveTo(x :number, y :number) :void
lineTo(x :number, y :number) :void
cubicCurveTo(c1x :number, c1y :number, c2x :number, c2y :number, x :number, y :number) :void
quadCurveTo(cx :number, cy :number, x :number, y :number) :void
closePath() :void
}
function parseSVGPath(path :string, callbacks :SVGPathParserParams) :void {
let re1 = /[MmLlHhVvCcSsQqTtAaZz]/g, m1 :RegExpExecArray|null
let re2 = /[\d\.\-\+eE]+/g
const num = () :number => {
let m = re2.exec(path)
let n = m ? parseFloat(m[0]) : NaN
if (isNaN(n)) {
throw new Error(`not a number at offset ${re2.lastIndex}`)
}
return n
}
while (m1 = re1.exec(path)) {
let cmd = m1[0]
re2.lastIndex = re1.lastIndex
let currx = 0, curry = 0
switch (cmd) {
case 'M': // x y
currx = num()
curry = num()
callbacks.moveTo(currx, curry)
break
case 'L': // x y
currx = num()
curry = num()
callbacks.lineTo(currx, curry)
break
case 'C': { // cubic bézier (ctrl1-x, ctrl1-y, ctrl2-x, ctr2-y, x, y)
let x1 = num(), y1 = num(), x2 = num(), y2 = num()
currx = num()
curry = num()
callbacks.cubicCurveTo(x1, y1, x2, y2, currx, curry)
break
}
case 'Q': { // quadratic bézier (ctrl-x, ctrl-y, x, y)
let x1 = num(), y1 = num()
currx = num()
curry = num()
callbacks.quadCurveTo(x1, y1, currx, curry)
break
}
case 'Z': // close path
callbacks.closePath()
break
case 'H': // draw a horizontal line from the current point to the end point
currx = num()
callbacks.lineTo(currx, curry)
break
case 'V': // draw a vertical line from the current point to the end point
curry = num()
callbacks.lineTo(currx, curry)
break
default:
throw new Error(`unexpected command ${JSON.stringify(cmd)} in vector path`)
}
}
}
}) // otworker
function parseFontInfo(text :string, fontInfo :FontInfo) {
let lineno = 1
const props :{[k:string]:'string'|'int'|'float'} = {
familyName: 'string',
styleName: 'string',
unitsPerEm: 'int',
ascender: 'float',
descender: 'float',
baseline: 'float',
}
for (let line of text.split(/\r?\n/)) {
let linetrim = line.trim()
if (linetrim.length > 0 && linetrim[0] != '#') {
// not a comment
let i = linetrim.indexOf(':')
if (i == -1) {
throw new Error(
`syntax error in info text, missing ":" after key` +
` on line ${lineno}:\n${line}`
)
}
let k = linetrim.substr(0, i)
let v :any = linetrim.substr(i + 1).trim()
let t = props[k]
if (!t) {
throw new Error(
`unknown key ${JSON.stringify(k)} in info text on line ${lineno}:\n${line}`
)
}
if (t == 'int') {
let n = parseInt(v)
assert(!isNaN(n) && `${n}` == v,
`invalid integer value ${v} at line ${lineno}\n${line}`)
v = n
} else if (t == 'float') {
let n = parseFloat(v)
assert(!isNaN(n), `invalid numeric value ${v} at line ${lineno}\n${line}`)
v = n
} // else: t == 'string'
fontInfo[k] = v
}
lineno++
}
}
function parseGlyphLayerName(name :string, gd :GlyphInfo) {
// parse layer name
// layerName = glyphName ( <SP> unicodeMapping )*
// unicodeMapping = ("U+" | "u+") <hexdigit>+
// examples:
// "A U+0041" map A to this glyph
// "I U+0031 U+0049 U+006C" map 1, I and l to this glyph
// "A.1 U+0" U+0 means "no unicode mapping"
//
let v = name.split(/[\s\b]+/)
if (v.length > 1) {
gd.name = v[0]
gd.unicodes = v.slice(1).map(s => {
let m = /[Uu]\+([A-fa-f0-9]+)/.exec(s)
if (!m) {
throw new Error(
`invalid layer name ${JSON.stringify(name)}.` +
` Expected U+XXXX to follow first word.`
)
}
return parseInt(m[1], 16)
}).filter(cp => cp > 0)
} else {
// derive codepoint from name
let cp = name.codePointAt(0)
if (cp === undefined) {
throw new Error(`invalid layer name ${JSON.stringify(name)}`)
}
if (name.codePointAt(1) !== undefined) {
throw new Error(
`unable to guess codepoint from layer name` +
` ${JSON.stringify(name)} with multiple characters.` +
` Add " U+XXX" to layer name to specify Unicode mappings` +
` or name the layer a single character.`
)
}
gd.unicodes = [ cp ]
}
}
const EXPORT_FRAME_NAME = "__export__"
let glyphFrame = figma.currentPage.children.find((n, index, obj) =>
n.type == "FRAME" && n.name == EXPORT_FRAME_NAME ) as FrameNode
assert(glyphFrame, "Missing top-level frame with name", EXPORT_FRAME_NAME)
// assert(isGroup(selection(0)), "Select a group or frame of glyphs")
// let glyphGroup = (selection(0) as GroupNode).clone()
// make a copy we can edit
let glyphFrame2 = glyphFrame.clone()
let glyphGroup = figma.group(glyphFrame2.children, figma.currentPage)
glyphFrame2.remove()
scripter.onend = () => { glyphGroup.remove() }
glyphGroup.opacity = 0
glyphGroup.x = Math.round(glyphGroup.x - glyphGroup.width * 1.5)
glyphGroup.expanded = false
let glyphHeight = 0
let warnings :string[] = []
let fontInfo :FontInfo = {
// default font info values
familyName: figma.root.name, // file name
styleName: "Regular",
unitsPerEm: 2048,
ascender: 0,
descender: 0,
baseline: 0,
figptPerEm: 0,
glyphs: [],
}
let unicodeMap = new Map<number,GlyphInfo>()
const glyphnames = await fetchJson("https://rsms.me/etc/glyphnames.json?x") as Record<string,string>
function assignGlyphName(gd :GlyphInfo) {
if (gd.unicodes.length == 0)
return
const cp = gd.unicodes[0].toString(16).toUpperCase()
let name = glyphnames[cp] || "uni" + cp
gd.name = name
}
for (let index of range(glyphGroup.children.length)) {
let n = glyphGroup.children[index]
if (!isFrame(n)) {
assert(!isComponent(n), `clone() yielded component! Figma bug?`)
if (isInstance(n)) {
// wrap instances in frames so we can do union
let f = Frame({
width: n.width,
height: n.height,
x: n.x,
y: n.y,
name: n.name,
backgrounds: [],
expanded: false,
}, n)
n.x = 0
n.y = 0
glyphGroup.insertChild(index, f)
n = f
} else {
// ignore all other node types in the group
if (isText(n) && n.name.toLowerCase() == "info") {
parseFontInfo(n.characters, fontInfo)
}
continue
}
}
let vn :VectorNode|null = null
assert(n.children.length == 1)
let c = n.children[0]
switch (c.type) {
case "VECTOR":
vn = c
break
case "FRAME":
case "GROUP":
case "INSTANCE":
case "COMPONENT":
if (c.children.length > 0)
vn = figma.flatten([figma.union(n.children, n)])
break
}
if (glyphHeight == 0) {
glyphHeight = n.height
} else if (n.height != glyphHeight) {
warnings.push(
`glyph ${n.name} has different height (${n.height})` +
` than other glyphs (${glyphHeight}).` +
` All glyph frames should be the same height.`
)
}
let name = n.name.trim()
let gd :GlyphInfo = {
name: name,
unicodes: [],
width: n.width,
offsetx: vn ? vn.x : 0,
offsety: vn ? vn.y : 0,
paths: vn ? vn.vectorPaths : [],
}
parseGlyphLayerName(name, gd)
if (gd.unicodes.length == 0) {
warnings.push(
`Glyph ${name} does not map to any Unicode codepoints.` +
` You won't be able to type this glyph. Add " U+XXXX" to the layer name.`
)
} else for (let uc of gd.unicodes) {
if (uc == 0) {
warnings.push(
`Glyph ${name}: Unicode U+0000 is invalid` +
` (.null/.notdef glyph is generated automatically)`
)
gd.unicodes = []
break
} else if (uc < 0) {
warnings.push(
`Glyph ${n.name}: Invalid negative Unicode codepoint` +
` -${Math.abs(uc).toString(16).padStart(4, '0')}`
)
gd.unicodes = []
break
}
let otherGd = unicodeMap.get(uc)
if (otherGd) {
warnings.push(
`Duplicate Unicode mapping: Glyphs ${otherGd.name} and ${gd.name}`+
` both maps U+${uc.toString(16).padStart(4,'0')}`
)
} else {
unicodeMap.set(uc, gd)
}
}
assignGlyphName(gd)
fontInfo.glyphs.push(gd)
}
// update font info
let emScale = fontInfo.unitsPerEm / glyphHeight
fontInfo.ascender = Math.round(fontInfo.ascender * emScale)
fontInfo.descender = Math.round(fontInfo.descender * emScale)
fontInfo.figptPerEm = glyphHeight
if (warnings.length > 0) {
alert(`Warning:\n- ${warnings.join("\n- ")}`)
}
// generate some common glyphs if they are missing
function emptyGlyphGen(
cp :int,
name :string,
widthf :(font:FontInfo)=>number,
) :[number,(font:FontInfo)=>void] {
return [cp, font => {
font.glyphs.push({
name,
unicodes: [cp],
width: Math.max(0, Math.round(widthf(font))),
offsetx: 0,
offsety: 0,
paths: [],
})
}]
}
const glyphGenerators :[number,(font:FontInfo)=>void][] = [
emptyGlyphGen(0x0020, "space", f => f.figptPerEm / 5),
emptyGlyphGen(0x2002, "enspace", f => f.figptPerEm / 2),
emptyGlyphGen(0x2003, "emspace", f => f.figptPerEm),
emptyGlyphGen(0x2004, "thirdemspace", f => f.figptPerEm / 3),
emptyGlyphGen(0x2005, "quarteremspace", f => f.figptPerEm / 4),
emptyGlyphGen(0x2006, "sixthemspace", f => f.figptPerEm / 6),
emptyGlyphGen(0x2007, "figurespace", f => f.figptPerEm / 4),
emptyGlyphGen(0x2008, "punctuationspace", f => f.figptPerEm / 8),
emptyGlyphGen(0x2009, "thinspace", f => f.figptPerEm / 16),
emptyGlyphGen(0x200A, "hairspace", f => f.figptPerEm / 32),
]
for (let [cp, f] of glyphGenerators) {
if (!unicodeMap.has(cp))
f(fontInfo)
}
// sort glyphs by codepoints
fontInfo.glyphs.sort((a, b) =>
a.unicodes.length == 0 ? (
b.unicodes.length == 0 ? 0 :
-1
) :
b.unicodes.length == 0 ? (
a.unicodes.length == 0 ? 0 :
1
) :
a.unicodes[0] < b.unicodes[0] ? -1 :
b.unicodes[0] < a.unicodes[0] ? 1 :
0
)
//print(fontInfo)
otworker.send(fontInfo)
await otworker
@martin-code1

Copy link
Copy Markdown

How to make geometric font In figma?

@chreesp

chreesp commented Oct 17, 2024

Copy link
Copy Markdown

Any tips on quick font scaling? My output with default settings is too small compared to other fonts in my library.

EDIT:
If anyone has similar problem, just modify the following:

const font = new opentype.Font({
familyName: fontData.familyName,
styleName: fontData.styleName,
unitsPerEm: fontData.unitsPerEm / 2, // double the font size
ascender: fontData.ascender,
descender: -Math.abs(fontData.descender),
glyphs: glyphs
})

@martin-code1

Copy link
Copy Markdown

Ok. What's the problem?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment