Scripts et IA

Fontista se pilote en Lua. Voici des scripts prêts à l'emploi, relus et testés par Thinkspark, deux skills pour que Claude dessine, corrige et relise vos fontes, et la documentation de référence.

Installer un script

Trois façons, au choix : coller le code dans la fenêtre Scripts (menu Scripts › Fenêtre Scripts…, puis Cmd ou Ctrl + Entrée) ; ouvrir le fichier par Scripts › Exécuter un script Lua… ; ou le déposer dans le dossier des scripts, où il devient une entrée du menu. Un script s'annule d'un seul Cmd ou Ctrl + Z. Les réglages se trouvent en tête de chaque fichier.

Toute la bibliothèque d'un coup. Décompressez l'archive dans le dossier des greffons (Scripts › Ouvrir le dossier des greffons) : les scripts apparaissent dans le menu Scripts › Bibliothèque Fontista.

Les scripts demandent une licence ou la version App Store ; la version d'essai les refuse. Un script peut lire et écrire des fichiers comme tout programme : n'exécutez que des scripts dont vous connaissez la source.

Relire

Approches des lettres témoins

approches.lua

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

Télécharger
Voir le 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

Bilan du crénage

crenage.lua

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.

Télécharger
Voir le 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

Caractères sans dessin

caracteres-vides.lua

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.

Télécharger
Voir le 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

Lettres hors des zones

zones.lua

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.

Télécharger
Voir le 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))

Rapport des chasses

chasses.lua

Les chasses des glyphes dessinés d'un master, de la plus étroite à la plus large, avec la moyenne et les extrêmes.

Télécharger
Voir le 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

Reprendre la revue de Fontista Control

revue-control.lua

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

Télécharger
Voir le 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

Ranger

Couleurs selon l'état

couleurs-etat.lua

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.

Télécharger
Voir le 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]))

Mettre au net toute la fonte

mettre-au-net.lua

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

Télécharger
Voir le 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

Numéroter les alternatives

renommer-alt.lua

Renomme les glyphes « .alt » en « .alt1 », la convention des alternatives de répétition (a.alt devient a.alt1) ; les composants suivent.

Télécharger
Voir le 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))

Espacer et créner

Chiffres à chasse fixe

chiffres-chasse-fixe.lua

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.

Télécharger
Voir le 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

Copier le crénage vers les autres masters

copier-crenage.lua

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.

Télécharger
Voir le 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))

Espaces typographiques

espaces-typographiques.lua

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.

Télécharger
Voir le 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, ", ")))

Glyphes

Compléter les masters

completer-masters.lua

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.

Télécharger
Voir le 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))

Exporter

Exporter la TTF en un geste

exporter-ttf.lua

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.

Télécharger
Voir le 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))

Jeu de caractères en texte

jeu-de-caracteres.lua

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

Télécharger
Voir le 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 pour Claude

Un skill apprend à Claude, l'assistant d'Anthropic, à se servir d'un outil. Avec ceux-ci, vous lui demandez en français « dessine un A géométrique », « crène AV à −60 » ou « qu'est-ce qui ne va pas dans ma fonte ? » : il écrit et lance les scripts, lit les relevés de Fontista Control, corrige, puis vérifie.

Fontista

fontista.skill

Répond aux questions sur l'application (vues, outils, Labo, espacement, crénage, OpenType, export, éditions), et dessine ou modifie des glyphes par des scripts Lua, jusqu'à l'espacement, au crénage, aux accents et à l'export.

Fontista Control

fontista-control.skill

Lance la relecture sans fenêtre, liste les glyphes signalés avec leurs notes et leurs repères, les alertes automatiques et les liaisons suspectes, corrige la source et relance la relecture pour vérifier. Pour n'importe quelle fonte, de n'importe quel éditeur.

Installation : dans l'application Claude ou sur claude.ai, importez le fichier .skill depuis les réglages, rubrique des skills ; dans Claude Code, décompressez-le (c'est une archive zip) dans ~/.claude/skills/. Les skills renvoient à cette page et à la documentation ci-dessous, qui font référence.

Documentation

La fonte vue par un script, toutes les fonctions, le format des contours et des squelettes, les commandes en lot, les dossiers, les greffons et la ligne de commande.

Relevé des glyphes, relevé des liaisons et revue en JSON, sans fenêtre : les colonnes, les clés, l'import et la boucle type avec un script ou une IA.

De la fonte vide à la fonte installée, et les questions fréquentes sur l'application et ses éditions.