mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: use the reading fonts for the app's own text on ios
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
Generated
+15
-14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user