mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: offer the reading fonts, including OpenDyslexic, for the app and plain-text mail
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
@@ -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';
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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') });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user