diff --git a/android/app/src/main/assets/fonts/AtkinsonHyperlegibleNext_bold.ttf b/android/app/src/main/assets/fonts/AtkinsonHyperlegibleNext_bold.ttf index da32902a..8c7b8677 100644 Binary files a/android/app/src/main/assets/fonts/AtkinsonHyperlegibleNext_bold.ttf and b/android/app/src/main/assets/fonts/AtkinsonHyperlegibleNext_bold.ttf differ diff --git a/android/app/src/main/assets/fonts/Lexend_bold.ttf b/android/app/src/main/assets/fonts/Lexend_bold.ttf index 3ff5666c..5fa7a1ac 100644 Binary files a/android/app/src/main/assets/fonts/Lexend_bold.ttf and b/android/app/src/main/assets/fonts/Lexend_bold.ttf differ diff --git a/app.config.js b/app.config.js index fc51e255..be945a34 100644 --- a/app.config.js +++ b/app.config.js @@ -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. diff --git a/package-lock.json b/package-lock.json index 38088548..fc237bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c4dea583..ab55c8bd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-reading-fonts.py b/scripts/build-reading-fonts.py index 28e41b3b..a8434bcd 100644 --- a/scripts/build-reading-fonts.py +++ b/scripts/build-reading-fonts.py @@ -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)) diff --git a/src/lib/licences.json b/src/lib/licences.json index 34cc3655..d7e3de6a 100644 --- a/src/lib/licences.json +++ b/src/lib/licences.json @@ -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", diff --git a/src/theme/__tests__/reading-fonts.test.ts b/src/theme/__tests__/reading-fonts.test.ts index 09090162..289479de 100644 --- a/src/theme/__tests__/reading-fonts.test.ts +++ b/src/theme/__tests__/reading-fonts.test.ts @@ -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 { + 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 = {}; + 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', () => { diff --git a/src/theme/reading-font-install.ts b/src/theme/reading-font-install.ts index d6d57ec7..5b0ed8cc 100644 --- a/src/theme/reading-font-install.ts +++ b/src/theme/reading-font-install.ts @@ -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 */ diff --git a/src/theme/reading-fonts.ts b/src/theme/reading-fonts.ts index 7c2f4c73..a2372071 100644 --- a/src/theme/reading-fonts.ts +++ b/src/theme/reading-fonts.ts @@ -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). */