feat: bundle Luciole, Literata, Charis SIL and a Hebrew fallback face

This commit is contained in:
randogoth 2026-10-11 14:24:06 +03:00
parent dfba1cfb16
commit 6de6672ae3
30 changed files with 624 additions and 11 deletions

View file

@ -1,6 +1,7 @@
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../fonts.dart';
/// The reader's presentation choices (spec 8.2) plus the per-app limits they
/// move within. Values persist through ReaderSettings in the database.
@ -19,7 +20,7 @@ class ReaderSettingsController {
static const themeModes = ['light', 'dark', 'system'];
static const variants = ['site', 'warm', 'cool', 'green', 'mono'];
final font = signal('Atkinson Hyperlegible');
final font = signal(defaultFontFamily);
final fontScale = signal(1.0);
final themeMode = signal('system');
final variant = signal('site');
@ -34,7 +35,11 @@ class ReaderSettingsController {
}
Future<bool> setFont(String family) async {
if (family.isEmpty || family == font.value) return false;
// Only a family this build bundles: an unknown one would silently render
// in the platform's font instead.
if (!readerFonts.any((f) => f.family == family) || family == font.value) {
return false;
}
font.value = family;
await _persist();
return true;

View file

@ -0,0 +1,60 @@
/// The typefaces bundled with the client (spec 8.2: the reader chooses).
/// Pure data, shared by the settings page that offers them and the renderer
/// that applies them.
library;
/// One bundled family: the name used in pubspec.yaml and stored in settings,
/// the id of its display name in the locale file, and where to look when it
/// has no glyph for a character.
typedef ReaderFont = ({String family, String labelId, List<String> fallback});
/// Offered in this order: the two sans faces first, then the two serifs.
/// Adding a font is four files in assets/fonts, one `fonts` entry in
/// pubspec.yaml, one entry here, one label in the locale file, and its licence
/// beside the files.
///
/// Each face names where to look for a character it has no glyph for. Hebrew
/// comes from a bundled Hebrew face, matched to the shape of the chosen one,
/// so it never depends on what the device happens to have installed. The
/// serifs also cover Greek, Cyrillic and a wide Latin range, so each falls
/// back to the other; the sans faces are narrow and have no bundled sans to
/// turn to, so for those the platform answers, and it answers a sans with a
/// sans. Scripts nothing here covers, such as Arabic and the CJK languages,
/// always come from the platform.
const readerFonts = <ReaderFont>[
(
family: 'Atkinson Hyperlegible',
labelId: 'settings-font-atkinson',
fallback: [hebrewSans],
),
(
family: 'Luciole',
labelId: 'settings-font-luciole',
fallback: [hebrewSans],
),
(
family: 'Literata',
labelId: 'settings-font-literata',
fallback: ['Charis SIL', hebrewSerif],
),
(
family: 'Charis SIL',
labelId: 'settings-font-charis',
fallback: ['Literata', hebrewSerif],
),
];
/// Bundled to be fallen back on, never offered: the reader picks a face for
/// the Latin text they are reading, and Hebrew follows its shape.
const hebrewSans = 'Noto Sans Hebrew';
const hebrewSerif = 'Noto Serif Hebrew';
const defaultFontFamily = 'Atkinson Hyperlegible';
/// Where to look after [family], or nothing when it is not one of ours.
List<String> fallbackFor(String family) {
for (final font in readerFonts) {
if (font.family == family) return font.fallback;
}
return const [];
}

View file

@ -1,17 +1,11 @@
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import '../fonts.dart';
import '../controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'mews_urls.dart';
import 'page_blocks.dart';
/// The bundled typefaces the reader can pick. Adding a font is one TTF in
/// assets/fonts, one entry in pubspec.yaml, one entry here and one label in
/// the locale file.
const _fonts = <({String family, String labelId})>[
(family: 'Atkinson Hyperlegible', labelId: 'settings-font-atkinson'),
];
/// Message ids for the values the controller accepts. Kept literal so every
/// id in the locale file can be found by searching for it.
const _themeLabels = {
@ -43,7 +37,7 @@ Future<MewsPage> buildSettingsPage(
...chrome(title: title, current: url, strings: strings),
HeadingBlock(2, [MewsText(strings.t('settings-font-heading'))]),
choiceRow([
for (final font in _fonts)
for (final font in readerFonts)
(
href: MewsUrls.action('set-font', {'value': font.family}),
label: strings.t(font.labelId),

View file

@ -9,6 +9,7 @@ import '../application/controllers/bookmarks_controller.dart';
import '../application/controllers/browser_controller.dart';
import '../application/controllers/reader_settings_controller.dart';
import '../application/controllers/subscriptions_controller.dart';
import '../application/fonts.dart';
import '../application/internal_actions.dart';
import '../application/internal_pages.dart';
import '../database/database.dart';
@ -171,6 +172,7 @@ class _ThemedApp extends StatelessWidget {
// The chrome reads in the typeface the reader chose for pages, so the
// address bar and the page below it are not two different faces.
fontFamily: fontFamily,
fontFamilyFallback: fallbackFor(fontFamily),
scaffoldBackgroundColor: palette.background,
// Selection highlights and the caret follow the palette too.
textSelectionTheme: TextSelectionThemeData(

View file

@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../l10n/strings.dart';
import '../../application/fonts.dart';
import '../../mews/document.dart';
import 'palettes.dart';
@ -117,6 +118,7 @@ class _MewsPageViewState extends State<MewsPageView> {
style: TextStyle(
color: palette.foreground.withValues(alpha: 0.7),
fontFamily: widget.fontFamily,
fontFamilyFallback: fallbackFor(widget.fontFamily),
fontSize: base * 0.85,
),
),
@ -129,6 +131,7 @@ class _MewsPageViewState extends State<MewsPageView> {
final foreground = TextStyle(
color: palette.foreground,
fontFamily: widget.fontFamily,
fontFamilyFallback: fallbackFor(widget.fontFamily),
fontSize: base,
height: 1.5,
);