Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Binary file added assets/fonts/fallback/NotoSansCJKhk.subset.woff2
Binary file not shown.
Binary file added assets/fonts/fallback/NotoSansCJKjp.subset.woff2
Binary file not shown.
Binary file added assets/fonts/fallback/NotoSansCJKkr.subset.woff2
Binary file not shown.
Binary file added assets/fonts/fallback/NotoSansCJKsc.subset.woff2
Binary file not shown.
Binary file added assets/fonts/fallback/NotoSansCJKtc.subset.woff2
Binary file not shown.
25 changes: 23 additions & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:privacy_gui/core/utils/logger.dart';
import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart';
import 'package:privacy_gui/demo/theme_studio/demo_theme_builder.dart';
import 'package:privacy_gui/theme/theme_json_config.dart';
import 'package:privacy_gui/localization/fallback_font_resolver.dart';
import 'package:privacy_gui/localization/localization_hook.dart';
import 'package:privacy_gui/components/layouts/root_container.dart';
import 'package:privacy_gui/providers/app_settings/app_settings.dart';
Expand Down Expand Up @@ -160,19 +161,39 @@ class _LinksysAppState extends ConsumerState<LinksysApp>
required DemoThemeConfig demoConfig,
required Color? userThemeColor,
}) {
final appLightTheme = buildDemoThemeData(
var appLightTheme = buildDemoThemeData(
brightness: Brightness.light,
config: demoConfig,
themeConfig: themeConfig,
userThemeColor: userThemeColor,
);
final appDarkTheme = buildDemoThemeData(
var appDarkTheme = buildDemoThemeData(
brightness: Brightness.dark,
config: demoConfig,
themeConfig: themeConfig,
userThemeColor: userThemeColor,
);

// CJK / non-Latin fallback for the active locale. The subset fonts are
// eager-loaded via pubspec `fonts:` (registered before first frame). Adding
// the fallback family to ThemeData.textTheme covers raw `Text` / third-party
// widgets; ui_kit's AppText.resolve() injects the same family per-locale for
// AppText. Without the family in the TextStyle, the engine treats CJK code
// points as missing and probes the CDN. Null for Latin-covered locales.
final effectiveLocale = appSettings.locale ?? systemLocale;
final cjkFallback =
FallbackFontResolver.prefixedFallbackFor(effectiveLocale);
if (cjkFallback != null) {
appLightTheme = appLightTheme.copyWith(
textTheme:
appLightTheme.textTheme.apply(fontFamilyFallback: cjkFallback),
);
appDarkTheme = appDarkTheme.copyWith(
textTheme:
appDarkTheme.textTheme.apply(fontFamilyFallback: cjkFallback),
);
}

return MaterialApp.router(
onGenerateTitle: (context) => loc(context).appTitle,
theme: appLightTheme,
Expand Down
17 changes: 14 additions & 3 deletions lib/components/styled/general_settings_widget/language_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class _LanguageTileState extends ConsumerState<LanguageTile> {
Widget build(BuildContext context) {
return InkWell(
onTap: () {
// The picker lists every language's native name at once (简体中文, ไทย,
// العربية …). All subset fonts are eager-loaded (pubspec `fonts:`), and
// each row wraps its title in Localizations.override(locale) below so
// the correct per-language fallback family is applied.
showSimpleAppDialog(
context,
content: _localeList(),
Expand Down Expand Up @@ -84,9 +88,16 @@ class _LanguageTileState extends ConsumerState<LanguageTile> {
return AppListTile(
key: Key('locale_item_${locale.toLanguageTag()}'),
selected: isSelected,
title: Semantics(
identifier: 'now-locale-item-${locale.toLanguageTag()}',
child: AppText.labelLarge(locale.displayText)),
// Override locale per item so AppText.resolve() picks THIS
// language's fallback family (e.g. the "ไทย" row resolves with
// Thai → NotoSansThai), not the app's current locale.
title: Localizations.override(
context: context,
locale: locale,
child: Semantics(
identifier: 'now-locale-item-${locale.toLanguageTag()}',
child: AppText.labelLarge(locale.displayText)),
),
trailing: isSelected
? Semantics(
identifier: 'now-locale-item-checked',
Expand Down
92 changes: 92 additions & 0 deletions lib/localization/fallback_font_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:flutter/widgets.dart' show Locale;
import 'package:ui_kit_library/ui_kit.dart' show LocaleFallbackFont;

/// Maps a locale to the bundled fallback font family for scripts the primary
/// font (NeueHaasGrotTextRound) doesn't cover: CJK, Greek, Cyrillic,
/// Vietnamese, Thai, Arabic.
///
/// These families are declared under `fonts:` in pubspec.yaml (eager-loaded and
/// registered with the engine before the first frame; assets in
/// `assets/fonts/fallback/`) as `packages/ui_kit_library/<Family>`. The single
/// source of truth for the locale→family mapping lives HERE.
///
/// **Two consumption forms — this matters:**
/// - ui_kit's [LocaleFallbackFont] (used by AppText) needs the BARE family name.
/// AppText's base TextStyle sets `package: ui_kit_library`, so `copyWith`
/// auto-prefixes fallback entries with `packages/ui_kit_library/`. Passing a
/// pre-prefixed name there produces a DOUBLE prefix that matches nothing.
/// - app.dart's ThemeData.textTheme fallback (for raw `Text`) does NOT go
/// through that base style, so it needs the PREFIXED name to match the
/// pubspec `fonts:` family.
///
/// Returns null for locales fully covered by the primary Latin font
/// (en/fr/de/es/pt/nordic/pl/tr …).
class FallbackFontResolver {
FallbackFontResolver._();

static const _prefix = 'packages/ui_kit_library';

/// Injects the BARE-name resolver into ui_kit. Call once at startup.
static void install() {
LocaleFallbackFont.resolver = _bareFallbackFor;
}

/// Bare family name (no package prefix) for [locale] — for ui_kit injection.
static String? bareFamilyForLocale({
required String languageCode,
String? countryCode,
String? scriptCode,
}) {
switch (languageCode.toLowerCase()) {
case 'ja':
return 'NotoSansJP';
case 'ko':
return 'NotoSansKR';
case 'zh':
final region = countryCode?.toUpperCase();
final script = scriptCode?.toLowerCase();
final isTraditional = script == 'hant' ||
region == 'TW' ||
region == 'HK' ||
region == 'MO';
if (isTraditional) {
return (region == 'HK' || region == 'MO')
? 'NotoSansHK'
: 'NotoSansTC';
}
return 'NotoSansSC';
case 'th':
return 'NotoSansThai';
case 'ar':
return 'NotoSansArabic';
case 'el': // Greek
case 'ru': // Cyrillic
case 'vi': // Vietnamese extended Latin
return 'NotoSansLatinExt';
default:
return null;
}
}

static List<String>? _bareFallbackFor(Locale? locale) {
if (locale == null) return null;
final fam = bareFamilyForLocale(
languageCode: locale.languageCode,
countryCode: locale.countryCode,
scriptCode: locale.scriptCode,
);
return fam == null ? null : [fam];
}

/// Package-prefixed fallback list for [locale] — for app.dart's
/// ThemeData.textTheme (raw `Text`, which doesn't get ui_kit's auto-prefix).
static List<String>? prefixedFallbackFor(Locale? locale) {
if (locale == null) return null;
final fam = bareFamilyForLocale(
languageCode: locale.languageCode,
countryCode: locale.countryCode,
scriptCode: locale.scriptCode,
);
return fam == null ? null : ['$_prefix/$fam'];
}
}
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:privacy_gui/config/global_config.dart';
import 'package:privacy_gui/constants/_constants.dart';
import 'package:privacy_gui/app.dart';
import 'package:privacy_gui/localization/fallback_font_resolver.dart';
import 'package:privacy_gui/di.dart';
import 'package:privacy_gui/providers/logger_observer.dart';

Expand Down Expand Up @@ -80,6 +81,10 @@ void main() async {
// GetIt - Register services and default theme data
dependencySetup();

// Inject the app's locale→fallback-font mapping into ui_kit so AppText
// applies the bundled CJK/non-Latin subsets per locale.
FallbackFontResolver.install();

runApp(app());
}

Expand Down
46 changes: 43 additions & 3 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ dependencies:
ui_kit_library:
git:
url: https://github.com/linksys/privacyGUI-UI-kit.git
ref: v2.28.0
ref: v2.28.1
generative_ui:
git:
url: https://github.com/linksys/privacyGUI-UI-kit.git
ref: v2.28.0
ref: v2.28.1
path: generative_ui
flutter_blue_plus: ^1.4.0
crypto: ^3.0.2
Expand Down Expand Up @@ -129,7 +129,47 @@ flutter:
- assets/resources/
- assets/a2ui/widgets/
- assets/theme/

- assets/fonts/fallback/

# Non-Latin fallback fonts (CJK subsets built from the interface charset — see
# tools/font_subset/ — plus Thai/Arabic/Latin-ext and Roboto). Declared under
# fonts: so the engine registers them before the first frame; this is what
# stops the CanvasKit fallback manager from probing the CDN (verified: as
# assets-only they were probed; declared here, zero CDN). Family names are
# package-prefixed to match ui_kit's AppText fallback injection and app.dart's
# textTheme fallback. Offline-first: all locales must render without network.
fonts:
- family: packages/ui_kit_library/NotoSansSC
fonts:
- asset: assets/fonts/fallback/NotoSansCJKsc.subset.woff2
- family: packages/ui_kit_library/NotoSansTC
fonts:
- asset: assets/fonts/fallback/NotoSansCJKtc.subset.woff2
- family: packages/ui_kit_library/NotoSansHK
fonts:
- asset: assets/fonts/fallback/NotoSansCJKhk.subset.woff2
- family: packages/ui_kit_library/NotoSansJP
fonts:
- asset: assets/fonts/fallback/NotoSansCJKjp.subset.woff2
- family: packages/ui_kit_library/NotoSansKR
fonts:
- asset: assets/fonts/fallback/NotoSansCJKkr.subset.woff2
- family: packages/ui_kit_library/NotoSansThai
fonts:
- asset: assets/fonts/fallback/NotoSansThai.woff2
- family: packages/ui_kit_library/NotoSansArabic
fonts:
- asset: assets/fonts/fallback/NotoSansArabic.woff2
- family: packages/ui_kit_library/NotoSansLatinExt
fonts:
- asset: assets/fonts/fallback/NotoSans-Latin.woff2
# Roboto is the engine's built-in default global fallback
# (globalFontFallbacks = ['Roboto']). Declared with its BARE family name so
# the engine finds it locally instead of probing the CDN on startup.
- family: Roboto
fonts:
- asset: assets/fonts/fallback/Roboto.woff2

# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.

Expand Down
4 changes: 4 additions & 0 deletions tools/font_subset/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Reproducible intermediates — regenerated by regenerate.sh, not checked in.
.venv/
full_fonts/
out/
77 changes: 77 additions & 0 deletions tools/font_subset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Fallback font subsetting (offline CJK)

Build-time tool that generates the bundled **CJK subset fonts** used for
offline rendering. The app must work with **no network** (router firmware), so
every glyph the interface can show must ship in the product. Full Noto Sans CJK
is ~12.7 MB; subsetting to just the glyphs the interface uses brings the 5 CJK
fonts down to **~2.1 MB (84% smaller)** while keeping every language's glyph
shapes correct.

Architecture overview and rationale:
[raw/offline_font_bundle_size_options.md](../../../Documents/docs/raw/offline_font_bundle_size_options.md)
(Obsidian vault) — or ask; it documents the full A+ design.

## ⚠️ When you MUST re-run this

Re-run **`regenerate.sh`** after ANY change that can introduce a new CJK / kana /
hangul glyph into interface text:

- new or edited strings in `lib/l10n/app_{zh,zh_TW,ja,ko}.arb`
- a new language name in `lib/util/languages.dart`
- a hardcoded CJK literal in Dart source under `lib/`

**If you skip it, the subset silently misses the new glyph** → online it falls
back to the CDN (a network request), **offline it renders as tofu (□)**. This is
hard to spot because most text still looks fine.

> Only the **5 CJK subsets** are regenerated. The non-CJK fallbacks
> (`NotoSansThai`, `NotoSansArabic`, `NotoSans-Latin`, `Roboto` in
> `assets/fonts/fallback/`) are FULL fonts that never change — leave them.

## Usage

```bash
bash tools/font_subset/regenerate.sh
```

One idempotent command: sets up a venv, downloads the full Noto Sans CJK OTFs
(first run only, needs net), extracts the interface charset, subsets the 5 CJK
fonts, and deploys them to `assets/fonts/fallback/`.

Then rebuild and verify no CDN requests appear for CJK text:

```bash
flutter build web --debug
# serve build/web, open a CJK locale, DevTools → Network → Font:
# should load only assets/fonts/fallback/*.woff2, zero fonts.gstatic.com
```

Optional visual check of glyph correctness:

```bash
.venv/bin/python tools/font_subset/make_test_page.py # -> out/test_render.html
```

## Charset sources (extract_charset.py)

The interface charset is the union of:
1. translatable values in the CJK ARB files (skips `@` metadata + ICU placeholders)
2. full CJK punctuation / fullwidth / compat blocks (U+3000–303F, U+FF00–FF60, U+FE30–FE4F)
3. language picker native names in `lib/util/languages.dart`
4. hardcoded CJK literals in Dart source under `lib/` (excludes generated l10n)

Missing any of these classes was a real cause of stray CDN requests — keep all four.

## Files

- `regenerate.sh` — the one command to run (download → extract → subset → deploy).
- `extract_charset.py` — builds `out/charset.txt` from the sources above.
- `make_test_page.py` — renders sample strings per language to `out/test_render.html`.
- `.venv/`, `full_fonts/`, `out/` — reproducible intermediates, gitignored.

## Where the fonts are consumed

- Declared eager under `pubspec.yaml` `fonts:` as `packages/ui_kit_library/NotoSans*`
(registered before first frame — this is what keeps CJK off the CDN).
- Locale→family mapping: `lib/localization/fallback_font_resolver.dart` (single
source of truth), injected into ui_kit's `LocaleFallbackFont` at startup.
Loading
Loading