"""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()