Skip to content

Instantly share code, notes, and snippets.

@rsms
Last active May 27, 2024 08:41
Show Gist options
  • Save rsms/a8ad736ba3d448100577de2b88e826de to your computer and use it in GitHub Desktop.
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
@rsms
Copy link
Author

rsms commented Jul 14, 2023

Do a web search for “figma plugin API”

@mineTomek
Copy link

Ok. If I manage to fix it I'll paste it here.

@mineTomek
Copy link

I found out that the fail was because I had a hidden letter in the side __export__ frame. I think it couldn't flatten a hidden layer.
@LauraHelenWinn, if you still have this problem, maybe check this.

@isamirivers
Copy link

I encountered the same error as above and wrote a solution for it which displays a warning indicating which component failed to flatten:

try{
    vn = figma.flatten([figma.union(n.children, n)])}
catch{
    warnings.push(`Can't flatten component "${n.name}"`)
}

I hope it will be useful

@martin-code1
Copy link

hi, how can i add a polish language?
the error come away.

@TrueMishamol
Copy link

Hello! Thank you a lot for such useful script! I've already created one of my new fonts, using it, and it turned out great.

I want to ask you a question. While creating the second font, I was hit by a problem, that the first font did not have... for some reason.
My second font is created using stroke and before generating font file, I use "Outline Stroke". Everything looks fine in Figma, but in the font preview (in all symbols with two rings) one of the rings always filled

image

For the record, my first font was created using "Pen", and it also has symbols with two rings, but this symbols are fine

image

So I'm wondering what's the point? Maybe you have the answer and could share with me the pipeline of creating stroke fonts

@TrueMishamol
Copy link

Okay I found a solution. Turns out I MUST use components. And in that case I don't even need to outline strokes!

@mineTomek
Copy link

@martin-code1

hi, how can i add a polish language? the error come away.

You have to find the codes for the extra letters. Like Ą has the code U+0104.
You can find instructions on how to use Unicode codes in the top left corner of the template iirc.

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