Scripts and AI
Fontista is driven by Lua. Here are ready-to-use scripts, reviewed and tested by Thinkspark, two skills so that Claude draws, fixes and proofs your fonts, and the reference documentation.
Installing a script
Three ways, as you like: paste the code into the Scripts window (Scripts › Scripts window…, then Cmd or Ctrl + Enter); open the file with Scripts › Run a Lua script…; or drop it into the scripts folder, where it becomes a menu item. A script is undone with a single Cmd or Ctrl + Z. Settings sit at the top of each file.
The whole library at once. Unzip the archive into the plugins folder (Scripts › Open the plugins folder): the scripts show up in the Scripts › Fontista library menu.
Scripts need a licence or the App Store version; the trial refuses them. A script can read and write files like any program: only run scripts whose source you know.
Review
Sidebearings of the control letters
approches.luaLeft and right sidebearings of H, O, n, o and zero in every master, then the glyphs with a strongly negative sidebearing (often a mistake, except for f, j and some italics).
Show the code
-- @titre Approches des lettres témoins
-- @title Sidebearings of the control letters
-- @categorie relire
-- @resume Les approches gauche et droite de H, O, n, o et du zéro dans chaque master, puis les glyphes dont une approche est très négative (souvent une erreur, sauf pour f, j et quelques italiques).
-- @summary Left and right sidebearings of H, O, n, o and zero in every master, then the glyphs with a strongly negative sidebearing (often a mistake, except for f, j and some italics).
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local CONTROL = { "H", "O", "n", "o", "zero" }
local NEGATIVE = 0.05 -- flag sidebearings below minus this share of the em (0.05 = -50 at 1000)
local en = lang == "en"
-- horizontal extent of the outline (curves sampled, components followed)
local function extent(name, master, depth)
local l = layer(name, master)
if not l or l.ink or (depth or 0) > 5 then return nil end
local x0, x1 = nil, nil
local function add(x) if not x0 or x < x0 then x0 = x end if not x1 or x > x1 then x1 = x end end
for _, c in ipairs(l.contours) do
local n, first = #c.nodes, nil
for i = 1, n do if c.nodes[i].kind ~= "off" then first = i break end end
if first then
local prev, offs = c.nodes[first], {}
add(prev.x)
local last = c.closed and n or (n - first)
for k = 1, last do
local q = c.nodes[(first - 1 + k) % n + 1]
if q.kind == "off" then offs[#offs + 1] = q
else
for i = 1, (#offs == 0 and 1 or 24) do
local t = i / (#offs == 0 and 1 or 24)
local u = 1 - t
local x = q.x
if #offs == 1 then x = u * u * prev.x + 2 * u * t * offs[1].x + t * t * q.x
elseif #offs >= 2 then x = u ^ 3 * prev.x + 3 * u * u * t * offs[1].x + 3 * u * t * t * offs[#offs].x + t ^ 3 * q.x end
add(x)
end
prev, offs = q, {}
end
end
end
end
for _, comp in ipairs(components(name, master)) do
local a, b = extent(comp.base, master, (depth or 0) + 1)
if a then add(a + comp.dx) add(b + comp.dx) end
end
return x0, x1, l.width
end
for _, m in ipairs(font.masters) do
print("— " .. m.name)
for _, g in ipairs(CONTROL) do
local a, b, w = extent(g, m.id)
if a then print(string.format(en and " %-6s left %5.0f right %5.0f width %5.0f" or " %-6s gauche %5.0f droite %5.0f chasse %5.0f", g, a, w - b, w)) end
end
local odd = {}
for _, g in ipairs(font.glyphs) do
local a, b, w = extent(g, m.id)
if a and (a < -NEGATIVE * font.upm or w - b < -NEGATIVE * font.upm) then
odd[#odd + 1] = string.format("%s (%.0f / %.0f)", g, a, w - b)
end
end
if #odd > 0 then
print(string.format(en and " strongly negative: %s" or " très négatives : %s", table.concat(odd, ", ")))
end
end
Kerning summary
crenage.luaFor every master: the number of pairs, the tightest and loosest pairs, and pairs naming a glyph that is not in the font.
Show the code
-- @titre Bilan du crénage
-- @title Kerning summary
-- @categorie relire
-- @resume Pour chaque master : le nombre de paires, les paires les plus serrées et les plus ouvertes, et les paires qui nomment un glyphe absent de la fonte.
-- @summary For every master: the number of pairs, the tightest and loosest pairs, and pairs naming a glyph that is not in the font.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local SHOW = 10 -- how many of the strongest pairs to list on each side
local en = lang == "en"
local have = {}
for _, g in ipairs(font.glyphs) do have[g] = true end
-- group names (kerning classes) are not glyph names
local function known(n) return have[n] or n:sub(1, 1) == "@" or n:find("^public%.kern") ~= nil end
for _, m in ipairs(font.masters) do
local list, orphans = {}, {}
for left, row in pairs(kerning(m.id)) do
for right, v in pairs(row) do
list[#list + 1] = { left, right, v }
if not known(left) or not known(right) then orphans[#orphans + 1] = left .. " " .. right end
end
end
table.sort(list, function(a, b) return a[3] < b[3] end)
print(string.format(en and "— %s: %d pairs" or "— %s : %d paires", m.name, #list))
for i = 1, math.min(SHOW, #list) do
if list[i][3] < 0 then print(string.format(" %-14s %-14s %5d", list[i][1], list[i][2], list[i][3])) end
end
for i = #list, math.max(1, #list - SHOW + 1), -1 do
if list[i][3] > 0 then print(string.format(" %-14s %-14s %+5d", list[i][1], list[i][2], list[i][3])) end
end
if #orphans > 0 then
table.sort(orphans)
print(string.format(en and " %d pair(s) naming a missing glyph: %s" or " %d paire(s) avec un glyphe absent : %s", #orphans, table.concat(orphans, ", ")))
end
end
Characters with no drawing
caracteres-vides.luaEncoded glyphs (carrying a Unicode character) still empty in at least one master, spaces and invisible characters excepted. Optionally, they get a grey label.
Show the code
-- @titre Caractères sans dessin
-- @title Characters with no drawing
-- @categorie relire
-- @resume Les glyphes codés (qui portent un caractère Unicode) restés vides dans au moins un master, espaces et caractères invisibles exceptés. En option, ils sont marqués en gris.
-- @summary Encoded glyphs (carrying a Unicode character) still empty in at least one master, spaces and invisible characters excepted. Optionally, they get a grey label.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local MARK = false -- true: give them the grey colour label
local en = lang == "en"
local function invisible(cp)
return cp < 0x21 or (cp >= 0x7F and cp <= 0xA0) or cp == 0xAD or cp == 0x034F
or (cp >= 0x2000 and cp <= 0x200F) or (cp >= 0x2028 and cp <= 0x202F)
or (cp >= 0x205F and cp <= 0x2064) or cp == 0x3000 or cp == 0xFEFF
end
local found = 0
for _, g in ipairs(font.glyphs) do
local info = glyph(g)
if info and info.unicode and info.export ~= false and not invisible(info.unicode) then
local missing = {}
for _, m in ipairs(font.masters) do
local l = layer(g, m.id)
if not l or l.empty then missing[#missing + 1] = m.name end
end
if #missing > 0 then
found = found + 1
print(string.format("%-24s U+%04X %s %s", g, info.unicode, utf8.char(info.unicode), table.concat(missing, ", ")))
if MARK then set_mark(g, 7) end
end
end
end
if found == 0 then
print(en and "Every encoded character is drawn in every master." or "Tous les caractères codés sont dessinés dans tous les masters.")
else
print(string.format(en and "%d character(s) without drawing" or "%d caractère(s) sans dessin", found))
end
Letters off their zones
zones.luaCompares the top and bottom of each Latin letter with its zone (cap height, x-height, ascender, descender, baseline) in every master: flags letters that fall short of their zone or overshoot it more than a normal overshoot.
Show the code
-- @titre Lettres hors des zones
-- @title Letters off their zones
-- @categorie relire
-- @resume Compare le haut et le bas de chaque lettre latine à sa zone (capitales, hauteur d'x, ascendante, descendante, ligne de base) dans chaque master : signale les lettres qui n'atteignent pas leur zone ou qui la dépassent plus qu'un débordement normal.
-- @summary Compares the top and bottom of each Latin letter with its zone (cap height, x-height, ascender, descender, baseline) in every master: flags letters that fall short of their zone or overshoot it more than a normal overshoot.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local OVERSHOOT = 0.025 -- largest accepted overshoot, as a share of the em (0.025 = 25 units at 1000)
local SHORT = 1 -- a letter that stops more than this many units before its zone is flagged
local en = lang == "en"
local tol = OVERSHOOT * font.upm
-- true outline bounds: straight lines, quadratic and cubic segments sampled; components followed
local function bounds(name, master, depth)
local l = layer(name, master)
if not l or (depth or 0) > 5 then return nil end
local b = nil
local function add(x, y)
if not b then b = { x, y, x, y } return end
if x < b[1] then b[1] = x end
if y < b[2] then b[2] = y end
if x > b[3] then b[3] = x end
if y > b[4] then b[4] = y end
end
local function segment(p, offs, q)
if #offs == 0 then add(q.x, q.y) return end
for i = 1, 24 do
local t = i / 24
local u = 1 - t
local x, y
if #offs == 1 then
local c = offs[1]
x = u * u * p.x + 2 * u * t * c.x + t * t * q.x
y = u * u * p.y + 2 * u * t * c.y + t * t * q.y
else
local c1, c2 = offs[1], offs[#offs]
x = u ^ 3 * p.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t ^ 3 * q.x
y = u ^ 3 * p.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t ^ 3 * q.y
end
add(x, y)
end
end
for _, c in ipairs(l.contours) do
local n = #c.nodes
local first = nil
for i = 1, n do if c.nodes[i].kind ~= "off" then first = i break end end
if first then
local prev, offs = c.nodes[first], {}
add(prev.x, prev.y)
local last = c.closed and n or (n - first)
for k = 1, last do
local nd = c.nodes[(first - 1 + k) % n + 1]
if nd.kind == "off" then offs[#offs + 1] = nd
else segment(prev, offs, nd) prev, offs = nd, {} end
end
end
end
for _, comp in ipairs(components(name, master)) do
local cb = bounds(comp.base, master, (depth or 0) + 1)
if cb then add(cb[1] + comp.dx, cb[2] + comp.dy) add(cb[3] + comp.dx, cb[4] + comp.dy) end
end
return b
end
-- the zone each Latin letter should reach (nil: not checked)
local ASC, XH, DESC = "bdfhkl", "acemnorsuvwxz", "gjpqy"
local function zones(ch, m)
if ch:match("^[A-Z]$") then
return m.cap_height, (ch ~= "J" and ch ~= "Q") and 0 or nil
elseif ch:match("^[a-z]$") then
local top = ASC:find(ch, 1, true) and m.ascender or (XH:find(ch, 1, true) and m.x_height or nil)
local bottom = DESC:find(ch, 1, true) and m.descender or 0
return top, bottom
end
end
local flagged, inked = 0, 0
for _, m in ipairs(font.masters) do
for _, g in ipairs(font.glyphs) do
local info = glyph(g)
local ch = info and info.unicode and info.unicode < 0x80 and string.char(info.unicode) or nil
local top, bottom = nil, nil
if ch then top, bottom = zones(ch, m) end
local l = (top or bottom) and layer(g, m.id) or nil
-- skeleton layers inked by a brush: their outline only exists once inked, Lua cannot measure it
local b = nil
if l and l.ink then inked = inked + 1 elseif l then b = bounds(g, m.id) end
if b then
local notes = {}
if top then
local d = b[4] - top
if d < -SHORT then notes[#notes + 1] = string.format(en and "top %.0f below the zone (%.0f)" or "haut %.0f sous la zone (%.0f)", -d, top)
elseif d > tol then notes[#notes + 1] = string.format(en and "top %.0f above the zone (%.0f)" or "haut %.0f au-dessus de la zone (%.0f)", d, top) end
end
if bottom then
local d = bottom - b[2]
if d < -SHORT then notes[#notes + 1] = string.format(en and "bottom %.0f above the zone (%.0f)" or "bas %.0f au-dessus de la zone (%.0f)", -d, bottom)
elseif d > tol then notes[#notes + 1] = string.format(en and "bottom %.0f below the zone (%.0f)" or "bas %.0f sous la zone (%.0f)", d, bottom) end
end
if #notes > 0 then
flagged = flagged + 1
print(string.format("%-12s %-10s %s", g, m.name, table.concat(notes, " · ")))
end
end
end
end
if inked > 0 then
print(string.format(en and "%d inked (skeleton) layer(s) not checked: review them in Fontista Control." or "%d calque(s) encré(s) (squelettes) non vérifié(s) : relisez-les dans Fontista Control.", inked))
end
print(flagged == 0 and (en and "Every letter reaches its zones." or "Toutes les lettres atteignent leurs zones.")
or string.format(en and "%d letter(s) to look at (overshoot accepted up to %.0f units)" or "%d lettre(s) à regarder (débordement accepté jusqu'à %.0f unités)", flagged, tol))
Advance width report
chasses.luaAdvance widths of the drawn glyphs of one master, narrowest first, with the average and the extremes.
Show the code
-- @titre Rapport des chasses
-- @title Advance width report
-- @categorie relire
-- @resume Les chasses des glyphes dessinés d'un master, de la plus étroite à la plus large, avec la moyenne et les extrêmes.
-- @summary Advance widths of the drawn glyphs of one master, narrowest first, with the average and the extremes.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local MASTER = 1 -- which master: 1 = the first one
local en = lang == "en"
local m = font.masters[MASTER] or font.masters[1]
local rows = {}
for _, g in ipairs(font.glyphs) do
local l = layer(g, m.id)
if l and not l.empty then rows[#rows + 1] = { g, l.width } end
end
table.sort(rows, function(a, b) return a[2] < b[2] end)
local sum = 0
for _, r in ipairs(rows) do
sum = sum + r[2]
print(string.format("%-28s %6.0f", r[1], r[2]))
end
if #rows == 0 then
print(en and "No drawn glyph in this master." or "Aucun glyphe dessiné dans ce master.")
else
print(string.format(en and "%d glyphs in %s · average %.0f · narrowest %s (%.0f) · widest %s (%.0f)"
or "%d glyphes dans %s · moyenne %.0f · plus étroit %s (%.0f) · plus large %s (%.0f)",
#rows, m.name, sum / #rows, rows[1][1], rows[1][2], rows[#rows][1], rows[#rows][2]))
end
Bring in the Fontista Control review
revue-control.luaReads the latest review exported by Fontista Control (or the JSON file you name): glyphs to fix turn red, with the note and the mark positions in their glyph note; for a join review, both letters of each pair. Site edition only (the App Store version cannot read other apps' files).
Show the code
-- @titre Reprendre la revue de Fontista Control
-- @title Bring in the Fontista Control review
-- @categorie relire
-- @resume Lit la dernière revue exportée par Fontista Control (ou le fichier JSON indiqué) : les glyphes à corriger passent en rouge, avec la note et la position des repères dans leur note de glyphe ; pour une revue des liaisons, les deux lettres de chaque paire. Édition du site seulement (la version App Store ne lit pas les fichiers des autres apps).
-- @summary Reads the latest review exported by Fontista Control (or the JSON file you name): glyphs to fix turn red, with the note and the mark positions in their glyph note; for a join review, both letters of each pair. Site edition only (the App Store version cannot read other apps' files).
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local FILE = "" -- empty: the latest export found; or the full path of a review .json
local RED = 0 -- colour label for glyphs to fix
local GREEN = nil -- colour label for approved glyphs (3 = green), nil to leave them
local en = lang == "en"
local windows = package.config:sub(1, 1) == "\\"
-- latest review: Control keeps a copy of every export in its own folder; Downloads is searched too
local function latest()
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local dirs
if windows then
dirs = { (os.getenv("APPDATA") or "") .. "\\Fontista Control\\exports", home .. "\\Downloads" }
elseif io.open("/System/Library", "r") then
dirs = { home .. "/Library/Application Support/Fontista Control/exports", home .. "/Downloads" }
else
dirs = { (os.getenv("XDG_DATA_HOME") or (home .. "/.local/share")) .. "/Fontista Control/exports", home .. "/Downloads" }
end
local best, best_t = nil, nil
for _, d in ipairs(dirs) do
local cmd = windows and ('dir /b /o-d "' .. d .. '\\*.json" 2>nul') or ('ls -t "' .. d .. '"/*.json 2>/dev/null')
local p = io.popen(cmd)
if p then
for line in p:lines() do
local f = windows and (d .. "\\" .. line) or line
local h = io.open(f, "r")
local head = h and h:read(400) or ""
if h then h:close() end
if head:find('"statuts"', 1, true) or head:find('"police"', 1, true) then
-- newest file of each folder comes first; compare folders by modification time when possible
local t = nil
if not windows then
local s = io.popen('stat -f %m "' .. f .. '" 2>/dev/null || stat -c %Y "' .. f .. '" 2>/dev/null')
t = s and tonumber(s:read("l") or "") or nil
if s then s:close() end
end
if not best or (t and best_t and t > best_t) then best, best_t = f, t end
break
end
end
p:close()
end
end
return best
end
-- small JSON reader (objects, arrays, strings, numbers, true, false, null)
local function decode(s)
local i = 1
local function ws() i = s:find("[^ \t\r\n]", i) or #s + 1 end
local function str()
local out, j = {}, i + 1
while true do
local c = s:sub(j, j)
if c == '"' then i = j + 1 return table.concat(out) end
if c == "\\" then
local e = s:sub(j + 1, j + 1)
if e == "u" then
local cp = tonumber(s:sub(j + 2, j + 5), 16)
j = j + 6
if cp >= 0xD800 and cp <= 0xDBFF and s:sub(j, j + 1) == "\\u" then
cp = 0x10000 + (cp - 0xD800) * 0x400 + (tonumber(s:sub(j + 2, j + 5), 16) - 0xDC00)
j = j + 6
end
out[#out + 1] = utf8.char(cp)
else
out[#out + 1] = ({ b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" })[e] or e
j = j + 2
end
elseif c == "" then error("JSON: unterminated string")
else out[#out + 1] = c j = j + 1 end
end
end
local value
function value()
ws()
local c = s:sub(i, i)
if c == "{" then
local t = {}
i = i + 1 ws()
if s:sub(i, i) == "}" then i = i + 1 return t end
while true do
ws() local k = str() ws() i = i + 1
t[k] = value() ws()
local d = s:sub(i, i) i = i + 1
if d == "}" then return t end
end
elseif c == "[" then
local t = {}
i = i + 1 ws()
if s:sub(i, i) == "]" then i = i + 1 return t end
while true do
t[#t + 1] = value() ws()
local d = s:sub(i, i) i = i + 1
if d == "]" then return t end
end
elseif c == '"' then return str()
elseif s:sub(i, i + 3) == "true" then i = i + 4 return true
elseif s:sub(i, i + 4) == "false" then i = i + 5 return false
elseif s:sub(i, i + 3) == "null" then i = i + 4 return nil
else
local num = s:match("^-?%d+%.?%d*[eE]?[-+]?%d*", i)
if not num or num == "" then error("JSON: unexpected character at " .. i) end
i = i + #num
return tonumber(num)
end
end
return value()
end
local path = FILE ~= "" and FILE or latest()
if not path then
print(en and "No Fontista Control review found. Export one (File › Export the review), or set FILE at the top of this script."
or "Aucune revue de Fontista Control trouvée. Exportez-en une (Fichier › Exporter la revue), ou indiquez FILE en tête de ce script.")
return
end
local h = assert(io.open(path, "r"))
local review = decode(h:read("a"))
h:close()
local fixed, approved, missing = 0, 0, {}
local function flag(g, text)
if not glyph(g) then missing[#missing + 1] = g return end
set_mark(g, RED)
-- keep the designer's own note, replace a previous Control line
local note = (glyph(g).note or ""):gsub("\n?Control : [^\n]*", ""):gsub("\n?Control: [^\n]*", "")
set_note(g, (note ~= "" and (note .. "\n") or "") .. (en and "Control: " or "Control : ") .. text)
fixed = fixed + 1
end
for _, item in ipairs(review.liste_a_corriger or {}) do
local text = item.note ~= "" and item.note or (en and "to fix" or "à corriger")
if item.mesure and item.mesure ~= "" then text = text .. " (" .. item.mesure .. ")" end
local marks = {}
for _, r in ipairs(item.reperes or {}) do
marks[#marks + 1] = string.format("%.0f;%.0f%s", r.x or 0, r.y or 0, (r.note and r.note ~= "") and (" " .. r.note) or "")
end
if #marks > 0 then text = text .. (en and " · marks " or " · repères ") .. table.concat(marks, ", ") end
if review.liaisons then
for _, g in ipairs(item.glyphes or {}) do flag(g, item.nom .. " : " .. text) end
else
flag(item.nom, text)
end
end
if GREEN and not review.liaisons then
for name, st in pairs(review.statuts or {}) do
if st == "ok" and glyph(name) then set_mark(name, GREEN) approved = approved + 1 end
end
end
print(string.format(en and "%s · pass %s · %d glyph(s) to fix marked%s" or "%s · passe %s · %d glyphe(s) à corriger marqué(s)%s",
path, tostring(review.passe or "?"), fixed, GREEN and string.format(en and ", %d approved" or ", %d validé(s)", approved) or ""))
if #missing > 0 then
print((en and "Not in this font: " or "Absents de cette fonte : ") .. table.concat(missing, ", "))
end
Tidy up
Colour by state
couleurs-etat.luaGives each glyph a colour label from its state in the first master: grey when empty, blue when made only of components, none when drawn. The Font view grid then shows at a glance what is left to do.
Show the code
-- @titre Couleurs selon l'état
-- @title Colour by state
-- @categorie ranger
-- @resume Donne une couleur d'étiquette à chaque glyphe selon son état dans le premier master : gris s'il est vide, bleu s'il n'est fait que de composants, aucune s'il est dessiné. La grille de la vue Fonte montre d'un coup d'œil ce qui reste à faire.
-- @summary Gives each glyph a colour label from its state in the first master: grey when empty, blue when made only of components, none when drawn. The Font view grid then shows at a glance what is left to do.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local EMPTY, COMPOSITE = 7, 5 -- colour labels: 0 red, 1 orange, 2 yellow, 3 green, 4 cyan, 5 blue, 6 purple, 7 grey
local KEEP_OTHERS = true -- true: leave the labels you set yourself (other colours) as they are
local en = lang == "en"
local m = font.masters[1].id
local counts = { 0, 0, 0 }
for _, g in ipairs(font.glyphs) do
local info = glyph(g)
local l = layer(g, m)
local mine = info.mark ~= nil and info.mark ~= EMPTY and info.mark ~= COMPOSITE
if not (KEEP_OTHERS and mine) then
if not l or l.empty then
set_mark(g, EMPTY) counts[1] = counts[1] + 1
elseif #l.contours == 0 and #components(g, m) > 0 then
set_mark(g, COMPOSITE) counts[2] = counts[2] + 1
else
set_mark(g, nil) counts[3] = counts[3] + 1
end
end
end
print(string.format(en and "%d empty (grey), %d composite (blue), %d drawn" or "%d vide(s) (gris), %d composite(s) (bleu), %d dessiné(s)", counts[1], counts[2], counts[3]))
Tidy up the whole font
mettre-au-net.luaIn every master: duplicate nodes and tiny segments removed, contour directions corrected. One undo step. Afterwards, check that the masters are still compatible (same node counts).
Show the code
-- @titre Mettre au net toute la fonte
-- @title Tidy up the whole font
-- @categorie ranger
-- @resume Dans tous les masters : nœuds en double et segments minuscules retirés, sens des contours corrigé. Un seul pas d'annulation. Après coup, vérifiez que les masters restent compatibles (même nombre de nœuds).
-- @summary In every master: duplicate nodes and tiny segments removed, contour directions corrected. One undo step. Afterwards, check that the masters are still compatible (same node counts).
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
for _, m in ipairs(font.masters) do
batch("master " .. m.id)
batch("select all")
print(m.name .. " · " .. batch("tidy") .. " · " .. batch("correct"))
end
Number the alternates
renommer-alt.luaRenames ".alt" glyphs to ".alt1", the convention for repetition alternates (a.alt becomes a.alt1); components follow.
Show the code
-- @titre Numéroter les alternatives
-- @title Number the alternates
-- @categorie ranger
-- @resume Renomme les glyphes « .alt » en « .alt1 », la convention des alternatives de répétition (a.alt devient a.alt1) ; les composants suivent.
-- @summary Renames ".alt" glyphs to ".alt1", the convention for repetition alternates (a.alt becomes a.alt1); components follow.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local en = lang == "en"
local n = 0
for _, g in ipairs(font.glyphs) do
if g:sub(-4) == ".alt" and not glyph(g .. "1") then
if rename_glyph(g, g .. "1") then n = n + 1 end
end
end
print(string.format(en and "%d glyph(s) renamed" or "%d glyphe(s) renommé(s)", n))
Spacing and kerning
Fixed-width figures
chiffres-chasse-fixe.luaGives the ten digits the same advance width in every master (the widest of the ten) and centres each drawing, anchors included. For tabular figures alongside the default ones, use Automations › Tabular figures instead.
Show the code
-- @titre Chiffres à chasse fixe
-- @title Fixed-width figures
-- @categorie espacer
-- @resume Donne aux dix chiffres la même chasse dans chaque master (la plus large des dix) et centre chaque dessin, ancres comprises. Pour des chiffres tabulaires en plus des chiffres par défaut, préférez Automatisations › Chiffres tabulaires.
-- @summary Gives the ten digits the same advance width in every master (the widest of the ten) and centres each drawing, anchors included. For tabular figures alongside the default ones, use Automations › Tabular figures instead.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local DIGITS = { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" }
local en = lang == "en"
local function xrange(contours)
local a, b = nil, nil
for _, c in ipairs(contours) do
for _, nd in ipairs(c.nodes) do
if not a or nd.x < a then a = nd.x end
if not b or nd.x > b then b = nd.x end
end
end
return a, b
end
for _, m in ipairs(font.masters) do
local widest = 0
for _, g in ipairs(DIGITS) do
local l = layer(g, m.id)
if l and l.width > widest then widest = l.width end
end
local moved = 0
for _, g in ipairs(DIGITS) do
local l = layer(g, m.id)
if l and #l.contours > 0 and #components(g, m.id) == 0 then
local a, b = xrange(l.contours)
local dx = (widest - (b - a)) / 2 - a
for _, c in ipairs(l.contours) do
for _, nd in ipairs(c.nodes) do nd.x = nd.x + dx end
end
set_contours(g, m.id, l.contours)
for name, p in pairs(anchors(g, m.id)) do set_anchor(g, m.id, name, p.x + dx, p.y) end
set_width(g, m.id, widest)
moved = moved + 1
elseif l then
set_width(g, m.id, widest)
end
end
print(string.format(en and "%s: %d figure(s) centred on %.0f units" or "%s : %d chiffre(s) centré(s) sur %.0f unités", m.name, moved, widest))
end
Copy kerning to the other masters
copier-crenage.luaCopies the kerning pairs of the first master into all the others, same values. A good start for a multi-weight font, to refine master by master afterwards.
Show the code
-- @titre Copier le crénage vers les autres masters
-- @title Copy kerning to the other masters
-- @categorie espacer
-- @resume Recopie les paires de crénage du premier master dans tous les autres, mêmes valeurs. Un bon départ pour une fonte à plusieurs graisses, à affiner ensuite master par master.
-- @summary Copies the kerning pairs of the first master into all the others, same values. A good start for a multi-weight font, to refine master by master afterwards.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local OVERWRITE = false -- true: replace pairs that already exist in the other masters
local en = lang == "en"
local src = font.masters[1].id
local k = kerning(src)
local n = 0
for i = 2, #font.masters do
local dst = font.masters[i].id
local existing = kerning(dst)
for left, row in pairs(k) do
for right, v in pairs(row) do
if OVERWRITE or not (existing[left] and existing[left][right]) then
kern(dst, left, right, v)
n = n + 1
end
end
end
end
print(#font.masters < 2 and (en and "Only one master: nothing to copy." or "Un seul master : rien à copier.")
or string.format(en and "%d pair(s) copied" or "%d paire(s) copiée(s)", n))
Typographic spaces
espaces-typographiques.luaAdds the missing spaces and sets their widths in every master: narrow no-break space (U+202F, used in French before ; : ! ?), thin (U+2009), hair (U+200A), figure space (U+2007, width of zero), punctuation space (U+2008, width of the period), and the no-break space matched to the space.
Show the code
-- @titre Espaces typographiques
-- @title Typographic spaces
-- @categorie espacer
-- @resume Ajoute les espaces qui manquent et règle leur chasse dans chaque master : espace fine insécable (U+202F, celle du français devant ; : ! ?), fine (U+2009), ultrafine (U+200A), espace de chiffre (U+2007, chasse du zéro), espace de ponctuation (U+2008, chasse du point), et l'insécable alignée sur l'espace.
-- @summary Adds the missing spaces and sets their widths in every master: narrow no-break space (U+202F, used in French before ; : ! ?), thin (U+2009), hair (U+200A), figure space (U+2007, width of zero), punctuation space (U+2008, width of the period), and the no-break space matched to the space.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local THIN = 1 / 5 -- thin and narrow no-break spaces, share of the em
local HAIR = 1 / 10 -- hair space, share of the em
local en = lang == "en"
-- glyph already carrying a code point, whatever its name
local by_code = {}
for _, g in ipairs(font.glyphs) do
local info = glyph(g)
if info and info.unicode then by_code[info.unicode] = g end
end
local function width_of(code, m)
local g = by_code[code]
local l = g and layer(g, m) or nil
return l and l.width or nil
end
local spaces = {
{ 0x202F, "uni202F", function(m) return THIN * font.upm end },
{ 0x2009, "uni2009", function(m) return THIN * font.upm end },
{ 0x200A, "uni200A", function(m) return HAIR * font.upm end },
{ 0x2007, "uni2007", function(m) return width_of(0x30, m) end },
{ 0x2008, "uni2008", function(m) return width_of(0x2E, m) end },
{ 0x00A0, "uni00A0", function(m) return width_of(0x20, m) end },
}
local added = {}
for _, s in ipairs(spaces) do
local code, name, w = s[1], s[2], s[3]
local g = by_code[code]
if not g then
if add_glyph(name, code) then g = name added[#added + 1] = name by_code[code] = name end
end
if g then
for _, m in ipairs(font.masters) do
local v = w(m.id)
if v then set_width(g, m.id, math.floor(v + 0.5)) end
end
end
end
print(#added == 0 and (en and "All spaces were there; widths set." or "Toutes les espaces existaient ; chasses réglées.")
or string.format(en and "Added: %s; widths set in every master." or "Ajoutées : %s ; chasses réglées dans chaque master.", table.concat(added, ", ")))
Glyphs
Fill in the masters
completer-masters.luaCopies the drawing of the first master (contours, width, anchors) into the other masters wherever the glyph is still empty. The masters become compatible; each copy still has to be adjusted to its weight.
Show the code
-- @titre Compléter les masters
-- @title Fill in the masters
-- @categorie glyphes
-- @resume Recopie le dessin du premier master (contours, chasse, ancres) dans les autres masters là où le glyphe est encore vide. Les masters deviennent compatibles ; il reste à ajuster chaque copie à sa graisse.
-- @summary Copies the drawing of the first master (contours, width, anchors) into the other masters wherever the glyph is still empty. The masters become compatible; each copy still has to be adjusted to its weight.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local MARK = 2 -- colour label for the copies (2 = yellow), nil for none
local en = lang == "en"
local src = font.masters[1].id
local n = 0
for i = 2, #font.masters do
local dst = font.masters[i].id
for _, g in ipairs(font.glyphs) do
local from, to = layer(g, src), layer(g, dst)
if from and not from.empty and #from.contours > 0 and #components(g, src) == 0 and (not to or to.empty) then
set_contours(g, dst, from.contours)
set_width(g, dst, from.width)
for name, p in pairs(anchors(g, src)) do set_anchor(g, dst, name, p.x, p.y) end
if MARK then set_mark(g, MARK) end
n = n + 1
end
end
end
print(#font.masters < 2 and (en and "Only one master." or "Un seul master.")
or string.format(en and "%d layer(s) filled from %s" or "%d calque(s) complété(s) depuis %s", n, font.masters[1].name))
Export
Export the TTF in one go
exporter-ttf.luaCompiles the font (variable when it has several masters) and writes the .ttf to the chosen folder, your home folder by default, without the Export window. Handy after each round of fixes, when Fontista Control follows that file.
Show the code
-- @titre Exporter la TTF en un geste
-- @title Export the TTF in one go
-- @categorie exporter
-- @resume Compile la fonte (variable si elle a plusieurs masters) et écrit le fichier .ttf dans le dossier choisi, par défaut votre dossier personnel, sans passer par la fenêtre Exporter. Pratique à lancer après chaque série de corrections, quand Fontista Control suit ce fichier.
-- @summary Compiles the font (variable when it has several masters) and writes the .ttf to the chosen folder, your home folder by default, without the Export window. Handy after each round of fixes, when Fontista Control follows that file.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local FOLDER = nil -- nil: your home folder; or a path such as "/Users/me/Fonts"
local en = lang == "en"
local dir = FOLDER or os.getenv("HOME") or os.getenv("USERPROFILE") or "."
local name = font.family:gsub("[^%w%-]", "")
if name == "" then name = "Fontista" end
local sep = package.config:sub(1, 1)
local path = dir .. sep .. name .. ".ttf"
local bytes = export_ttf(path)
print(string.format(en and "%s · %d bytes" or "%s · %d octets", path, bytes))
Character set as text
jeu-de-caracteres.luaPrints every drawn character of the font, sorted by code point, ready to paste into the Preview, a poster or Fontista Control's search. Encoded but empty characters are listed separately.
Show the code
-- @titre Jeu de caractères en texte
-- @title Character set as text
-- @categorie exporter
-- @resume Écrit tous les caractères dessinés de la fonte, rangés par code, prêts à coller dans l'Aperçu, une affiche ou la recherche de Fontista Control. Les caractères codés mais vides sont listés à part.
-- @summary Prints every drawn character of the font, sorted by code point, ready to paste into the Preview, a poster or Fontista Control's search. Encoded but empty characters are listed separately.
-- Fontista · bibliothèque de scripts · https://fontista.thinkspark.dev/scripts.html
local PER_LINE = 48
local en = lang == "en"
local m = font.masters[1].id
local drawn, empty = {}, {}
for _, g in ipairs(font.glyphs) do
local info = glyph(g)
local cp = info and info.unicode
if cp and cp > 0x20 and not (cp >= 0x7F and cp <= 0xA0) and cp ~= 0xAD then
local l = layer(g, m)
if l and not l.empty then drawn[#drawn + 1] = cp else empty[#empty + 1] = cp end
end
end
table.sort(drawn)
table.sort(empty)
local function lines(list)
local row = {}
for i, cp in ipairs(list) do
row[#row + 1] = utf8.char(cp)
if #row == PER_LINE or i == #list then print(table.concat(row)) row = {} end
end
end
print(string.format(en and "%d drawn characters:" or "%d caractères dessinés :", #drawn))
lines(drawn)
if #empty > 0 then
print(string.format(en and "%d encoded but empty:" or "%d codés mais vides :", #empty))
lines(empty)
end
Skills for Claude
A skill teaches Claude, Anthropic's assistant, to use a tool. With these, you ask it "draw a geometric A", "kern AV at −60" or "what's wrong with my font?": it writes and runs the scripts, reads Fontista Control's reports, fixes, then checks.
Fontista
fontista.skillAnswers questions about the app (views, tools, Lab, spacing, kerning, OpenType, export, editions), and draws or edits glyphs through Lua scripts, down to spacing, kerning, accents and export.
Fontista Control
fontista-control.skillRuns the review without a window, lists the flagged glyphs with their notes and marks, the automatic alerts and the suspect joins, fixes the source and runs the review again to check. For any font, from any editor.
Installing: in the Claude app or on claude.ai, import the .skill file from the settings, in the skills section; in Claude Code, unzip it (it is a zip archive) into ~/.claude/skills/. The skills point back to this page and to the documentation below, which are the reference.
Documentation
The font as a script sees it, every function, the contour and skeleton format, batch commands, folders, plugins and the command line.
Glyph report, join report and review as JSON, without a window: the columns, the keys, importing, and the typical loop with a script or an AI.