Skip to content

Instantly share code, notes, and snippets.

@rsms
Last active May 3, 2024 12:41
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • 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
@LauraHelenWinn
Copy link

I'm getting this error from Scripter
Screen Shot 2565-07-11 at 20 18 54
:

@mineTomek
Copy link

Yeah, me too.

@rsms
Copy link
Author

rsms commented Jul 13, 2023

Figma keeps changing the runtime and plugins keep breaking. I'm done wasting my time chasing their never-ending breaking changes. Sorry.

@mineTomek
Copy link

Is there some documentation so I can fix it myself?

@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.

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