feat: offer the reading fonts, including OpenDyslexic, for the app and plain-text mail

This commit is contained in:
Linus Rath
2026-10-31 14:49:21 +01:00
parent 23b2bf9732
commit 4724c28f53
27 changed files with 821 additions and 15 deletions
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
The reading fonts in this folder (Settings > Accessibility > Font) are
licensed under the SIL Open Font License, Version 1.1, copied below.
AtkinsonHyperlegibleNext*.ttf:
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
(https://github.com/googlefonts/atkinson-hyperlegible-next)
Lexend*.ttf:
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
with Reserved Font Name "RevReading Lexend".
OpenDyslexic*.ttf:
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
The files are built by scripts/build-reading-fonts.py from the woff2 files
the webmail bundles (from Fontsource, https://fontsource.org): the variable
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
instances, OpenDyslexic unchanged apart from the woff2 decompression.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+3
View File
@@ -1,3 +1,6 @@
// First: wraps react-native's Text for the reading font before any module
// copies it (src/theme/reading-font-text).
import './src/theme/reading-font-install';
import { createElement } from 'react';
import { AppRegistry } from 'react-native';
import { registerRootComponent } from 'expo';
+10
View File
@@ -1384,6 +1384,16 @@
"appearance": "Darstellung",
"general": "Allgemein",
"privacy": "Datenschutz & Sicherheit"
},
"accessibility": {
"presets": {
"dyslexia": {
"description_mobile": "Atkinson Hyperlegible als Schrift, auch für Nachrichten als reiner Text."
}
},
"font": {
"description_ios": "Atkinson Hyperlegible und Lexend sind so gestaltet, dass sich die Buchstaben gut unterscheiden; OpenDyslexic hilft manchen Menschen mit Legasthenie. Auf diesem Gerät gilt die Schrift für Nachrichten als reiner Text."
}
}
},
"sharing": {
+7 -1
View File
@@ -963,10 +963,16 @@
"updates": "Updates"
},
"accessibility": {
"font": {
"description_ios": "Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages."
},
"presets": {
"applied_mobile": "{name} changed {count, plural, one {# setting} other {# settings}}",
"apply": "Apply",
"apply_named": "Apply {name}",
"dyslexia": {
"description_mobile": "Atkinson Hyperlegible as the font, also for plain-text messages."
},
"low-vision": {
"description_mobile": "Text at 150% and labels under the toolbar icons."
},
@@ -1016,7 +1022,6 @@
"accessibility_link": {
"description_mobile": "Text size, reduced motion and the accessibility presets are on the Accessibility page."
},
"description_mobile": "Customize how the app looks",
"cross_all": {
"label_mobile": "All mail"
},
@@ -1026,6 +1031,7 @@
"cross_unread": {
"label_mobile": "All unread"
},
"description_mobile": "Customize how the app looks",
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
+158
View File
@@ -0,0 +1,158 @@
"""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()
+4
View File
@@ -17,6 +17,10 @@ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
// Code that isn't an npm dependency but ships in the app.
const VENDORED = [
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
// Reading fonts (scripts/build-reading-fonts.py).
{ name: 'Atkinson Hyperlegible Next', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/atkinson-hyperlegible-next' },
{ name: 'Lexend', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/lexend' },
{ name: 'OpenDyslexic', version: '', license: 'OFL-1.1', url: 'https://opendyslexic.org' },
];
function repoUrl(meta) {
+5 -1
View File
@@ -444,6 +444,9 @@ export default function EmailBodyView({
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
// The reading font reaches a plain-text body ("Same as above"), as in the
// webmail; HTML mail keeps the sender's fonts.
const appFont = useSettingsStore((s) => s.appFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// Body text follows the app's text size and the system font scale (webmail
@@ -611,9 +614,10 @@ export default function EmailBodyView({
quoteLabels,
textFactor,
plainDarkColors,
appFont,
}), [
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont,
]);
const source = React.useMemo(
@@ -1,10 +1,11 @@
import React from 'react';
import { View } from 'react-native';
import { Platform, View } from 'react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { FONT_SIZES, useSettingsStore, type FontSize } from '../../stores/settings-store';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store';
import {
ACCESSIBILITY_PRESET_IDS,
@@ -15,6 +16,14 @@ import {
} from '../../lib/accessibility-presets';
import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration';
// Font names are names: the same in every language (webmail
// accessibility-settings.tsx).
const FONT_NAMES: Record<Exclude<AppFont, 'default'>, string> = {
atkinson: 'Atkinson Hyperlegible',
lexend: 'Lexend',
opendyslexic: 'OpenDyslexic',
};
// Percentages read the same in every language the app has.
const TEXT_SIZE_LABELS: Record<FontSize, string> = {
small: '87.5%',
@@ -41,6 +50,11 @@ function presetCopy(id: AccessibilityPresetId, t: Translate): { label: string; d
label: t('settings.accessibility.presets.low-vision.label', 'Low vision'),
description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'),
};
case 'dyslexia':
return {
label: t('settings.accessibility.presets.dyslexia.label', 'Dyslexia'),
description: t('settings.accessibility.presets.dyslexia.description_mobile', 'Atkinson Hyperlegible as the font, also for plain-text messages.'),
};
case 'motion':
return {
label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'),
@@ -61,6 +75,7 @@ export function AccessibilitySettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const fontSize = useSettingsStore((s) => s.fontSize);
const appFont = useSettingsStore((s) => s.appFont);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
@@ -130,7 +145,6 @@ export function AccessibilitySettings() {
<SettingItem
label={t('settings.accessibility.text_size.label', 'Text size')}
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
noBorder
>
<Select
value={fontSize}
@@ -138,6 +152,22 @@ export function AccessibilitySettings() {
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.font.label', 'Font')}
description={uiFontsBundled(Platform.OS)
? t('settings.accessibility.font.description', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. They cover Latin script.')
: t('settings.accessibility.font.description_ios', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages.')}
noBorder
>
<Select
value={appFont}
onChange={(v) => update('appFont', v as AppFont)}
options={APP_FONTS.map((font) => ({
value: font,
label: font === 'default' ? t('settings.accessibility.font.default', 'Theme font') : FONT_NAMES[font],
}))}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
@@ -50,6 +50,15 @@ describe('bodyDocument', () => {
expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
});
it('draws a plain-text body in the reading font and rebuilds when it changes', () => {
const text = input({ rawHtml: null, text: 'Hello' });
const plain = bodyDocument(text);
const lexend = bodyDocument({ ...text, appFont: 'lexend' });
expect(lexend).not.toBe(plain);
expect(lexend.html).toContain('font-family: "Lexend"');
expect(plain.html).not.toContain('Lexend');
});
it('keeps messages apart by key', () => {
const a = bodyDocument(input({ key: '|e1' }));
const b = bodyDocument(input({ key: 'group|e1' }));
+16
View File
@@ -388,6 +388,22 @@ describe('wrapPlainTextEmail', () => {
it('lets the content pick its text direction', () => {
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
});
// Settings > Accessibility > Font: a plain body sits in the app document in
// the webmail, so it is drawn in the reading font ("Same as above").
it('draws the body in the reading font, which the page loads from data:', () => {
const html = wrapPlainTextEmail('x', { font: 'sans', readingFont: 'opendyslexic' });
expect(html).toContain('font-family: "OpenDyslexic", -apple-system');
expect(html).toMatch(/@font-face \{ font-family: "OpenDyslexic";[^}]*src: url\("data:font\/woff2;base64,/);
expect(html).toContain('font-src data:');
});
it('keeps monospace and the theme font as they were', () => {
const mono = wrapPlainTextEmail('x', { font: 'mono', readingFont: 'opendyslexic' });
expect(mono).toContain('font-family: ui-monospace');
expect(mono).not.toContain('OpenDyslexic');
expect(wrapPlainTextEmail('x', { font: 'sans', readingFont: 'default' })).not.toContain('@font-face');
});
});
describe('hasNativeDarkMode', () => {
+11 -4
View File
@@ -6,14 +6,15 @@
//
// The phone takes the presets whose settings it has: screen reader (no
// conversation grouping), low vision (150% text, labels under the toolbar
// icons) and motion (no animations). The webmail's others need settings the
// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds).
// icons), dyslexia (the reading font, also for plain-text mail) and motion
// (no animations). The webmail's others need settings the app doesn't have
// (spacing, focus outlines, hover, sounds).
import type { PersistedSettings } from '../stores/settings-store';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'dyslexia' | 'motion';
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion'];
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'dyslexia', 'motion'];
type PresetValues = Partial<PersistedSettings>;
@@ -27,6 +28,12 @@ export const ACCESSIBILITY_PRESETS: Record<AccessibilityPresetId, PresetValues>
fontSize: 'xx-large',
showToolbarLabels: true,
},
// The webmail's font part (its spacing, reading width and mail style
// options have no phone counterpart yet).
dyslexia: {
appFont: 'atkinson',
plainTextFont: 'sans',
},
motion: {
animationsEnabled: false,
},
+5 -1
View File
@@ -10,6 +10,7 @@ import {
} from './email-html';
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
import { mailBaseFontPx } from './mail-reading';
import type { AppFont } from '../theme/reading-fonts';
export interface BodyDocumentInput {
/** The message (account and id); the sources below are compared as well. */
@@ -33,6 +34,8 @@ export interface BodyDocumentInput {
textFactor?: number;
/** The app theme's colours for a plain-text body in dark mode. */
plainDarkColors?: PlainTextColors;
/** The reading font (Settings > Accessibility > Font) for a plain-text body. */
appFont?: AppFont;
}
export interface BodyDocument {
@@ -68,6 +71,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
font: input.plainTextFont,
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
readingFont: input.appFont,
}),
recolorDark: false,
hasNativeDark: false,
@@ -97,7 +101,7 @@ export function bodyDocument(input: BodyDocumentInput): BodyDocument {
const key = JSON.stringify([
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
input.plainDarkColors ?? null,
input.plainDarkColors ?? null, input.appFont ?? 'default',
]);
const hit = documents.get(key);
if (hit && sameSources(hit.input, input)) {
+17 -4
View File
@@ -22,6 +22,7 @@
// sheet is scrubbed of url()/@import/@font-face references instead.
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
import { mailFontStack, readingFontFaceCss, type AppFont } from '../theme/reading-fonts';
const FORBIDDEN_TAGS = [
'script', 'iframe', 'object', 'embed', 'form',
@@ -935,10 +936,22 @@ function cssColor(value: string, fallback: string): string {
export function wrapPlainTextEmail(
innerHtml: string,
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
options: {
isDark?: boolean;
font?: PlainTextFont;
fontPx?: number;
darkColors?: PlainTextColors;
/**
* The app's reading font (Settings > Accessibility > Font). "Same as
* above" (sans) draws the body in it, as the webmail does for a plain
* body in the app document; monospace stays monospace.
*/
readingFont?: AppFont;
} = {},
): string {
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options;
const cleaned = stripDangerousTags(innerHtml);
const reading = font === 'sans' ? readingFont : undefined;
// In dark mode the body takes the app theme's ground, text and link colours
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
@@ -948,9 +961,9 @@ export function wrapPlainTextEmail(
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
: mailFontStack(reading);
const styles = `
const styles = `${readingFontFaceCss(reading)}
html, body { background: ${bg}; }
/* Pinch-zoomed content overflows sideways; it is panned via the injected
transform, never via native horizontal scrolling. */
+18
View File
@@ -213,6 +213,24 @@
"version": "",
"license": "MIT",
"url": "https://tabler.io/icons"
},
{
"name": "Atkinson Hyperlegible Next",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/atkinson-hyperlegible-next"
},
{
"name": "Lexend",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/lexend"
},
{
"name": "OpenDyslexic",
"version": "",
"license": "OFL-1.1",
"url": "https://opendyslexic.org"
}
]
}
+3 -1
View File
@@ -63,8 +63,10 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
accessibility: [
'settings.accessibility.presets.screen-reader',
'settings.accessibility.presets.low-vision',
'settings.accessibility.presets.dyslexia',
'settings.accessibility.presets.motion',
'settings.accessibility.text_size',
'settings.accessibility.font',
'settings.accessibility.reduce_motion',
'settings.accessibility.toast_duration',
'settings.accessibility.confirm_send',
@@ -267,7 +269,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic',
appearance: 'theme dark light font size text color animation density',
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
+12
View File
@@ -17,6 +17,9 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type PlainTextFont = 'sans' | 'mono';
export type MessageSpacing = 'auto' | 'always' | 'edge';
// a11y-reading: the webmail's reading fonts (lib/accessibility-settings.ts AppFont).
export type AppFont = 'default' | 'atkinson' | 'lexend' | 'opendyslexic';
export const APP_FONTS: readonly AppFont[] = ['default', 'atkinson', 'lexend', 'opendyslexic'];
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
export type PostExportAction = 'keep' | 'archive' | 'trash';
export type SwipeAction =
@@ -159,6 +162,11 @@ export interface PersistedSettings {
emailAlwaysLightMode: boolean;
activeThemeId: string | null;
// a11y-reading: Settings > Accessibility (webmail lib/accessibility-
// settings.ts, same keys for export/import). The reading font for the
// app's text and plain-text mail (theme/reading-fonts).
appFont: AppFont;
// Composing
autoSelectReplyIdentity: boolean;
replyIdentityMatch: ReplyIdentityMatch;
@@ -359,6 +367,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
animationsEnabled: true,
emailAlwaysLightMode: false,
activeThemeId: null,
// a11y-reading:
appFont: 'default',
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
@@ -617,6 +627,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
messageListOrder: (v) => Array.isArray(v),
fontSize: oneOf(FONT_SIZES),
// a11y-reading:
appFont: oneOf(APP_FONTS),
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
attachmentReminderKeywords: stringArray,
// Same set the webmail accepts (stores/settings-store.ts importSettings).
+141
View File
@@ -0,0 +1,141 @@
// Reading fonts (Settings > Accessibility > Font, webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css): the names,
// 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 { join } from 'node:path';
// The wrapper subscribes with useSyncExternalStore; outside a React render
// the test reads the snapshot directly.
vi.mock('react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(),
}));
import {
appFontAssetFamily,
appFontFamilyName,
mailFontStack,
readingFontFaceCss,
uiFontFamily,
uiFontsBundled,
} from '../reading-fonts';
import {
getUiFontFamily,
setUiFontFamily,
withFontFamily,
wrapModuleDefaults,
wrapWithReadingFont,
} from '../reading-font-text';
import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store';
import { presetChanges } from '../../lib/accessibility-presets';
const ASSETS = join(__dirname, '..', '..', '..', 'android', 'app', 'src', 'main', 'assets', 'fonts');
describe('reading fonts', () => {
it('offers the webmail fonts under its names and keys', () => {
expect(APP_FONTS).toEqual(['default', 'atkinson', 'lexend', 'opendyslexic']);
expect(appFontFamilyName('atkinson')).toBe('Atkinson Hyperlegible Next');
expect(appFontFamilyName('lexend')).toBe('Lexend');
expect(appFontFamilyName('opendyslexic')).toBe('OpenDyslexic');
expect(appFontFamilyName('default')).toBeNull();
});
it('bundles a regular and a bold file on Android for every font', () => {
for (const font of APP_FONTS) {
const family = appFontAssetFamily(font);
if (font === 'default') {
expect(family).toBeNull();
continue;
}
expect(existsSync(join(ASSETS, `${family}.ttf`))).toBe(true);
expect(existsSync(join(ASSETS, `${family}_bold.ttf`))).toBe(true);
}
expect(existsSync(join(ASSETS, 'OFL.txt'))).toBe(true);
});
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('default', 'android')).toBeNull();
// iOS reports an unknown family as an error.
expect(uiFontFamily('opendyslexic', 'ios')).toBeNull();
});
it('puts the font first in a mail body and keeps the system stack behind it', () => {
expect(mailFontStack('lexend')).toMatch(/^"Lexend", -apple-system, /);
expect(mailFontStack('default')).toMatch(/^-apple-system, /);
});
it('gives a mail document the font as data: woff2 with the webmail unicode ranges', () => {
const css = readingFontFaceCss('atkinson');
const faces = css.split('\n');
expect(faces).toHaveLength(2);
for (const face of faces) {
expect(face).toContain('font-family: "Atkinson Hyperlegible Next";');
expect(face).toContain('font-weight: 200 800;');
// "wOF2", the woff2 signature, in base64.
expect(face).toMatch(/src: url\("data:font\/woff2;base64,d09GMg[A-Za-z0-9+/=]+"\) format\("woff2"\);/);
expect(face).toMatch(/unicode-range: U\+/);
}
expect(readingFontFaceCss('opendyslexic')).toContain('font-family: "OpenDyslexic";');
expect(readingFontFaceCss('default')).toBe('');
});
it('keeps a stored font and drops one the app does not know', () => {
expect(mergeWithDefaults({ appFont: 'lexend' }).appFont).toBe('lexend');
expect(mergeWithDefaults({ appFont: 'comic-sans' as never }).appFont).toBe('default');
});
it('has a dyslexia preset that picks the readable font for the app and plain-text mail', () => {
expect(presetChanges('dyslexia', { appFont: 'default', plainTextFont: 'mono' })).toEqual([
{ key: 'appFont', from: 'default', to: 'atkinson' },
{ key: 'plainTextFont', from: 'mono', to: 'sans' },
]);
});
});
describe('the reading font on the app text', () => {
it('puts the family under the caller style, which still wins', () => {
const own = { color: 'red' };
expect(withFontFamily({ style: own }, 'Lexend')).toEqual({ style: [{ fontFamily: 'Lexend' }, own] });
expect(withFontFamily({}, 'Lexend')).toEqual({ style: { fontFamily: 'Lexend' } });
const props = { style: own };
expect(withFontFamily(props, null)).toBe(props);
});
it('renders the original component with the current family', () => {
const seen: unknown[] = [];
const Original = Object.assign((props: Record<string, unknown>) => { seen.push(props); return null; }, {
displayName: 'TextInput',
State: { currentlyFocusedInput: () => null },
});
const Wrapped = wrapWithReadingFont(Original);
// Statics such as TextInput.State stay reachable.
expect((Wrapped as unknown as typeof Original).State).toBe(Original.State);
expect(Wrapped.displayName).toBe('TextInput');
setUiFontFamily(null);
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: { color: 'red' }, children: 'x' });
setUiFontFamily('OpenDyslexic');
expect(getUiFontFamily()).toBe('OpenDyslexic');
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: [{ fontFamily: 'OpenDyslexic' }, { color: 'red' }], children: 'x' });
setUiFontFamily(null);
});
it('wraps each module default once and leaves non-functions alone', () => {
const text = { default: () => null };
const memo = { default: { $$typeof: Symbol.for('react.memo') } };
const original = text.default;
expect(wrapModuleDefaults([text, memo])).toBe(1);
expect(text.default).not.toBe(original);
const wrapped = text.default;
expect(wrapModuleDefaults([text])).toBe(0);
expect(text.default).toBe(wrapped);
expect(memo.default).toEqual({ $$typeof: Symbol.for('react.memo') });
});
});
+22
View File
@@ -0,0 +1,22 @@
// 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
// nothing it loads can copy the plain Text first.
/* eslint-disable @typescript-eslint/no-require-imports */
import { Platform } from 'react-native';
import { setUiFontFamily, wrapModuleDefaults } from './reading-font-text';
import { uiFontFamily, uiFontsBundled } from './reading-fonts';
if (uiFontsBundled(Platform.OS)) {
wrapModuleDefaults([
require('react-native/Libraries/Text/Text'),
require('react-native/Libraries/Components/TextInput/TextInput'),
]);
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
setUiFontFamily(uiFontFamily(useSettingsStore.getState().appFont, Platform.OS));
useSettingsStore.subscribe((state, prev) => {
if (state.appFont !== prev.appFont) setUiFontFamily(uiFontFamily(state.appFont, Platform.OS));
});
}
+88
View File
@@ -0,0 +1,88 @@
// The reading font for the app's own text (Settings > Accessibility > Font).
//
// The webmail sets one CSS variable on <html> and every piece of text follows
// (app/accessibility.css data-app-font). React Native has no inherited font:
// each Text takes its family from its own style, the app's styles name none,
// and Text.defaultProps is gone on the new architecture. So the app's Text
// and TextInput components are wrapped once at startup: the wrapper puts the
// chosen family under the caller's own style (an explicit fontFamily still
// wins) and re-renders when the setting changes.
//
// Wrapping happens before any screen module loads (index.ts imports
// ./reading-font-install first): with Metro's import support every module
// copies `Text` out of react-native when it is evaluated, so a module loaded
// earlier keeps the plain component. The wrapper stays installed for the
// whole run, also while the theme font is chosen; swapping the component
// later would remount every text field.
import { useSyncExternalStore } from 'react';
type Props = { style?: unknown } & Record<string, unknown>;
type FunctionComponent = ((props: Props) => unknown) & { displayName?: string };
let family: string | null = null;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
/** The family the app's text is drawn in right now, or null for the theme font. */
export function getUiFontFamily(): string | null {
return family;
}
/** Changes the family; every wrapped Text re-renders. */
export function setUiFontFamily(next: string | null): void {
if (next === family) return;
family = next;
for (const listener of [...listeners]) listener();
}
/** `props` with the family under the caller's own style; unchanged for null. */
export function withFontFamily<P extends { style?: unknown }>(props: P, fontFamily: string | null): P {
if (!fontFamily) return props;
return { ...props, style: props.style == null ? { fontFamily } : [{ fontFamily }, props.style] };
}
const WRAPPED = Symbol.for('bulwark.readingFont');
/**
* A component that renders `Original` with the reading font. `Original` is
* called as a function, not rendered as a child, so no extra component is
* added per text: its hooks run as part of the wrapper, always in the same
* order. Statics (TextInput.State) and the display name are kept.
*/
export function wrapWithReadingFont(Original: FunctionComponent): FunctionComponent {
const Wrapped = function ReadingFontText(props: Props) {
const current = useSyncExternalStore(subscribe, getUiFontFamily, getUiFontFamily);
return Original(withFontFamily(props, current));
} as FunctionComponent;
Object.assign(Wrapped, Original);
Wrapped.displayName = Original.displayName ?? Original.name;
(Wrapped as unknown as Record<symbol, unknown>)[WRAPPED] = true;
return Wrapped;
}
/**
* Wraps the default export of each module (react-native's Text and
* TextInput) in place. Only plain function components are wrapped; anything
* else (a future memo or forwardRef object) is left alone. Returns how many
* were wrapped; a second call wraps nothing.
*/
export function wrapModuleDefaults(modules: Array<{ default?: unknown }>): number {
let wrapped = 0;
for (const mod of modules) {
const component = mod?.default;
if (typeof component !== 'function') continue;
if ((component as unknown as Record<symbol, unknown>)[WRAPPED]) continue;
try {
mod.default = wrapWithReadingFont(component as FunctionComponent);
wrapped++;
} catch {
// A read-only export: leave the component as it is.
}
}
return wrapped;
}
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// The reading fonts on Settings > Accessibility > Font (webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css,
// lib/mail-reading.ts appFontFamilyName): Atkinson Hyperlegible Next, Lexend
// and OpenDyslexic. As in the webmail the font is the app's text font, and a
// plain-text mail body (which the webmail draws in the app document) uses it
// 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.
//
// 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
// constants, which Hermes keeps in the bytecode's string table rather than
// parsing them at startup).
import type { AppFont } from '../stores/settings-store';
import { ATKINSON, LEXEND, OPENDYSLEXIC, type ReadingFontFace } from './reading-fonts-data';
export type { AppFont };
/** The CSS family name a font is bundled under (webmail appFontFamilyName), or null for the default. */
export function appFontFamilyName(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'Atkinson Hyperlegible Next';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** The Android asset family (assets/fonts/<name>.ttf and <name>_bold.ttf). */
export function appFontAssetFamily(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'AtkinsonHyperlegibleNext';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** Whether the platform has the fonts for the app's own text. */
export function uiFontsBundled(platform: string): boolean {
return platform === 'android';
}
/**
* 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).
*/
export function uiFontFamily(font: AppFont | null | undefined, platform: string): string | null {
return uiFontsBundled(platform) ? appFontAssetFamily(font) : null;
}
/** The mail body's own font stack (lib/email-html). */
export const MAIL_SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
/**
* font-family for a mail body: the reading font first (it covers Latin
* script; other scripts fall through to the system stack, as in the webmail).
*/
export function mailFontStack(font: AppFont | null | undefined): string {
const family = appFontFamilyName(font);
return family ? `"${family}", ${MAIL_SYSTEM_FONT_STACK}` : MAIL_SYSTEM_FONT_STACK;
}
function faceFor(font: AppFont): ReadingFontFace | null {
switch (font) {
case 'atkinson': return ATKINSON;
case 'lexend': return LEXEND;
case 'opendyslexic': return OPENDYSLEXIC;
default: return null;
}
}
/**
* The @font-face rules a mail document needs for the font, with the fonts as
* data: URIs (the page's CSP allows font-src data: in every mode). Empty for
* the default font.
*/
export function readingFontFaceCss(font: AppFont | null | undefined): string {
if (!font || font === 'default') return '';
const face = faceFor(font);
if (!face) return '';
return face.subsets.map((subset) => [
'@font-face {',
`font-family: "${face.family}";`,
'font-style: normal;',
'font-display: swap;',
`font-weight: ${face.weight};`,
`src: url("data:font/woff2;base64,${subset.woff2}") format("woff2");`,
subset.unicodeRange ? `unicode-range: ${subset.unicodeRange};` : '',
'}',
].filter(Boolean).join(' ')).join('\n');
}