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