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

159 lines
6.1 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.
- 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 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)
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()