feat: use the reading fonts for the app's own text on ios

This commit is contained in:
Linus Rath
2026-10-31 16:03:54 +01:00
parent 0ae33f3a07
commit cfca6b9c24
10 changed files with 161 additions and 27 deletions
Binary file not shown.
+20
View File
@@ -110,6 +110,26 @@ module.exports = {
'./plugins/ios-client-cert',
// The iOS inbox widget (WidgetKit); src/widgets/ios.ts feeds it.
['./plugins/ios-widget', { teamId: APPLE_TEAM_ID }],
// The reading fonts (Settings > Accessibility > Font) for the app's
// text on iOS, found by the family names in the files
// (src/theme/reading-fonts.ts). iOS only: Android reads the same files
// from its hand-maintained assets/fonts, where the plugin would copy
// them onto themselves.
[
'expo-font',
{
ios: {
fonts: [
'AtkinsonHyperlegibleNext.ttf',
'AtkinsonHyperlegibleNext_bold.ttf',
'Lexend.ttf',
'Lexend_bold.ttf',
'OpenDyslexic.ttf',
'OpenDyslexic_bold.ttf',
].map((file) => `./android/app/src/main/assets/fonts/${file}`),
},
},
],
// Bundles the Involved sound calendar reminders (src/lib/calendar-
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
// Android has it in res/raw.
+15 -14
View File
@@ -23,6 +23,7 @@
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.24",
"expo-font": "~14.0.12",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.11",
@@ -5308,6 +5309,20 @@
"react-native": "*"
}
},
"node_modules/expo-font": {
"version": "14.0.12",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz",
"integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==",
"license": "MIT",
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-haptics": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
@@ -5842,20 +5857,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-font": {
"version": "14.0.12",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz",
"integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==",
"license": "MIT",
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
+1
View File
@@ -32,6 +32,7 @@
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.24",
"expo-font": "~14.0.12",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.11",
+40 -1
View File
@@ -8,7 +8,9 @@ all under the SIL Open Font License. The webmail ships them as woff2 subsets
- 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.
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.
@@ -85,6 +87,41 @@ def merge(fonts, out_path):
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():
@@ -93,6 +130,8 @@ def build_assets():
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))
+6
View File
@@ -82,6 +82,12 @@
"license": "MIT",
"url": "https://docs.expo.dev/versions/latest/sdk/filesystem/"
},
{
"name": "expo-font",
"version": "14.0.12",
"license": "MIT",
"url": "https://docs.expo.dev/versions/latest/sdk/font/"
},
{
"name": "expo-haptics",
"version": "15.0.8",
+68 -4
View File
@@ -3,7 +3,8 @@
// the bundled Android files, the @font-face rules a mail body gets, and the
// wrapper that puts the font on the app's Text.
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
// The wrapper subscribes with useSyncExternalStore; outside a React render
@@ -31,7 +32,31 @@ import {
import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store';
import { presetChanges } from '../../lib/accessibility-presets';
const ASSETS = join(__dirname, '..', '..', '..', 'android', 'app', 'src', 'main', 'assets', 'fonts');
const ROOT = join(__dirname, '..', '..', '..');
const ASSETS = join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'fonts');
/** The Windows-platform names of a TTF's name table, by name id. */
function ttfNames(file: string): Record<number, string> {
const buf = readFileSync(file);
const numTables = buf.readUInt16BE(4);
let offset = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
if (buf.toString('latin1', rec, rec + 4) === 'name') offset = buf.readUInt32BE(rec + 8);
}
if (offset < 0) throw new Error(`${file} has no name table`);
const count = buf.readUInt16BE(offset + 2);
const strings = offset + buf.readUInt16BE(offset + 4);
const out: Record<number, string> = {};
for (let i = 0; i < count; i++) {
const rec = offset + 6 + i * 12;
if (buf.readUInt16BE(rec) !== 3) continue;
const start = strings + buf.readUInt16BE(rec + 10);
const bytes = Buffer.from(buf.subarray(start, start + buf.readUInt16BE(rec + 8)));
out[buf.readUInt16BE(rec + 6)] = bytes.swap16().toString('utf16le');
}
return out;
}
describe('reading fonts', () => {
it('offers the webmail fonts under its names and keys', () => {
@@ -58,9 +83,48 @@ describe('reading fonts', () => {
it('names a family for the app text only where the fonts are bundled', () => {
expect(uiFontsBundled('android')).toBe(true);
expect(uiFontFamily('opendyslexic', 'android')).toBe('OpenDyslexic');
expect(uiFontFamily('atkinson', 'android')).toBe('AtkinsonHyperlegibleNext');
expect(uiFontFamily('default', 'android')).toBeNull();
// iOS reports an unknown family as an error.
expect(uiFontFamily('opendyslexic', 'ios')).toBeNull();
// iOS finds the font by the family name in the file, not the file name.
expect(uiFontsBundled('ios')).toBe(true);
expect(uiFontFamily('atkinson', 'ios')).toBe('Atkinson Hyperlegible Next');
expect(uiFontFamily('lexend', 'ios')).toBe('Lexend');
expect(uiFontFamily('default', 'ios')).toBeNull();
expect(uiFontsBundled('web')).toBe(false);
expect(uiFontFamily('lexend', 'web')).toBeNull();
});
it('gives each bold file its own name under the family iOS looks up', () => {
for (const font of APP_FONTS) {
if (font === 'default') continue;
const family = appFontAssetFamily(font);
const regular = ttfNames(join(ASSETS, `${family}.ttf`));
const bold = ttfNames(join(ASSETS, `${family}_bold.ttf`));
expect(regular[1]).toBe(appFontFamilyName(font));
expect(bold[1]).toBe(appFontFamilyName(font));
expect(bold[2]).toBe('Bold');
// Two files under one PostScript name register as one font on iOS.
expect(bold[6]).not.toBe(regular[6]);
expect(bold[4]).not.toBe(regular[4]);
}
});
it('bundles every font file for iOS through expo-font', () => {
const config = createRequire(__filename)(join(ROOT, 'app.config.js')).expo;
const entry = config.plugins.find((p: unknown) => Array.isArray(p) && p[0] === 'expo-font');
const files: string[] = entry[1].ios.fonts;
for (const font of APP_FONTS) {
if (font === 'default') continue;
const family = appFontAssetFamily(font);
for (const name of [`${family}.ttf`, `${family}_bold.ttf`]) {
const listed = files.find((f) => f.endsWith(`/${name}`));
expect(listed, name).toBeDefined();
expect(existsSync(join(ROOT, listed!))).toBe(true);
}
}
// Android keeps its own assets/fonts; the plugin must not copy into it.
expect(entry[1].fonts).toBeUndefined();
expect(entry[1].android).toBeUndefined();
});
it('puts the font first in a mail body and keeps the system stack behind it', () => {
+2 -2
View File
@@ -1,7 +1,7 @@
// Installs the reading font for the app's text (./reading-font-text). Imported
// first thing by index.ts, before any screen module copies `Text` out of
// react-native. Android only: iOS has no bundled reading fonts yet
// (./reading-fonts). The settings store is required after the wrapping, so
// react-native. Android and iOS bundle the fonts (./reading-fonts); on other
// platforms nothing is wrapped. The settings store is required after the wrapping, so
// nothing it loads can copy the plain Text first.
/* eslint-disable @typescript-eslint/no-require-imports */
+9 -6
View File
@@ -6,10 +6,10 @@
// too; HTML mail keeps the sender's fonts.
//
// The app's text: Android finds the fonts in android/app/src/main/assets/
// fonts by family name (scripts/build-reading-fonts.py), and the Text patch in
// ./reading-font-text applies the family. iOS needs them registered in the
// app bundle (expo-font's config plugin), which the app doesn't have yet, so
// there the setting only reaches mail bodies.
// fonts by the file name (scripts/build-reading-fonts.py), and the Text patch
// in ./reading-font-text applies the family. iOS bundles the same files
// (expo-font's config plugin in app.config.js) and finds them by the family
// name in their name table, so there the family is the font's real name.
//
// A mail body renders in a sandboxed WebView, which gets the woff2 files as
// data: URIs (./reading-fonts-data: a few hundred KB of base64 string
@@ -43,15 +43,18 @@ export function appFontAssetFamily(font: AppFont | null | undefined): string | n
/** Whether the platform has the fonts for the app's own text. */
export function uiFontsBundled(platform: string): boolean {
return platform === 'android';
return platform === 'android' || platform === 'ios';
}
/**
* The fontFamily the app's text is drawn in: null for the theme font, and
* where the fonts aren't bundled (an unknown family is an error on iOS).
* Android looks the family up by asset file name, iOS by the family name
* the font registers under.
*/
export function uiFontFamily(font: AppFont | null | undefined, platform: string): string | null {
return uiFontsBundled(platform) ? appFontAssetFamily(font) : null;
if (!uiFontsBundled(platform)) return null;
return platform === 'ios' ? appFontFamilyName(font) : appFontAssetFamily(font);
}
/** The mail body's own font stack (lib/email-html). */