Files
native/scripts/build-reading-fonts.py
T

198 lines
7.7 KiB
Python

"""Build the reading fonts the app bundles, from the webmail's copies.
Settings > Accessibility > Font offers the webmail's three reading fonts
(app/accessibility.css): Atkinson Hyperlegible Next, Lexend and OpenDyslexic,
all under the SIL Open Font License. The webmail ships them as woff2 subsets
(latin and latin-ext). The app needs them twice:
- Android draws UI text with fonts from android/app/src/main/assets/fonts,
looked up by family name, with a `_bold` file for bold text. Android can't
read woff2 or variable weights there, so each family becomes a static
Regular (400) and Bold (700) TTF, latin and latin-ext merged. iOS bundles
the same TTFs (expo-font in app.config.js) and finds them by the family
name in their name table.
- A plain-text mail body renders in a WebView, which reads the woff2 files
directly; they go into src/theme/reading-fonts-data.ts as base64 so the
sandboxed page (CSP font-src data:) can load them without file access.
Run from the app root with the webmail checked out two levels up (or
WEBMAIL_DIR set to it):
python scripts/build-reading-fonts.py
Needs fontTools with brotli (pip install fonttools brotli).
"""
import base64
import io
import os
import re
import sys
from fontTools.merge import Merger
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The webmail checkout (the app lives in its repos/react-native); WEBMAIL_DIR
# points elsewhere, e.g. from a separate worktree.
WEBMAIL_FONTS = os.path.join(os.environ.get('WEBMAIL_DIR') or os.path.join(ROOT, '..', '..'), 'app', 'fonts')
ASSETS = os.path.join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'fonts')
DATA = os.path.join(ROOT, 'src', 'theme', 'reading-fonts-data.ts')
# Android family name -> the webmail's woff2 subsets (latin first).
FAMILIES = {
'AtkinsonHyperlegibleNext': {
'variable': ['atkinson-hyperlegible-next-latin-wght-normal.woff2',
'atkinson-hyperlegible-next-latin-ext-wght-normal.woff2'],
},
'Lexend': {
'variable': ['lexend-latin-wght-normal.woff2', 'lexend-latin-ext-wght-normal.woff2'],
},
'OpenDyslexic': {
400: ['opendyslexic-latin-400-normal.woff2'],
700: ['opendyslexic-latin-700-normal.woff2'],
},
}
# The woff2 files a mail body loads: (export name, css family, weight range, files).
WEB_FACES = [
('ATKINSON', 'Atkinson Hyperlegible Next', '200 800', FAMILIES['AtkinsonHyperlegibleNext']['variable']),
('LEXEND', 'Lexend', '100 900', FAMILIES['Lexend']['variable']),
('OPENDYSLEXIC', 'OpenDyslexic', '400', FAMILIES['OpenDyslexic'][400]),
]
def load(name):
return TTFont(os.path.join(WEBMAIL_FONTS, name))
def static_instance(name, weight):
font = load(name)
if 'fvar' in font:
font = instantiateVariableFont(font, {'wght': weight}, updateFontNames=False)
font.flavor = None
return font
def merge(fonts, out_path):
paths = []
for i, font in enumerate(fonts):
tmp = out_path + f'.part{i}.ttf'
font.save(tmp)
paths.append(tmp)
if len(paths) == 1:
os.replace(paths[0], out_path)
return
merged = Merger().merge(paths)
merged.save(out_path)
for p in paths:
os.remove(p)
def name_static_style(path, weight):
"""Name a static instance of a variable font after its weight.
The instance keeps the variable font's default names, so the bold file
said "Regular" and carried the regular file's PostScript name. Android
picks the file by its name and doesn't care, but iOS registers the fonts
by their names (expo-font, UIAppFonts) and would see the same font twice.
"""
font = TTFont(path)
style = 'Bold' if weight >= 700 else 'Regular'
name = font['name']
family = name.getDebugName(1)
old_ps = name.getDebugName(6)
ps = f"{old_ps.split('-')[0]}-{style}"
for rec in name.names:
if rec.nameID == 2:
rec.string = style
elif rec.nameID == 3:
rec.string = rec.toUnicode().replace(old_ps, ps)
elif rec.nameID == 4:
rec.string = f'{family} {style}'
elif rec.nameID == 6:
rec.string = ps
# Only the typographic names the static font has left: drop the
# variable font's preferred family/subfamily if the instancer kept them.
name.removeNames(nameID=16)
name.removeNames(nameID=17)
os2 = font['OS/2']
os2.usWeightClass = weight
# fsSelection: bit 5 BOLD, bit 6 REGULAR; the other bits stay.
os2.fsSelection = (os2.fsSelection & ~0x60) | (0x20 if style == 'Bold' else 0x40)
font['head'].macStyle = (font['head'].macStyle & ~0x1) | (0x1 if style == 'Bold' else 0)
font.save(path)
def build_assets():
os.makedirs(ASSETS, exist_ok=True)
for family, spec in FAMILIES.items():
for weight, suffix in ((400, ''), (700, '_bold')):
files = spec.get('variable') or spec[weight]
fonts = [static_instance(f, weight) for f in files]
out = os.path.join(ASSETS, family + suffix + '.ttf')
merge(fonts, out)
if spec.get('variable'):
name_static_style(out, weight)
print('wrote', os.path.relpath(out, ROOT), os.path.getsize(out))
def unicode_ranges():
"""woff2 file name -> its unicode-range in the webmail's app/accessibility.css."""
css_path = os.path.join(WEBMAIL_FONTS, '..', 'accessibility.css')
with open(css_path, encoding='utf-8') as fh:
css = fh.read()
out = {}
for block in re.findall(r'@font-face\s*\{([^}]*)\}', css):
src = re.search(r"fonts/([^\"')]+\.woff2)", block)
rng = re.search(r'unicode-range:\s*([^;]+);', block)
if src:
out[src.group(1)] = rng.group(1).strip() if rng else ''
return out
def build_data():
ranges = unicode_ranges()
lines = [
"// Generated by scripts/build-reading-fonts.py from the webmail's",
'// app/fonts (SIL Open Font License 1.1, see',
'// android/app/src/main/assets/fonts/OFL.txt). Do not edit by hand. The',
'// woff2 files a plain-text mail body loads as data: URIs, with the',
"// unicode ranges of the webmail's app/accessibility.css.",
'',
'export interface ReadingFontFace {',
' /** CSS font-family name, as the webmail uses it. */',
' family: string;',
' /** font-weight descriptor (a range for variable fonts). */',
' weight: string;',
' /** One per unicode subset: base64 woff2 and its unicode-range ("" = all). */',
' subsets: Array<{ woff2: string; unicodeRange: string }>;',
'}',
'',
]
for export, family, weight, files in WEB_FACES:
lines.append(f'export const {export}: ReadingFontFace = {{')
lines.append(f" family: '{family}',")
lines.append(f" weight: '{weight}',")
lines.append(' subsets: [')
for name in files:
if name not in ranges:
sys.exit(f"{name} is not in the webmail's accessibility.css")
with open(os.path.join(WEBMAIL_FONTS, name), 'rb') as fh:
blob = base64.b64encode(fh.read()).decode('ascii')
lines.append(' {')
lines.append(f" unicodeRange: '{ranges[name]}',")
lines.append(f" woff2: '{blob}',")
lines.append(' },')
lines.append(' ],')
lines.append('};')
lines.append('')
with open(DATA, 'w', encoding='utf-8', newline='\n') as fh:
fh.write('\n'.join(lines))
print('wrote', os.path.relpath(DATA, ROOT), os.path.getsize(DATA))
if __name__ == '__main__':
if not os.path.isdir(WEBMAIL_FONTS):
sys.exit(f'No webmail fonts at {WEBMAIL_FONTS}')
build_assets()
build_data()