mirror of
https://github.com/hanzoai/font.git
synced 2026-11-02 23:42:52 +00:00
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
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
"""The Arabic companions: fetched from upstream, checked, shipped.
|
|
|
|
Zen draws Latin and Cyrillic and no Arabic, so a page in Arabic used to fall back
|
|
to whatever face each browser picked. The companions in `arabic.json` fill that
|
|
gap. They are NOT ours and are never renamed: each keeps its own family, notice
|
|
and vendor, which is what `identity.py` checks them for. Each file is pinned by
|
|
commit and checksum, so a rebuild cannot quietly change what ships.
|
|
|
|
A companion must draw every letter in `needs`: the Arabic-block characters that
|
|
every Arabic-script catalog @hanzo/i18n ships writes (Arabic, Persian, Dari, Urdu,
|
|
Pashto, Sindhi, Sorani Kurdish, Uyghur, Shahmukhi Punjabi), plus both sets of
|
|
digits. A face missing one sets that letter in another face, mid-word, where the
|
|
two do not join. Cairo, Readex Pro and Rubik were measured and each misses letters
|
|
Pashto, Sindhi, Kurdish or Uyghur write, so none is here.
|
|
|
|
What happens to a face, and why:
|
|
|
|
- `cut` faces are cut to `range`. Every axis but weight is pinned (Noto Sans
|
|
Arabic's width, at 100): the page asks for a weight and nothing else, and a
|
|
second axis doubles the bytes. The cmap keeps the Arabic blocks plus
|
|
U+200C-200F, so the joiners shape inside the face; the joiners stay OUT of the
|
|
declared unicode-range, or an English page with an emoji ZWJ sequence would
|
|
fetch the Arabic face for one invisible character. Layout features are kept
|
|
whole, and hinting goes: the subset is drawn by a browser that ignores it.
|
|
- A face that is NOT cut ships as its foundry published it. IBM Plex Sans Arabic
|
|
is one: the OFL reserves the name "Plex", a subset is a Modified Version, and a
|
|
Modified Version may not carry a Reserved Font Name. IBM's own woff2 is used
|
|
byte for byte, and the declared unicode-range still keeps its Latin unused.
|
|
|
|
Run from the repository root (needs the network once per file):
|
|
|
|
python3 packages/zen/scripts/arabic.py
|
|
"""
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
import urllib.request
|
|
|
|
from fontTools import subset
|
|
from fontTools.ttLib import TTFont
|
|
from fontTools.varLib import instancer
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
TABLE = json.loads((HERE / 'arabic.json').read_text())
|
|
OUT = HERE.parent / 'dist' / 'fonts' / 'arabic'
|
|
JOINERS = range(0x200C, 0x2010)
|
|
|
|
|
|
def fetch(url: str, sha256: str) -> bytes:
|
|
with urllib.request.urlopen(url, timeout=60) as r:
|
|
data = r.read()
|
|
got = hashlib.sha256(data).hexdigest()
|
|
if got != sha256:
|
|
raise SystemExit(f'{url}\n sha256 {got}\n wanted {sha256}')
|
|
return data
|
|
|
|
|
|
def codepoints(spec: str) -> set[int]:
|
|
out: set[int] = set()
|
|
for part in spec.split(','):
|
|
lo, _, hi = part.strip().removeprefix('U+').partition('-')
|
|
out.update(range(int(lo, 16), int(hi or lo, 16) + 1))
|
|
return out
|
|
|
|
|
|
NEEDS = codepoints(TABLE['needs'])
|
|
|
|
|
|
def cut(face: dict, f: dict) -> bytes:
|
|
font = TTFont(io.BytesIO(fetch(f['source'], f['sha256'])))
|
|
if face['pin']:
|
|
# Through bytes and back: the instancer leaves gvar half-loaded, and the
|
|
# subsetter then asks it for glyphs it no longer has.
|
|
pinned = io.BytesIO()
|
|
instancer.instantiateVariableFont(font, face['pin']).save(pinned)
|
|
font = TTFont(io.BytesIO(pinned.getvalue()))
|
|
axes = {a.axisTag: (a.minValue, a.maxValue) for a in font['fvar'].axes}
|
|
lo, hi = (float(w) for w in f['weight'].split())
|
|
if axes != {'wght': (lo, hi)}:
|
|
raise SystemExit(f"{face['family']}: axes {axes}, declared wght {f['weight']}")
|
|
options = subset.Options()
|
|
options.layout_features = ['*']
|
|
options.name_IDs = ['*']
|
|
options.name_languages = ['*']
|
|
options.name_legacy = True
|
|
options.hinting = False
|
|
options.flavor = 'woff2'
|
|
sub = subset.Subsetter(options)
|
|
sub.populate(unicodes=codepoints(TABLE['range']) | set(JOINERS))
|
|
sub.subset(font)
|
|
buf = io.BytesIO()
|
|
font.flavor = 'woff2'
|
|
font.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def whole(face: dict, f: dict) -> bytes:
|
|
data = fetch(f['source'], f['sha256'])
|
|
font = TTFont(io.BytesIO(data))
|
|
if font.flavor != 'woff2':
|
|
raise SystemExit(f"{f['file']}: {face['family']} ships uncut, so its source must already be woff2")
|
|
if font['OS/2'].usWeightClass != int(f['weight']):
|
|
raise SystemExit(f"{f['file']}: weight class {font['OS/2'].usWeightClass}, declared {f['weight']}")
|
|
return data
|
|
|
|
|
|
def main() -> int:
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
for name, lic in TABLE['licenses'].items():
|
|
(OUT / name).write_bytes(fetch(lic['source'], lic['sha256']))
|
|
for face in TABLE['faces']:
|
|
for f in face['files']:
|
|
data = (cut if face['cut'] else whole)(face, f)
|
|
missing = NEEDS - set(TTFont(io.BytesIO(data)).getBestCmap())
|
|
if missing:
|
|
letters = ''.join(chr(c) for c in sorted(missing))
|
|
raise SystemExit(f"{f['file']}: {face['family']} does not draw {letters}")
|
|
(OUT / f['file']).write_bytes(data)
|
|
print(f"{f['file']:<36} {len(data) / 1024:6.1f} KB {face['family']} {f['weight']}")
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|