Files
zeekay e738240139 IBM Plex Sans Arabic beside the Noto companions; 1.9.6
The Arabic table now holds a third companion, and every companion must draw
every letter the Arabic-script catalogs in @hanzo/i18n write ("needs":
Arabic, Persian, Dari, Urdu, Pashto, Sindhi, Sorani Kurdish, Uyghur,
Shahmukhi Punjabi). arabic.py refuses a face that does not. Measured
against those catalogs, Cairo, Readex Pro and Rubik each miss letters
Pashto, Sindhi, Kurdish or Uyghur write; Noto Kufi, Noto Sans and IBM Plex
Sans Arabic miss none.

Plex ships as IBM published it, one woff2 per weight, byte for byte: the
OFL reserves "Plex", and a subset is a Modified Version that may not carry
it. So a face is either cut (the Noto pair, as before) or whole, and a
face lists its files. The default must still be one variable file over
Zen's 100-900, since a static face under 'Zen' wins the weight match for
Latin; Plex is offered under its own name only.

The publish check reads the files to expect from the table.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-29 23:34:46 -07:00

127 lines
5.3 KiB
Python

"""What makes a face ours, said once.
Two scripts write identity into a binary — `name.py` names the hand-exported Zen
Pixel cuts, `notice.py` puts the upstream line back on the fontmake-built
families — and both need the same answer to "whose is this?". Held in one place
they cannot drift apart, and `check` reads the same answer back.
Run it over a directory to verify rather than mutate:
python3 packages/zen/scripts/identity.py packages/zen/dist/fonts
It exits non-zero on the first face that is not ours, which is why
`copy-npm-fonts` ends with it: the moment a face becomes something the package
would publish is the moment to refuse it.
The check is worth having because the failure it catches is invisible. A face
relabelled family-only — nameID 1 rewritten to Zen while the vendor tag stays
VRCL and nameID 0 names one holder — answers "Zen" to every question a
stylesheet, a grep or a font menu asks. Four repositories in the estate carried
Geist under our name that way. Vendor and notice are where the bytes admit what
they are.
"""
import json
import pathlib
import sys
from fontTools.ttLib import TTFont
UPSTREAM = 'Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)'
OURS = 'Copyright 2026 Hanzo AI, Inc. (https://git.hanzo.ai/hanzoai/font)'
NOTICE = f'{UPSTREAM}\n{OURS}'
VENDOR = 'https://hanzo.ai'
TAG = 'HNZO'
FACES = ('.otf', '.ttf', '.woff2')
# The Arabic companions are the one set of faces here that are NOT ours, and they
# are checked for exactly that: each must still say the family, notice and vendor
# its upstream gave it. Relabelling one as Zen would be the failure this file
# exists to catch, pointed the other way. The table is the one arabic.py builds
# from, so the two cannot disagree about which files are companions.
COMPANIONS = {
f['file']: face
for face in json.loads((pathlib.Path(__file__).resolve().parent / 'arabic.json').read_text())['faces']
for f in face['files']
}
FAMILIES = {'Zen', 'Zen Mono'} | {
f'Zen Pixel {cut}' for cut in ('Circle', 'Grid', 'Line', 'Square', 'Triangle')
}
def complaints(path: pathlib.Path) -> list[str]:
"""Everything wrong with one face's identity. Empty means it is ours."""
f = TTFont(str(path))
out = []
# A static cut outside the four styles a family can group puts its weight in
# nameID 1 — "Zen Thin" — and the family they all share in nameID 16. Where
# the two would say the same thing, 16 is absent. So 16 is the family when
# it is there, and 1 is the family when it is not.
family = f['name'].getDebugName(16) or f['name'].getDebugName(1) or ''
# The variable Zen Pixel carries all five cuts on one axis, so it names the
# family they share rather than any one of them.
if family not in FAMILIES and family != 'Zen Pixel':
out.append(f'family {family!r}')
tag = f['OS/2'].achVendID
if tag != TAG:
out.append(f'vendor {tag!r}')
# Every nameID 0 record, not just the one a reader happens to pick: the
# notice lives in a Windows record and a Mac one, and a face that rewrote
# only the record you read agrees with itself right up until something
# reads the other. OFL §2 binds each copy to the notice, and a .woff2
# travels alone.
notices = [r.toUnicode() for r in f['name'].names if r.nameID == 0]
if not notices:
out.append('no copyright at all')
for holder, who in ((UPSTREAM, 'upstream'), (OURS, 'ours')):
if any(holder not in n for n in notices):
out.append(f'no {who} copyright')
return out
def companion(path: pathlib.Path, face: dict) -> list[str]:
"""Everything wrong with a companion's identity. Empty means it is still theirs."""
f = TTFont(str(path))
out = []
family = f['name'].getDebugName(16) or f['name'].getDebugName(1) or ''
if family != face['family']:
out.append(f'family {family!r}, upstream says {face["family"]!r}')
if f['OS/2'].achVendID == TAG:
out.append(f'vendor {TAG} on a face that is not ours')
notices = [r.toUnicode() for r in f['name'].names if r.nameID == 0]
if not notices or any(face['notice'] not in n for n in notices):
out.append('upstream copyright missing')
if path.parent.name != 'arabic':
out.append('outside fonts/arabic')
return out
def main() -> int:
roots = [pathlib.Path(a) for a in sys.argv[1:]] or [
pathlib.Path(__file__).resolve().parents[1] / 'dist' / 'fonts'
]
seen = 0
bad = []
for r in roots:
for path in sorted(r.rglob('*')):
if path.suffix not in FACES:
continue
seen += 1
face = COMPANIONS.get(path.name)
if said := (companion(path, face) if face else complaints(path)):
bad.append(f' {path.name:<34} {", ".join(said)}')
if not seen:
print(f'no faces under {", ".join(str(r) for r in roots)}', file=sys.stderr)
return 1
if bad:
print(f'{len(bad)} of {seen} faces do not say what they are:', file=sys.stderr)
print('\n'.join(bad), file=sys.stderr)
return 1
print(f'all {seen} faces check out: ours name themselves Zen, tag {TAG}, and carry both '
f'notices; the {len(COMPANIONS)} Arabic companion files keep their own')
return 0
if __name__ == '__main__':
sys.exit(main())