Instrument Sans extended for Tilo: Vietnamese, Greek, Cyrillic and distinct I/l
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-05 01:32:08 +00:00
proofs Tilo Sans: a steeper slash in the condensed bold zero 2026-10-04 17:32:11 -07:00
scripts Record the v1.002 release and what the toolkit fixture is for 2026-10-04 18:31:57 -07:00
sources Tilo Sans: a steeper slash in the condensed bold zero 2026-10-04 17:32:11 -07:00
.gitattributes Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
.gitignore Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
AUTHORS.txt Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
build.py Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
build.py.lock Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
FONTLOG.txt Tilo Sans 1.002: ship the distinct I and l and the slashed zero 2026-10-04 16:12:42 -07:00
OFL.txt Tilo Sans: Instrument Sans as editable UFO sources with build and proof tools 2026-10-03 02:38:20 -07:00
proof.py Tilo Sans as a standalone project 2026-10-03 15:46:31 -07:00
README.md Record the v1.002 release and what the toolkit fixture is for 2026-10-04 18:31:57 -07:00

Tilo Sans

Tilo's UI typeface: Instrument Sans by Rodrigo Fuenzalida and Jordan Egstad, extended for Tilo.

Instrument Sans is a refined, compact grotesque with a width and a weight axis, tabular figures and no Reserved Font Name, but its character set stops at Latin. Tilo Sans keeps every Latin outline and spacing of it unchanged and adds what a desktop used around the world needs:

  • complete Vietnamese: every precomposed letter, stacked tones that fit the line, the horns, ₫, and decomposed input composing to the same glyphs;
  • monotonic Greek: tonos, dialytika, the ano teleia and the Greek question mark;
  • Cyrillic for Russian, Ukrainian, Belarusian, Bulgarian (locl BGR), Serbian and Macedonian (locl SRB/MKD) and Kazakh;
  • a distinct I and l for identifiers (a serif capital I, a tailed l: ss13, cv01, cv02), so usernames, codes and passwords cannot be misread;
  • a slashed zero (zero) whose slash stays inside the counter (and, in the condensed and heavy weights, clear of it at both ends).

Everything is drawn in all eight masters (upright and italic, Condensed to Normal, Regular to Bold) from Instrument's own parts and stems. Not all of it ships yet: Latin, Vietnamese, Greek, the distinct I/l and the slashed zero are in the default build Tilo uses; Cyrillic is held back until review approves it (Coverage and what ships says why).

Licensed under the SIL Open Font License 1.1. Instrument Sans declares no Reserved Font Name; the extended family is renamed anyway so it can never be mistaken for upstream's files. The name table credits Instrument Sans and its authors in the copyright (ID 0), designer (ID 9) and description (ID 10) strings. Authors and the change history are in AUTHORS.txt and FONTLOG.txt.

Quick start

Everything is a uv script that declares its own dependencies; run them from the repository root. The proofs render with headless Chromium: uvx playwright install chromium-headless-shell (or set CHROME to a Chromium or Chrome binary).

uv run build.py                    # sources/ -> fonts/ (both variable fonts, plus fonts/default/ and fonts/full/)
uv run scripts/shaping_tests.py    # HarfBuzz checks on the binaries
uv run scripts/verify_upstream.py  # every Instrument Sans glyph unchanged
uv run proof.py --set vietnamese   # proof sheets -> proofs/work/vietnamese/

The comparison fonts the proofs and checks use (upstream Instrument Sans, Onest, Geologica, Noto Sans as Tilo ships it) are fetched on first use from pinned upstream locations and checked against recorded SHA-256 values (scripts/reference_fonts.py --list).

Provenance

Upstream https://github.com/Instrument/instrument-sans
Pinned commit 7fa22308a3d0c94ee2b3cd537a1196b65db34a3e (2023-06-14, "feat: updated example gif")
Upstream sources sources/InstrumentSans.glyphs, sources/InstrumentSans-Italic.glyphs (Glyphs 3)
Converted with glyphsLib 6.15 via scripts/import_upstream.py (one-time; see below)

The upright variable font rebuilt here matches upstream's fonts/variable/InstrumentSans[wdth,wght].ttf (byte-identical to the InstrumentSans.ttf Tilo used before) glyph for glyph: same glyph set, cmap, advances, outlines (point by point at all 12 named locations) and shaping (every character, every letter pair, mark stacks, each GSUB feature and locl language). The italic matches upstream's italic the same way. The one difference: bullet.ss01 and bullet.case.ss01 (scaled period.ss01 components) are now decomposed before the cubic-to-quadratic conversion, so their circles carry a few more points; the shape is the same within one unit. scripts/verify_upstream.py checks all of this.

Coverage and what ships

The sources hold everything every area has drawn. build.py compiles all of it, then scripts/release.py writes two sets of binaries:

fonts/default/ (what Tilo ships) fonts/full/ (review only, never shipped)
Latin (Instrument Sans' own set) yes yes
Vietnamese: every precomposed letter, stacked tones, ₫, decomposed input yes yes
Greek: monotonic, tonos, dialytika, ano teleia, ; yes yes
Cyrillic: Russian, Ukrainian, Belarusian, Bulgarian and Serbian/Macedonian (locl), Kazakh no (Onest ss02 draws it) yes
Distinct I and l (ss13, cv01, cv02) and the slashed zero (zero) yes (since v1.002; the Cyrillic serif І Ї with Cyrillic) yes
Glyphs / code points 785 / 521 921 / 635

Why:

  • Vietnamese ships, as the product requires. The kerning gaps review found (ũ Ũ ẽ Ẽ ỹ Ỹ ữ Ữ without groups, T/Y before ư ụ ủ ứ ừ ử ữ ự at 0, Tố/Tấ pushed apart in the bold and italic masters) are fixed: every Vietnamese letter kerns like its base letter until a collision says otherwise, and the circumflex of a stacked tone stays where â has it. Still open, for the native reader: the capital stacks Ấ Ầ Ẩ … use the shorter .case circumflex while Â Ê Ô keep Instrument's (VÂN beside VẤN); the bold capital tilde and hook stacks reach 1026 units, so at a line height below 1.25 (CSS normal is 1.22 with this font's metrics) they can touch the descenders of the line above; at 12–13 px Condensed Bold the tones beside a circumflex are two-pixel marks; ỵ's dot sits right of the descender.
  • Greek ships: review approved it against the fallback (Geologica at 0.85 × the weight). The product owner's sheet is proofs/final/greek-review/.
  • Cyrillic is held back. Review found the shapes recognisably Instrument and better than Onest beside Instrument's Latin, and the earlier collisions (б and ф kerned as rounds under Т Г У in «Тбилиси», «Убрать»; the italic ∂-shaped д in «Где», «Удача»), the crowded dieresis of «її» and the clogged bold в Ф Ю are fixed (proofs/final/cyrillic/review-fixes.png). Still open:
    • the condensed italic Я (worst in Condensed Bold Italic): moving the italic foot right by the slant left leg and stem nearly parallel, 3–32 units apart, so «ЯНВАРЬ» reads as «9I». The gap between leg and stem must stay at least the upright master's (or 0.6 of R's), by moving the leg's junction rather than only its foot, with a counter check in construct.py;
    • ї Ї in Bold, Condensed Bold and their italics: the dots are about half the area of і's and ï's and sit higher (bottom at 626 against і's 578 in Bold), so «Україні» looks light-topped. They should reach about 0.85 of і's dot, aligned with it, keeping the narrow span;
    • the Serbian/Macedonian italic г and ѓ (locl SRB): the widened macron overhangs the advance, so in lang=sr text «гг» and «ѓг» overlap and «ѓгѓ» in the bold italics is one bar over three stems. It needs insetting like п and т, and pairs.py does not check locl forms yet (it should run with lang=sr, mk and bg);
    • ы in Bold and Condensed Bold: the white between bowl and second stem is 47 units in Condensed Bold (63 in Bold), so it clogs at 12–13 px;
    • э in Condensed Bold: the eye above the bar is about 55 × 90 units and fills in before с and е do. Native readers check proofs/final/cyrillic-review/ (a Bulgarian reader the locl BGR forms) once those are fixed.
  • Distinct I and l and the slashed zero ship (v1.002), approved on 1x and 4x renders at 12–17 px in Regular, Bold and Condensed Bold, upright and italic, light and dark (proofs/final/il-review/, scripts/il_sheet.py --review). What review found on the way, in all eight masters (scripts/il_forms.py draws them, scripts/il_kern.py spaces them, scripts/pairs.py checks them):
    • the tailed l read as L or t at 12–14 px: its tail is a short curved foot that turns up and ends square to the stroke, with a round outer corner (119–176 units past the stem, against L's 166–370); in the condensed bold, whose foot is more than a pixel thick at 12 px, the tail is lighter (88) and its end rises at 72° to 165 units, so the upturn fills a second pixel row and «lL», «LlL» never read «LL», «LLL»;
    • ł read as t («michat», «tódź»): ł keeps its plain form under ss13 and cv02 (lslash.ss13 is gone);
    • the tail bridged into the next stem («isIUegal» in the condensed bold, «Ilya_l1» read «Ilya_U» at 13 px in Bold): the tail keeps 126 units, a pixel and a half at 12 px, from a following figure, | ¦ ! ¡ or letter whose left side is a stem beside it (l i j n m h b k r p u and the stem capitals), by its advance and by kerning; from every other letter 70 (a round curves away from the tail, a s v x y z leave no stem beside it), so «hello», «legal», «all» stay as tight as the plain l allows (with 126 or 90 from every letter the condensed bold opened into gaps around its l's);
    • the serifs of II/III fused at 12 px: the serifs are short (66–46 units past the stem) and keep 126 units between them, 1.5 px at 12 px; so do a bar or arm level with a serif (7I, TI, IT, I7), which at 87 read as one bar in the condensed bold italic;
    • I4/4I and I/l against accents and ¿ came diagonally closer than 40 units: they keep 40 in any direction, and 80 beside a figure (about a pixel at 12 px), the figure before a tailed l included (4l, 7l were Instrument's 54 in the condensed bold, where they fused);
    • lcaron.ss13 is kerned like the other tailed l's (and keeps its own class for the caron). The zero is slashed (not dotted, which reads as θ beside Greek). In Regular the slash ends inside the ring, so it stays apart from Ø. In the condensed and heavy masters a slash that reaches the ring filled the narrow counter grey, and 0, θ, Θ and Ø rendered as dark ovals in Condensed Bold: there the slash is shorter and stops clear of the counter at both ends (40 units along the slash, 24–36 beside it; at 70–80 it shrank to a tick that read as a dotted zero at large sizes). The condensed bold zero is 14 units wider on each side with a counter of 196 units (2.35 px at 12 px, against Instrument's 152), its sides 134 units against the plain zero's 142: at 236 units and 114-unit sides it was visibly fainter than the figures beside it at 12 px. Not fixable here: rn against m and cl against d are Instrument's own Latin letters and spacing; in Condensed Bold at 12 px rn comes close to m.

Final sheets of the merged family are in proofs/final/ (tracked): one folder per area (vietnamese, greek, cyrillic, il, latin), and the review sheets greek-review/ and cyrillic-review/ (scripts/review_sheet.py): every letter large above the fallback's, interface strings and running text at 14, 16, 20 and 32 px in light and dark, Tilo Sans beside Tilo's Latin plus the fallback. Geologica at 340 is still heavier than Tilo Sans at 400 (its stem is about 103 units at Tilo's x-height against 80).

Checks on the binaries, besides build.py's own:

  • scripts/shaping_tests.py: Vietnamese and Greek typed precomposed or decomposed draw the same; the Vietnamese tones and Greek tonos/dialytika typed as combining marks sit on every vowel; ν· takes the ano teleia; ss13, cv01, cv02 and zero work in full/ and do nothing in default/; Bulgarian and Serbian/Macedonian locl; default/ has no Cyrillic and shapes every Latin, Vietnamese and Greek test string exactly like full/.
  • scripts/verify_upstream.py: every upstream outline and advance unchanged. Its shaping differences are intended: decomposed Vietnamese sequences now compose (E + U+0303 → Ẽ, ấ's stacked tones), the collision kerning on Instrument's own Vietnamese letters (Yì, Yí, fê, Ťý at the condensed masters), and usWinAscent 1026 for the tallest stacks.
  • fontbakery check-googlefonts: the FAILs are upstream's own (the STAT combination "SemiCondensed SemiBold" is 32 characters; Dutch J́ has no anchor on J), plus, in full/ only, Tajik letters (ҷ ҳ җ) and ʼ missing for languages fontbakery counts as Cyrillic. The WARNs are upstream's (no dotted circle, no meta table, vendor ID, outline alignment, the soft-dotted cases other than ị) plus overlapping segments where two contours meet in β and ω.

To ship a held-back script or feature once it is approved: delete its entry from HELD_BACK in scripts/release.py, run build.py, check the new fonts/default/ with scripts/shaping_tests.py and the proofs, and cut a release (see Releases and how Tilo consumes them). Tilo's toolkit applies the distinct I/l and slashed zero by capability, so shipping them needed no code change there; its tests check a face's features on a few letters of the full build (scripts/toolkit_fixture.py).

Layout

tilo-sans/
  OFL.txt, AUTHORS.txt, FONTLOG.txt   license, copyright authors, change history
  build.py                 compile sources/ -> fonts/ (variable TTFs)
  build.py.lock            the exact build dependencies (uv), so releases rebuild byte for byte
  proof.py                 render proofs with headless Chromium -> proofs/
  scripts/reference_fonts.py   fetch the comparison fonts (pinned URLs, SHA-256) -> .reference-fonts/
  scripts/measure.py       metrics, stems, overshoots, sidebearings per master
  scripts/vietnamese.py    draws the Vietnamese marks, stacks and horns from a table of design values
  scripts/kerning.py       generates the Vietnamese kerning (groups), rebuilds and checks
  scripts/components.py    clearance between an accented glyph's parts, tone angles, stack heights
  scripts/pairs.py         neighbouring letters colliding after kerning
  scripts/inkgeom.py       glyph ink as polygons (shared by the two checks)
  scripts/verify_upstream.py   every upstream glyph unchanged?
  scripts/il_kern.py       kerning of the distinct I and l (ss13), solved from the built fonts
  scripts/il_sheet.py      the confusable characters (I l 1 | ! i j, O 0 o, rn m, cl d) at 12-14 px at 1x, enlarged
  scripts/release.py       fonts/default/ (shipped: held-back scripts removed) and fonts/full/
  scripts/shaping_tests.py HarfBuzz tests: Vietnamese and Greek NFC = NFD, marks, I/l, locl, default = full
  scripts/review_sheet.py  Greek / Cyrillic beside their fallback, for native readers
  scripts/toolkit_fixture.py   artifact for tilo/tilo: the toolkit's small test face with the I/l and zero features
  scripts/import_upstream.py   how sources/ was made from upstream (do not rerun)
  scripts/greek/           the Greek: outline generator, kerning, checks (see Greek)
  sources/
    TiloSans.designspace           upright: 4 masters
    TiloSans-Italic.designspace    italic: 4 masters
    TiloSans-{CondensedRegular,CondensedBold,Regular,Bold}.ufo
    TiloSans-{CondensedItalic,CondensedBoldItalic,Italic,BoldItalic}.ufo
    features/*.fea                 OpenType code, one file per feature, shared by all 8 masters
  fonts/        (ignored)  TiloSans[wdth,wght].ttf, TiloSans-Italic[wdth,wght].ttf (everything),
                           default/ (what Tilo ships), full/ (for review)
  proofs/work/  (ignored)  every proof run
  proofs/sheets/           proofs written with --final, per area
  proofs/final/            the sheets of the merged family: one per area, and the review sheets
  .reference-fonts/ (ignored)  comparison fonts fetched by scripts/reference_fonts.py
  .upstream/    (ignored)  upstream checkout for import_upstream.py

Every glyph is one .glif file per master, so parallel work on different glyphs merges cleanly. Each master's features.fea only includes features/*.fea.

Commands

All tools are uv scripts that declare their own dependencies (PEP 723); run them from the repository root.

uv run build.py              # lint + interpolation, clearance and pair checks + compile both (~25 s)
uv run build.py --upright    # or --italic
uv run build.py --fast       # skip the interpolation, clearance and pair checks while iterating
uv run build.py --check      # lint only, no compile

uv run proof.py --set vietnamese
uv run proof.py --set greek --fallback geologica
uv run proof.py --set cyrillic --fallback onest
uv run proof.py --set cyrillic --lang bg --name cyrillic-bg   # locl forms
uv run proof.py --chars "Il1|" --features ss13 --name il
uv run proof.py --set vietnamese --italic
uv run proof.py --set latin --fallback instrument           # regression vs upstream

uv run scripts/measure.py [--glyphs n o A-cy]
uv run scripts/vietnamese.py            # redraw the Vietnamese marks and stacks
uv run scripts/kerning.py [--reset]      # regenerate the Vietnamese kerning
uv run scripts/components.py [--all] [--glyphs Abreveacute]
uv run scripts/pairs.py [--show 40] [--text "đĩa Tĩnh"]
uv run scripts/verify_upstream.py
uv run scripts/il_forms.py [--check]  # draw the distinct I/l and slashed zero in all masters
uv run scripts/il_kern.py [--check]   # after build; repeat until --check is clean
uv run scripts/il_sheet.py [--final]  # confusables at UI sizes -> proofs/work/il-sheet/
uv run scripts/shaping_tests.py        # after build
uv run scripts/review_sheet.py --script greek   # or cyrillic -> proofs/final/<script>-review/

Kerning is written by four tools, each owning its pairs. After a change that touches kerning, run them in this order, building in between: scripts/greek/kern.py (Greek) and the Cyrillic kerning in scripts/cyrillic/ (Cyrillic memberships and pairs), then scripts/kerning.py (Vietnamese groups, and every collision pairs.py finds, Greek and Cyrillic against punctuation included), then scripts/il_kern.py (every pair with a serif I or tailed l), repeated until --check reports nothing. kerning.py resets the pairs it generated, so il_kern.py must follow it.

verify_upstream.py and proof.py --fallback instrument compare against upstream's variable fonts at UPSTREAM_COMMIT (see scripts/import_upstream.py), which scripts/reference_fonts.py fetches. Only import_upstream.py needs the full upstream checkout in .upstream/instrument-sans, and it clones it itself.

Proofs

proof.py writes 2x PNGs to proofs/work/<name>/ (--final writes to proofs/sheets/<name>/, which git tracks):

page what
grid-<instance>[-n].png every glyph at 160 px in its advance box (tinted) with baseline, x-height, cap height, ascender, descender; glyph name and code point; 36 per page; one set per instance: Regular, Medium, SemiBold, Bold (wdth 100), Condensed Regular, Condensed Bold (wdth 75). A code point the font lacks is a red cell labelled MISSING.
text-light.png, text-dark.png sample text at 13, 14, 16, 20, 26 px, body at 400 and a 600 heading, in Tilo's light and dark surface colours
compare-<fallback>.png the glyphs (96 px, 400 and 700; 48 px) and the samples (13–26 px) in Tilo Sans and the fallback, the fallback scaled to Tilo Sans' x-height (510/1000)
context-<style>.png each glyph between its script's control letters (HH?HOH?OHO?OO, nn?non?ono?oo; Cyrillic Н О н о; Greek Η Ο π ο) and the samples, at Regular, Bold and Condensed Regular 32 px and Regular 14 px

Fallbacks: onest (Onest with ss02, same weight), geologica (Geologica at 0.85x the weight: 340 for Regular), noto (Noto Sans, the exact files Tilo ships), instrument (upstream), or a path to any font. The named ones are fetched by scripts/reference_fonts.py on first use. Default per set: Greek → geologica, Cyrillic → onest, Vietnamese → noto, else instrument. Characters Tilo Sans lacks are struck through in red on every page; Chromium would otherwise draw them from a system font. The proof refuses to run on a font older than its sources.

Look at proofs with an image viewer at full size, at both 160 px and the UI sizes, light and dark, and across weights and widths.

Design space

Axes as upstream; user and design coordinates coincide except one width point.

axis tag min default max map (user → design)
Width wdth 75 100 100 75→75, 88→87.5, 100→100
Weight wght 400 400 700 linear

Masters (one UFO each; every glyph must exist in all of them, compatible):

UFO wdth wght
TiloSans-CondensedRegular.ufo / TiloSans-CondensedItalic.ufo 75 400
TiloSans-CondensedBold.ufo / TiloSans-CondensedBoldItalic.ufo 75 700
TiloSans-Regular.ufo / TiloSans-Italic.ufo (default) 100 400
TiloSans-Bold.ufo / TiloSans-BoldItalic.ufo 100 700

Named instances in fvar: Regular 400, Medium 500, SemiBold 600, Bold 700 at wdth 100 (italic: Italic, Medium Italic, SemiBold Italic, Bold Italic). STAT also labels Condensed (75) and SemiCondensed (87.5), and ital 0/1 (Roman/Italic). There is no weight below 400 and no width above 100: there are no Thin or Expanded masters.

Metrics a new glyph must match

UPM 1000. Identical in every master: x-height 510, cap height 720, ascender 740 (b d l tops sit at 720, d's top anchor at 740), descender −205, overshoot 10 above and below for round shapes (o, O), italic angle 13° (UFO italicAngle −13). Line metrics: typo/hhea ascender 970, descender −250, line gap 0, win 1026/350 (upstream's win ascent is 986; it follows the Vietnamese capital stacks), USE_TYPO_METRICS set.

Stems (font units; italic stems measured across the stroke). From scripts/measure.py:

master n stem o side o top/bottom H stem H bar O side O top/bottom
Condensed Regular 68 70 60 70 62 72 64
Condensed Bold 138 142 108 142 110 144 112
Regular 80 82 68 82 72 84 74
Bold 150 156 118 154 118 156 122
Condensed Italic 66 69 62 68 62 73 67/66
Condensed Bold Italic 134 138 120 138 110 139 122/121
Italic 78 80 69/70 80 72 83 76
Bold Italic 146 152 123/124 150 118 152 126

Spacing references (advance, LSB, RSB; italic sidebearings measured on the outline slanted back upright about half the x-height, as Glyphs shows them; the italic spacing equals the upright's):

master n o H O
Condensed Regular 442, 64, 60 432, 36, 36 500, 70, 70 500, 40, 40
Condensed Bold 462, 44, 40 460, 21, 21 514, 50, 50 536, 30, 30
Regular 599, 80, 74 584, 40, 40 736, 86, 86 786, 50, 50
Bold 619, 60, 54 604, 20, 20 720, 50, 50 798, 30, 30

Glyph naming

Glyphs.app "nice names", as upstream: A, aacute, idotless, Ohorn, Ecircumflexacute, A-cy, be-cy, Alpha, alphatonos, iotadieresistonos, numero. Combining marks end in comb (acutecomb, dotbelowcomb, hookabovecomb); ligatures join components with _ (f_i). Suffixes: .ss01–.ssNN (stylistic set), .case (capital forms), .tf (tabular), .alt, .liga, .loclBGR / .loclSRB / .loclMKD / .loclCAT (language forms); stack them in feature order (Gcommaaccent.ss01.ss07). The italic uses the same names; there are no italic-only glyph names.

Production (post table) names are derived at build time: A-cy ships as uni0410, Ecircumflexacute as uni1EBE, Be-cy.loclBGR as uni0411.loclBGR. Do not add public.postscriptNames entries by hand. File names inside the UFO follow the UFO convention (an underscore after each capital: Aacute → A_acute.glif, A-cy → A_-cy.glif); let the UFO library pick them.

Stylistic sets in use: ss01 Round dots, ss02 Single Story a, ss03 Alternate y, ss04 Alternate K, ss05 Alternate R, ss06 Alternate M, ss07 Alternate G, ss08 Alternate J, ss09 Alternate Q, ss10 Alternate 2, ss11 Alternate Ampersand, ss12 Alternate Currency, ss13 Distinct I and l. New sets start at ss14.

Distinct forms for identifiers (features/il.fea)

Off by default. Tilo's UI toolkit turns on ss13 and zero for text that must be unambiguous: usernames, user-submitted identifiers, passwords, codes.

feature name glyphs
ss13 Distinct I and l I → I.ss13 (short slab serifs, a little lighter than E's arms), l → l.ss13 (a short tail that turns up), with every precomposed form: Iacute.ss13 … Iogonek.ss13, IJ.ss13, Idieresis.ss01.ss13, lacute.ss13, lcaron.ss13, lcommaaccent.ss13, and the ligatures f_l.ss13, f_f_l.ss13; the same capital in Greek and Cyrillic: Iota.ss13, Iotadieresis.ss13, Iotadieresis.ss01.ss13, Iotatonos.ss13, I-cy.ss13, Yi-cy.ss13
cv01 Capital I with serifs the I half of ss13
cv02 Lowercase l with tail the l half of ss13
zero (slashed zero) zero.zero, zero.tf.zero: a thin slash inside the counter, never past the ring (so it does not read as Ø; not a dot, which reads as θ beside Greek); in Regular it ends in the ring, in the condensed and heavy weights it stops short of it at both ends

1, | and ! keep their upstream forms: the flagged 1, the long thin bar and the dotted ! already differ from both new forms. ł keeps its plain form too: its bar already sets it apart from I l 1 |, and a tail under the bar is t's silhouette («michał» read «michat»). The lookups run after liga and ss01, so fl becomes f_l.ss13 and Ï with round dots becomes Idieresis.ss01.ss13. All four features are in aalt, so glyph palettes reach the alternates.

Every new precomposed I or l needs its .ss13 composite, a line in il.fea and a place in the kern groups below, in the same change that adds it; otherwise ss13 silently leaves a plain stem in an identifier (shaping_tests.py checks every I and l it knows). Greek Ι Ϊ Ί and Cyrillic І Ї take the serif form: a bare stem beside a serif Latin I would be ambiguous again. Ί.ss13 keeps the tonos where Ί has it and moves the serif I so its top-left serif keeps the clearance the plain stem had; Ї.ss13 keeps the Cyrillic dots, centred. The palochka Ӏ is not in the font. I.ss13 carries topleft for the Greek tonos, placed as far from the serif as upstream's I places it from its stem.

scripts/il_forms.py draws every form, in all eight masters, from each master's own I, l, t and 0 and a small table of design values (its docstring has them); the italics are drawn upright and slanted back with Instrument's own shear, so all masters stay point-compatible. Edit the table and rerun it rather than editing the glyphs; --check reports any source that differs from what it draws.

  • The serif I: the plain stem with slab serifs at baseline and cap height, reaching 66 units past the stem in Regular, 58 in Bold, 52 and 46 in the condensed masters, a little thinner than E's arms. The serifs are short so that two I's never meet: its sidebearings are 63 units, so II and III keep 126 units (a pixel and a half at 12 px) between their serifs in every master, where the earlier, longer serifs fused into one bar; and I1, Il stay two shapes.
  • The tailed l: the stem turns at the foot in a round like t's and runs on as a short tail along an arc, rising to 45–50° and cut square to the stroke, as Instrument cuts t and c. The upturned end and the round outer corner are what tell it from L's flat, square foot at UI sizes; the tail stays short (144 units past the stem in Regular, 170 Bold, 119 Condensed, 124 Condensed Bold: about a third of n's width, under L's 166–370). In Condensed Bold, where the foot is more than a pixel thick at 12 px, the tail is a little lighter (88 units) and its end rises at 72° to 165 units, so the upturn spans two pixel rows. After the tail it keeps t's right sidebearing, or more so the tail ends 126 units (a pixel and a half at 12 px) from a following l's stem.
  • The slashed zero: a straight slash inside the counter, steeper than Ø's and lighter than the ring (62 units in Regular, 88 in Bold, 54 in Condensed, 66 in Condensed Bold). In Regular it ends in the ring; in the other masters it is as long as it can be while it stops 40 units short of the counter along its length and keeps 24–36 beside it. The condensed bold zero is drawn 14 units wider on each side (zero.zero 28 units wider, zero.tf.zero keeping the tabular advance) with a counter of 196 units, so its sides stay 134 units, near the plain zero's 142 (a wider counter made it fainter than the figures beside it at 12 px). In that narrow counter the slash stands at 75° (57–68° elsewhere), so the side clearance still leaves it about half the counter's height, as in Bold.

Spacing and kerning. Groups: public.kern1.KO_I.ss13 / public.kern2.KO_I.ss13 (with IJ.ss13 on the left side; its right side carries IJ's pairs; the Greek and Cyrillic serif I's in both, except Iotatonos.ss13, whose left side is its tonos and copies plain Ί's pairs); public.kern1.KO_l.ss13 for l.ss13, its accented forms and the tailed ligatures (their left sides are plain l's and stay in plain ĺ ľ ļ's KO_j group); public.kern1.KO_lcaron.ss13 for lcaron.ss13 alone (it must not share ď's values: the caron overhang sets those, and the tail would run into the next letter). f_l.ss13 and f_f_l.ss13 stay in public.kern2.KO_f and zero.zero in both KO_zero groups (with copies of zero's glyph-level pairs), so the alternates keep every upstream pair.

Every pair with one of these groups (or their members) is written by scripts/il_kern.py, never by hand. It measures the shaped fonts at all eight masters, with ss13 on and off: level gaps (scanline against scanline) for the white beside a stem, and ink distances in any direction for the floors, so a serif tip coming towards the flag of 1, the arm of 4, the bowl of ¿ or an accent counts as much as one level with it. The kerning that reaches a distance is solved exactly, so a pass settles. The rules (the script's docstring has the detail):

  • the white beside the bare stem (between the serifs, above the tail) is the plain I/l gap plus a little: 14 / 12 units for most partners (t f v w y r, figures, operators), a share of the extra width next to rounds (O C Q Ø, o e, 0 6 9, and the Greek and Cyrillic rounds: a third beside the serif I, whose serifs sit where a round curves away; a sixth beside the tailed l, whose tail fills under it), half of it next to top and middle shapes (T V W Y 7, quotes, dashes, guillemets, ·). Tightening stops where the tail or a serif would sit closer to the partner than it sits to a straight stem (l n, I H), so neither the tail nor the serifs collide and no hole opens above the tail or beside the inset stem;
  • distances never shorter than: 24 for any pair (the collision check's 16 plus room for the instances between masters), even where the plain letter collides; the serif I and the tailed l 40 from anything, in any direction, even where the plain letter comes closer (I4, l4 in the condensed bold); where the serif I's nearest approach is diagonal, also what Z's bars keep from the same partner (Ζ in Greek, Е in Cyrillic) or the plain I's, whichever is shorter; the tailed l also what Instrument's t or plain l keep; next to 1 | ¦ ! ¡ and the stems l i j (and their accented forms) the plain letter's distance up to 104 units (a clear pixel and a quarter at 12 px), so I1, Il, Ii never close up; beside a figure 80 (about a pixel at 12 px, so I4, 4I stay two shapes in the condensed bold), a figure before a tailed l too (4l, 7l); the tail 126 from a following figure, | ¦ ! ¡ or letter whose left side is a stem beside it (l i j n m h b k r p u, the stem capitals), 70 from any other letter, so it never bridges into the next stem at 12–13 px (isIllegal, l1) and the condensed bold does not open up around its l's (hello), and 14 more beside a figure, so the instances between the condensed masters keep 126 too; ď ľ ť ř before I 45. Each distance is solved 6 units long, so the exact outlines keep it after the scanline sampling and rounding;
  • level gaps: serif I beside serif I (II, ÏÎ) 126 between the serifs, and the same beside a bar or arm level with a serif (7I, TI, IT, I7: a partner that comes 40 units nearer at the serif's height than H does), where less reads as one bar at 12 px; IJ tI ťI at LZ; lI at the larger of LZ and ZZ;
  • lcaron.ss13 is measured like the other tailed l's and kerns at least as loose as plain ľ's value moved by the advance difference; its caron keeps 40 from anything and 80 from a figure (plain ľ's own is negative against T Y 7 and the i accents);
  • Catalan l·l (locl CAT): the middle dot stays centred between tailed l's.

The italic masters are measured on their own outlines, with the slant taken out for the level gaps (as Glyphs shows italic sidebearings) and as drawn for the distances. Letters of two scripts are never measured together (HarfBuzz shapes them in separate runs, which are not kerned). pairs.py checks the distinct forms as well, at the masters and the instances between them: no pair with ss13 on may come closer than 16 units unless the plain pair already does; beside the accents, ¿ ¡ and each other the serif I and tailed l keep 40 in any direction at the masters (36 between them) and beside the figures 80 (76 between them), a figure before a tailed l included; and the tail (the tailed l's ink below 200 units) keeps 126 from a following figure or stem (120 between them), all measured exactly on the outlines (scripts/inkgeom.py).

scripts/il_sheet.py renders what the forms are for: the confusables (I l 1 | ! i j, lL LlL ll, II III I1 I4, O 0 o Ø ø θ Θ, rn m, cl d) and identifiers (user_Il1, Ilya_l1, IIl1lI, mail.l1@x.io, lI1O0o, ID-I1l0O, michał, łódź, isIllegal, …) at 12, 13 and 14 px at 1x, enlarged without smoothing, in Regular, Bold and Condensed Bold, upright and italic, light and dark, each line off and on; and the tight pairs at 96 px. Its sheets are in proofs/sheets/il-sheet/. With --review it writes the sign-off sheet to proofs/final/il-review/: the identifiers and confusables in each style at 12, 13 and 14 px, default forms beside the distinct ones, light and dark, at 1x and 2x as rendered and the 1x page enlarged 3x.

A new letter that can sit next to a serif I or tailed l (Vietnamese Ĩ Ỉ Ị, Cyrillic і, Greek ι, a new accent) needs no hand kerning against them: build, run il_kern.py, build again, and il_kern.py --check must report no changes. Upstream's glyph-level pairs on accented plain I's (Ï/-, -/Î, «/Î) and f_l/_ ~ do not carry over: with ss13 on, an accented serif I kerns like the serif I, and f_l.ss13 like l.ss13.

Anchors

Bases: top, bottom, ogonek, center (for strokes: Đ, Ł, Ħ), topleft, topright (caron-as-apostrophe: ď ľ ť). Marks: _top + top (stackable), _bottom + bottom, _ogonek, _topright (caroncomb.alt). Ligatures: top_1…, bottom_1…, caret_1… . Marks attach at _top y = 510 (x-height) and carry their own top for the next mark (acutecomb top at 740); a capital's top is at 720, so the same acutecomb serves lower and upper case (there are no .case marks yet).

The build propagates anchors into composites (Glyphs' algorithm): Aacute = A + acutecomb gets A's bottom/ogonek and a top on the acute, so a further mark stacks on it. Draw anchors on simple glyphs and marks; put anchors on a composite only to override. Spacing accents (acute, tonos, …) get none. mark, mkmk, kern and GDEF are generated from anchors, categories and kerning; never write them by hand.

Vietnamese

Every precomposed Vietnamese letter (all 134 with Đ đ) and the đồng sign ₫, upright and italic, in every master. Upstream already had the letters shared with other languages (à á â ã è é ê ì í ò ó ô õ ù ú ý ỳ ă đ); the rest are composites on these marks, placed by the anchors (build.py propagates them).

The marks, stacks, horns and mark positions are drawn by scripts/vietnamese.py from Instrument's own marks and letters and a table of design values per master (DESIGN, HORN); it never changes upstream glyphs and can be rerun at any time. To change a stack, edit the table, run it, then scripts/kerning.py and build.py.

  • Hook above (hookabovecomb): the top of Instrument's question mark, a flat foot, a round shoulder and a terminal on the left cut flat below the middle, so the curl stays an open crescent in every weight. Its stroke is a little lighter than the acute's, as the question mark's curve is lighter than its stem.
  • Dot below (dotbelowcomb): i's square dot; dotbelowcomb.ss01 is the round one ("Round dots"), its top level with the square dot's. ỵ puts the dot just right of the descender at the same depth (the anchor would put it on the tail); y + U+0323 becomes ydotbelow in ccmp.
  • Horns (ơ ư Ơ Ư, single outlines): an arm that leaves the right of the stem (or the bowl's upper right) below its top, swings right and up and ends in a flat cut 40 units above the x-height or cap height; the stem keeps its flat top. The arm carries the stem's weight in every master. The advance follows one rule in every master (the italics measured unslanted): half way between a horn that overhangs the next letter as far as a flat-topped n/H allows (24 units clear) and one that clears the crossbar of t and the arm of T; kerning opens ợt, ỚT and the like by the rest. Accented horn letters take the horn letter's advance. horncomb is ơ's horn alone; ccmp composes o O u U + U+031B into the drawn letters, so decomposed text gets the same horn and advance from shapers that do not recompose.
  • Stacked tones are ligature marks named as in Glyphs (circumflexcomb_acutecomb, brevecomb_tildecomb, …). Acute, grave and hook sit to the right of the circumflex, the tone's foot 36 units below the circumflex's top and 34 from its leg. The acute and grave are the master's own acutecomb/gravecomb, cut shorter from the foot (106–112 units lowercase, 88–92 on capitals) so they keep its angle and stroke exactly: sắc and huyền stay as far apart as on a single vowel, in the italics too. They overhang the next letter instead of standing up; kerning opens the few pairs that would touch. The circumflex stays within 20 units of where â ê ô have it (STACK_SHIFT) and the tone hangs out to the right; centring the pair over the letter pushed the circumflex left into the arm of a T or Y before it (Tố was kerned apart in the bold and italic masters). The grave goes right like the others: on the left it would run into the ascender of the th, kh, đ, l that so often precede ầ ề ồ; a native reader should confirm the choice. Every tone sits on top of the breve, the tilde on top of the circumflex, 34 units clear (32 in the bold masters). Over the breve the tones are the short capital forms in both cases, so ắ does not tower over the ascenders, and the hook may move up to 40 units off centre when that lets its stem dip into the breve's cup.
  • Capital stacks (Ấ Ẫ Ắ Ẳ …) use .case marks starting 42 units above the cap height (single accents on capitals stay upstream's 73): circumflexcomb.case is drawn like Instrument's circumflex (legs with open corners under a flat top) 124–126 units tall and 86–90 % as wide, with the lowercase leg stroke; the flat top keeps its share of the width and the apex stays at least 80 % of the leg stroke and at most half the mark's height, so the notch reads. brevecomb.case is 114 units tall (118 bold) with the arms' width kept and the bowl's bottom thinner (54/64 units), so the counter is 46–53 % of the mark's height, not a shallow notch. acutecomb.case/ gravecomb.case are the master's tones 96–100 units tall, tildecomb.case a lower wave (86/106 units, the bold stroke at 80 %), hookabovecomb.case a smaller hook (132–134 units). The tallest stacks reach 1003 (Regular Ẫ), 1008 (Bold Ắ), 1026 (Bold and Condensed Bold Ẫ) and 1023 (Condensed Bold Ẳ); scripts/components.py fails any added glyph above 1030. usWinAscent follows the tallest ink (scripts/vietnamese.py sets it), so Windows does not clip them.
  • Line spacing and clipping. typo/hhea stay upstream's (970/−250, USE_TYPO_METRICS), so line spacing is Instrument's. At Tilo's default line height (TextStyle 1.25, lines 1250 units apart) a capital stack stays clear of a g (−215) on the line above in every master; at CSS normal (1220) the bold stacks come within the descender by up to about 20 units, and at 1.15 they touch. The stacks rise 30–56 units above the 970 ascender, so a box that clips exactly to the line box (a web overflow: hidden label at line-height normal) trims the top of Ẫ's tilde. Decision for the Tilo toolkit: text is never clipped at its line box. Clip rects come from widget shapes (an input's field, a scroll viewport, a table body), which include their padding; any clip that hugs a single line of text must extend at least 0.06 em above the line box (usWinAscent − hhea ascender) and 0.1 em below it, the way browsers let ink overflow an unclipped line. Tilo's toolkit (crates/sdk/ui-core in tilo/tilo) follows this today (no per-line clips); new widgets must too.
  • ĩ Ĩ use narrow tildes with the stroke kept: tildecomb.narrow (76 %, 80 % condensed) on ĩ, tildecomb.narrow.case (60–70 %) on Ĩ, which overhangs I by 7 units in Regular and 19 in Bold (the full tilde: 40); decomposed i/I + U+0303 get them in ccmp.
  • i's and i.ss01's stray bottom anchors (upstream's, up to 100 units off the stem in the italic) are gone; they inherit dotless i's, so i + U+0323 draws ị (verify_upstream reports the changed mark positions on i).
  • ₫ is đ over a bar below the baseline, as thick as đ's crossbar and spanning bowl to stem (Noto Sans' construction); it kerns with đ's groups.
  • features/vietnamese.fea (after ccmp.fea) composes decomposed horns, ligates decomposed circumflex/breve + tone into the stacked marks (.case after A E O), and adds the Vietnamese letters to ss01 (round dot below), ss02 (single-storey a) and ss03 (alternate y). hookabovecomb is in @CombiningTopAccents, so i j lose their dot under it. A lookupflag inside a feature block carries over into the following lookups: the lookups after the mark-filtered one reset it with lookupflag 0.
  • Kerning is by group and generated by scripts/kerning.py; the hand-made kerning stays Instrument's. Each letter joins the groups of its closest upstream relative (ấ → â); the horned letters' right-side groups (KO_ohorn, KO_uhorn, KO_Ohorn, KO_Uhorn) carry copies of o u O U's pairs. Where a pair still collides, the accented letter's tone variants at the same mark height leave its group together: KO_a gives up KO_a__top (á à ả ã ă â …) and KO_a__stack (ấ ầ ẩ ẫ ắ …), which inherit every pair of KO_a and get, against the neighbour's group, the smallest kern that opens all their members to 24 units. So every tone of a letter spaces alike. Every added letter also joins its base letter's group on any side where it has none (ũ → KO_u both sides, ữ → KO_uhorn on the right), and copies its base letter's glyph-level pairs: Instrument kerns T u and Y u as glyph pairs, so Tư Tự Từ Tủ and Yư get the same −94/−77 (raised only where a tone collides). These copies, like every glyph pair on an added Vietnamese letter, are generated: kerning.py removes and rewrites them on each run. A Greek or Cyrillic letter that collides with punctuation gets a glyph pair of its own (ν·, (ј), since those letters share Latin groups. Instrument's own exceptions are raised where they govern a colliding pair (T F K V W f '
    • ì, so Tìm clears; Y + è); the originals are kept in the font lib and --reset restores them. Pairs that cannot occur in Vietnamese (two toned letters, a lowercase letter before a capital) are not kerned.

Checks; build.py runs both (not with --fast):

  • scripts/components.py, on the sources: the ink distance between the parts of every accented glyph, in every master and at the instances between them (wght 500/550/600/650 at wdth 75 and 87.5, 500/550/600 at wdth 100): a tone or tilde on top of a breve or circumflex ≥ 30, a tone beside the circumflex ≥ 30, an accent above its letter ≥ 30, a dot below ≥ 24. Also: the stacked acute and grave lean like the master's own within 5°, and no added glyph rises above 1030. --all prints every gap.
  • scripts/pairs.py, on the compiled fonts: every checked letter (the added ones and every accented Vietnamese letter) next to the neighbours it meets in text (ASCII, the untoned Vietnamese letters in both cases, quotes, dashes; a Cyrillic letter also every Cyrillic letter), shaped with HarfBuzz so kerning applies, at the masters and the same instances between them; fails on inks closer than 16 units. --text "…" checks the pairs of a string, --show N lists the tightest.

For native review: the grave's side, the dot of ỵ, the horn's size and the height of the capital stacks.

Adding a glyph

  1. Draw it in all four masters of its designspace (upright and italic are separate designspaces: a new letter needs eight drawings). Same contours, same point counts and order, same start points and directions, same components and anchors in every master; only coordinates differ.
  2. Set the advance, the code point (<unicode hex="0410"/>; none for alternates) and anchors. Combining marks have advance 0.
  3. Make sure each glyphs/contents.plist lists it (any UFO library does this; build.py fails on a .glif that is not listed). Two branches adding different glyphs merge contents.plist cleanly (merge=union in .gitattributes).
  4. Kerning: join the public.kern1.* (left side of a pair, i.e. the glyph's right side) and public.kern2.* (its left side) groups whose shapes it shares — Cyrillic А, В, Е, К, М, Н, О, Р, С, Т, Х and Greek Α, Β, Ε, Ζ, Η, Ι, Κ, Μ, Ν, Ο, Ρ, Τ, Υ, Χ belong in the Latin groups (KO_A, KO_H, KO_O, …); the kern writer splits pairs per script. Groups and kerning live in each master's groups.plist (same in all masters) and kerning.plist (per master).
  5. Features: edit sources/features/<feature>.fea (one file, all 8 masters). A new script needs its languagesystem lines in languagesystems.fea (languagesystem cyrl dflt;, languagesystem cyrl BGR;, languagesystem cyrl SRB;, languagesystem grek dflt;), otherwise kerning and locl never reach it. Add new combining top marks to @CombiningTopAccents in ccmp.fea so i/j lose their dot under them.
  6. build.py (it also checks accent clearance and colliding pairs; scripts/kerning.py kerns the collisions), then proof.py, then scripts/verify_upstream.py (must stay clean: a Latin change shows up by glyph name).

Glyph order, production names and GDEF classes for new glyphs are filled in by build.py; lib.plist does not need editing. A glyph whose Glyphs category is wrong can be overridden with com.schriftgestaltung.Glyphs.category / .subCategory in the glyph's lib.

Greek

Modern monotonic Greek, upright and italic, all masters: Α–Ω, α–ω with ς, the tonos and dialytika letters (Ά Έ Ή Ί Ό Ύ Ώ Ϊ Ϋ, ά έ ή ί ό ύ ώ ϊ ϋ ΐ ΰ), tonos ΄, dialytika-tonos ΅, ano teleia ·, the Greek question mark ; and the numeral signs ʹ ͵ (numeral-greek, lowernumeral-greek); beside them MICRO SIGN µ (micro, the Greek μ) and HORIZONTAL BAR ―, the Greek dialogue dash (horizontalbar, the em dash, with a .case form).

The Greek glyphs are generated. scripts/greek/gen.py draws every one of them into all eight masters from that master's own Latin parts and measured stems, and overwrites their .glif files. Change a Greek glyph in the generator, never in the .glif, or the next run loses the edit:

cd scripts/greek
uv run --with ufoLib2 --with fonttools python gen.py [glyph ...]   # all, or the named glyphs
uv run kern.py [--report]          # Greek kerning groups and pairs, all masters
uv run view.py out.png --masters Bold,CondensedBold --glyphs "beta xi"   # sources, no build
uv run check.py                    # after build.py: pair kerning, substitutions, accent overhang
  • Shared shapes: Α Β Ε Ζ Η Ι Κ Μ Ν Ο Ρ Τ Υ Χ and ο are components of the Latin glyphs; η comes from n, μ from u, κ from k, ι and τ from t, α from the single-storey a with t's tail, ρ σ from o, ν γ from v, λ's long stroke from y's turned over, Γ Δ Λ Π Ξ from F, A, H and E's stems and bars (Δ's base ends flush with the diagonals). Bowls (δ θ φ Φ) are the master's o or O stretched, so they carry Instrument's curve tension, and in the italic its corrected italic o.
  • Drawn shapes (β γ ε ζ ξ ς υ ψ ω Ψ Ω) are skeleton strokes outlined with an elliptical pen using o's vertical and horizontal weights; terminals are cut across the stroke like c's and j's, and the descender hooks of ζ ξ ς taper. Joins are thinned where strokes meet (β's waist and stem joins, δ's neck, ψ and ω's bottoms, φ's bowl where the stem crosses it), more in Bold. Heavy masters lighten small bowls (β's and ε's upper bowls, φ, ψ) and widen the letters whose counters would otherwise close (φ Φ ψ Ψ ω θ in Condensed Bold).
  • ζ and ξ leave their top bar from its right end, the bar ending flush with the stroke; ζ's spine stays diagonal well down and turns late into the bowl, with a small round under the bar in the heavy masters. ε's and ξ's tongue is where the two bowls part, run on a little and cut leaning back, not a bar. β is one stroke: the stem rolls over into the upper bowl, pinches at the waist and the lower bowl runs back into the stem. γ's right arm runs on without a break into a tail that leans a little left, the left arm joining it above the baseline. λ's short leg joins the long stroke a little under half way up, its foot well in, so the base is narrower than Λ's. υ and ω lean their arms in over the top quarter (by a share of the counter, less in Condensed Bold); ω's middle stroke is short, light and set high. Ω's sides turn in below the middle and run, still curving and a little lighter, into the feet.
  • Italic masters are drawn upright and sheared 13°. Strokes are pre-compensated so the shear keeps the upright's weight distribution (a plain shear thickens \ strokes and thins / strokes by about 11%).
  • The tonos is the acute stood up. Over a capital it sits in front of the letter at cap height; Ε Η Ι Ο set the white beside it, Υ and Α (whose sides run away from it) match Ε's mean white, and Ω's arch comes a little closer than Ο. Υ's tonos drops beside the arm, its top under the arm's tip, so it holds the same white as Έ. In ΐ ΰ ΅ the tonos stands up between the dots, narrower and steeper in the heavy masters, with its foot between the dots' tops; the dots keep the dieresis spacing where they can (drawn in, and 8% smaller in Condensed Bold). No accent hangs more than about 36 units past the advance: ϊ ϋ Ϊ Ϋ ΐ ΰ take extra sidebearing where needed.
  • Kerning (scripts/greek/kern.py): each Greek glyph joins the Latin kerning groups of the Latin glyph its sides match (Γ's arm kerns like T's, Δ Λ like A, ρ like p then o, υ ω like o on the right, ι like t, λ like A on the right and l on the left, accented letters like the Latin letter with the same accent). The kern writer splits pairs per script, so Greek and Latin never kern against each other. Where Instrument kerns the Latin analogue as a single glyph (T-u is a glyph pair), the Greek pair gets the same value as a glyph pair, so Τυ Τω Τμ Γω Υω kern like Tu. Where a Greek side is only partly like its analogue the pair is set from the outlines against the analogue's white: ζ ξ before lowercase take c's white plus about 40 units (their body sits well left of the bar and hook); υ ω before ν γ χ take u's white before v and x; Γ's long arm closes over x-height letters to the white T's arm keeps over the same letter; an x-height bar (π σ on the right, π τ on the left) keeps at least the white between two t's bars, so πτ never reads as one bar. A collision guard then opens any Greek letter pair whose outlines come closer than nearly all Latin pairs do in that master, and a punctuation guard keeps a Greek letter at least 70% as far from brackets, quotes and figures as its Latin analogue ((ζ no longer gets (c's kern). Deliberate exceptions: φχ φλ λρ 0, T-like arms before a capital with tonos +20 to +40, opening brackets, quotes and guillemets before a capital with tonos −45 (the tonos hangs into the space: «Έκθεση, (Άδεια).
  • Features (features/greek.fea): languagesystem grek dflt; the Greek question mark follows the capitals under case; ss01 (round dots) covers ϊ ϋ Ϊ Ϋ ΐ ΰ ΅, the ano teleia and the question mark; and locl for Greek gives U+00B7 MIDDLE DOT the ano teleia's form when it follows a Greek letter directly, since NFC turns U+0387 into U+00B7 and Greek keyboards type it. A middle dot with a space before it is a separator (Αρχεία · Ρυθμίσεις, 12 MB · 3 λεπτά) and stays centred. case also covers ―. Dropping the tonos in all-caps text (and adding the dialytika it can call for) is left to the text layer's Greek uppercasing.

Editing

UFO is a plain-text format: any UFO editor works (Fontra, RoboFont, FontForge, Glyphs.app's UFO import), as do scripts with ufoLib2 and hand-edited .glif XML. Keep coordinates integral. glyphsLib.filters.eraseOpenCorners runs at build time, so Glyphs-style open corners are fine.

Cyrillic

Coverage: Russian, Ukrainian (Ґ Є І Ї), Belarusian (Ў), Bulgarian, Serbian and Macedonian (Ђ Ѓ Ѕ Ј Љ Њ Ћ Ќ Ѝ Џ, Ѐ), Kazakh (Ә Ғ Қ Ң Ө Ұ Ү Һ) and №, upright and italic, in all eight masters (134 glyphs). proof.py --set cyrillic shows all of them.

  • Shared forms are components of the Latin glyphs: А В Е К М Н О Р С Т Х, а е о р с у х, Ѕ І Ј Ү, ѕ і ј һ, and Ё Ѐ ё ѐ reuse the Latin composites' offsets.
  • Native forms are built by scripts/cyrillic/construct.py from the same master's Latin drawings, never by plain scaling. Curved parts are either scaled as a whole (an affine map keeps every curve's tension) or redrawn from numbers measured off one Latin glyph of that master (parts.py):
    • the Ь bowl (Б Ь Ъ Ы Љ Њ, and at x-height ь ъ ы љ њ я) is P's bowl redrawn at the letter's size with P's bars, side and curve tensions; capitals use P's own height, lowercase bowls are about 60% of the x-height, a little taller and with lighter bars in the bold masters so the counter stays open. The counter's left edge is the stem's inner edge;
    • Л л Љ љ have a vertical left leg whose foot is a short J tail (parts.foot_leg: J's bottom-stroke weight, turn proportions, terminal rise and handles, remapped segment by segment), reaching about one light-master stem left of the leg in every master;
    • Д д are two square stems on a base that runs past them and descends at both ends, the left stem meeting the base square as Ц's do; counter and overhang are shares of H's or n's counter, so Д keeps H's width and д n's in every master;
    • Ђ ђ are Ћ ћ whose right leg runs on into J's or j's tail as one contour (Ђ's hook ends about half its counter clear of the stem);
    • м is M's construction redrawn at x-height with M's proportion to H, M's diagonal weight, and the vertex 36 units up in every master;
    • я is R's bowl and leg mirrored, the leg at R's stroke thickness leaving the bowl 0.3 of its width out from the stem;
    • Ю ю and Ф ф narrow O, o and p's bowls as a whole (outer and counter each scaled); in the bold masters the rings and bowls are drawn from a lighter O, o, p or q, so that next to the full stem Ю ю keep 0.85 of O's or o's counter and each half of Ф keeps 0.45–0.49 of O's. Ф is 1.07 of O's width in every master (and grows with weight as O does);
    • в is B zone-mapped to x-height, with bars and sides a little lighter in the bold masters (the middle bar at most e's crossbar), widened until its upper counter is 0.72 of n's counter (VE_UPPER); ы keeps 0.8 of ь's counter;
    • Э Є э є are C or c whose terminals run as far round as C's and c's, cut back along their own curves only where they would come within 0.4 of the bar's thickness of it vertically; the bar sits inside the counter and stops 0.6 of its thickness clear of the terminal tips;
    • Ґ ґ lengthen Г's arm where needed so it runs free for 0.7 of a stem between stem and upturn;
    • б is o with a straight stroke rising into f's top curve, run on as a level flag;
    • ї Ї get their own dieresis: dots 0.92 (light) to 0.7 (bold) of the Latin dieresis' with a gap of 0.55 of a dot between them, overhanging the advance by at most a quarter of that gap, so ї keeps і's width;
    • Һ is H wide, with h's arch zone-mapped so its shoulder tops out at 0.6 of the cap height (the leg keeps its weight, the arch its thickness). И Я Э are mirrored N, R, C, З the 3, Ч ч u's bowl, Ж ж K's and k's arms, У the Y arms, Ц Щ Џ Қ Ң tails to −160. Overhangs and gaps that should not grow with the stems (tails, strokes, the Ю gap, Л's foot) are measured in the light master's stem. Italic masters are slanted back upright (13° about y = 251), constructed and slanted again. A mirrored italic curve would lean backwards, so the italic Э э Я and ∂ mirror the upright master's C, c, R and 6 and are slanted as a whole, and the italic ф takes its left bowl from q. Slanted as a whole, a leg running down to the left lies flatter than upright, so the italic Я я have their foot moved right by the slant over the leg's height and keep the upright angle; the condensed italic Ж spreads its outer arm ends 12–26 units so they keep a diagonal.
  • Checks: before writing, construct.py checks every glyph it built in every master and writes nothing if one fails (--check only reports, --force writes anyway): no curve tighter than Instrument's own (tension above 0.81, 0.86 in the condensed masters), no handle outside its segment's box (upright; italic: vertically only), counters probed along scanlines against the n, H, o or O counter of that master (PROBES), ђ has its bar, ї Ї keep the gap inside their dieresis and overhang the advance by no more than they may; no contour crosses itself (no area wound against the contour's direction; Instrument's own overlapping strokes wind the same way and pass); lowercase stems end at 510 and capital stems at 720; in the italic no curve side leans backwards; Ґ ґ keep their free arm; and every letter widens from the light to the bold master within 8% of its Latin model (n or H, or the Latin letters it is made of, GROWTH_REF). Running the script rewrites those glyphs (and the Cyrillic kerning), so refine a glyph in the script, or stop passing it to the script once it is edited by hand.
  • Italic uses the cursive forms: и й ѝ п т = u ŭ ù n m, ц ш щ on u, д = ∂ (the 6 mirrored).
  • locl (features/cyrillic.fea): Bulgarian (BGR) д ж и й ѝ к л п т ц ш щ ю (*.loclBGR: g-shaped д, ascending ж and ю, u n m shapes, Λ л). These follow the Bulgarian convention and switch on for every lang=bg text; they stay on locl (not behind a stylistic set) because a Bulgarian UI is the one place they belong, but a Bulgarian reader has not approved them yet: if they are rejected, delete the BGR block; Serbian and Macedonian (SRB, MKD) б г ѓ д п т (*.loclSRB), which differ only in the italic (δ-shaped б, ī and ī with acute, g-shaped д, n̄, m̄; the macrons a little lighter than i's dot, the one over ī at least 2.2 stems wide so it is not read as a dot, the ones over n̄ m̄ inset 0.3 of a stem). The upright Serbian б keeps the common form. The same file makes і and ј lose their dot under a top mark. Bulgarian з keeps the common form until a Bulgarian reader signs off a variant.
  • Anchors: every native letter has top and bottom, so stress marks (а́ е́ и́ …) and the precomposed letters attach through mark.
  • Kerning: look-alikes sit in their Latin base glyph's groups; other Cyrillic sides join the Latin group with the same profile (Г Ґ Ѓ in KO_T, У Ў in KO_Y, Ж in KO_K/KO_X, Д Ц Щ Ы on the right in KO_H, …). Sides without a Latin counterpart use Cyrillic groups whose values are taken, per master, from a Latin row or column: KO_Softsign-cy (the right of Ь Ъ Љ Њ: 0.55 × L's row before Т У Ч V Y and every quote group, which tuck in over the low bowl, B's row otherwise), KO_n-cy (left stems after Т Г У, from n; ч joins it), KO_El-cy (Д Л Љ: 0.4 × A–O after A, else 0.25 × KO_A), KO_Ya-cy (Я: 0.73 × O's column after T Y V W and quotes, nothing else, since its leg runs out at the foot), KO_el-cy/KO_de-cy (л љ д, from n; the italic ∂ from e), KO_te-cy (т: r upright, n italic). Some sides tuck in like their Latin source in one part and must not in another: б ф and the italic ∂ are round below and rise into an ascender or flag; я Я ж reach out at the foot; Қ қ run a tail out. They get their own groups (KO_be-cy, KO_ef-cy, KO_de-cy, KO_ya-cy, KO_Ya-cy, KO_zhe-cy, KO_Kadescender-cy, KO_kadescender-cy) whose values come from the Latin source (o, e, x, K, k) but are held off (FLOOR1, FLOOR2 in kern.py), in the band of heights where that part sits, so that every Cyrillic letter or punctuation mark beside them keeps a share of the white it keeps there from a Latin reference at Instrument's own kerning: 0.55 of what it keeps from h's ascender beside б ф ∂ (l on their right), 0.8 of what it keeps from x's or X's foot beside я Я ж, all of it beside Қ қ's tail as from K and k; and never less than 28 units. White wider than between two n's needs no holding off. scripts/cyrillic/ink.py measures this on the outlines construct.py writes, in every master. So Тб is −19 in Regular where Те is −106, and the italic Тд −7. ї next to ї or і gets a pair that keeps the space between their dots 1.5 times the space inside ї's dieresis; Ї Ї likewise. What still touches after that (ј under the tails of Ц Щ Д Ң, ї beside ђ ћ, the condensed Тў) is opened by scripts/kerning.py with glyph pairs.

The fallback check is proof.py --set cyrillic --fallback onest (Onest with ss02). Native readers have not reviewed these yet. Left as they are, on purpose: the bold м keeps M's own joins (no notch, as Instrument's M has none), the italic ф's q bowls meet the stem as q's do, and a lowercase letter before a capital (ґТ) is not kerned, as nowhere else in the font.

Rebasing on a newer upstream

scripts/import_upstream.py --force regenerates sources/ from UPSTREAM_COMMIT; it overwrites every master. Bump the commit, run it on a branch, then re-apply the Tilo Sans glyphs from git history. It also resets two artefacts of today's glyphsLib to what upstream shipped: the 0.13° slant on the italic i/j components, and base classes on spacing accents.

Releases and how Tilo consumes them

Tilo does not build this repository. It vendors the default build's binaries (fonts/default/*.ttf) in its UI toolkit's font assets (crates/sdk/ui-core/assets/fonts/ in tilo/tilo) and records, in its font catalog (crates/sdk/font-catalog/manifest.toml and the assets' PROVENANCE.md), the tag and commit here they were built from and each file's SHA-256. A change here reaches Tilo only when a release is copied in.

A release is a tag vMAJOR.MINOR matching the fonts' version (head.fontRevision, name ID 5), e.g. v1.001. Build it reproducibly: build.py.lock fixes every build dependency, and SOURCE_DATE_EPOCH set to the commit time fixes head.modified, the only part of the output that otherwise follows the clock:

git checkout v1.001
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) uv run build.py
uv run scripts/shaping_tests.py
sha256sum fonts/default/*.ttf
release commit time (SOURCE_DATE_EPOCH) fonts/default/ SHA-256
v1.001 1791039400 TiloSans[wdth,wght].ttf 140edddcc0ee4953e5120ac208757e88f48c786788bfdd092d2dba1bb49abef4, TiloSans-Italic[wdth,wght].ttf a4ca118abf92d41fa93f46a6833436bea98869eada3f07634a3594104dfad09c
v1.002 1791160331 TiloSans[wdth,wght].ttf 903aac545b421e1c050552980762613e9373b4a01b491e45c9737f42525888d6, TiloSans-Italic[wdth,wght].ttf 72d98aff60fd0447e5081c48895b08656aa9f2b219c0698866e97f3cd20c5743

To ship a release in Tilo: copy fonts/default/*.ttf and OFL.txt (as LICENSES/Tilo-Sans-OFL.txt) into the toolkit's font assets, update both copies of the manifest, PROVENANCE.md and LICENSES/FONT-COPYRIGHT-NOTICES.txt there with the tag, commit and hashes, and run its tools/font-catalog.py check. Regenerate the toolkit's feature test face with scripts/toolkit_fixture.py from the same commit and copy it to crates/sdk/ui-core/tests/fixtures/fonts/.