Skip to content

feat(fonts): offline CJK subsetting + zero-CDN fallback (12.7MB -> 2.14MB) - #1135

Merged
AustinChangLinksys merged 4 commits into
dev-2.6.0from
feature/offline-font-subset-poc
Jul 16, 2026
Merged

feat(fonts): offline CJK subsetting + zero-CDN fallback (12.7MB -> 2.14MB)#1135
AustinChangLinksys merged 4 commits into
dev-2.6.0from
feature/offline-font-subset-poc

Conversation

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator

What

Reduce the offline web font bundle by subsetting CJK/non-Latin fallback fonts, and rework how fallback fonts are supplied so every locale renders offline with zero CDN requests while online users can still fetch rare glyphs on demand.

CJK fonts: 12.7MB → 2.14MB (84% smaller). All 26 locales render offline, glyph shapes stay correct per language, and interface text triggers 0 fonts.gstatic.com requests.

Full background, root-cause analysis, and verification: #1134

How ("A+" strategy)

  • Subset the 5 Noto CJK fonts (SC/TC/HK/JP/KR) to the interface charset (~2.1MB), one font per language (merging corrupts glyph shapes).
  • Bundle full (small) non-CJK fallbacks the primary font lacks: Thai, Arabic, Latin-ext, plus Roboto (engine default fallback).
  • Declare all eager under pubspec fonts: so CanvasKit registers them before first frame — this is what stops CDN probing.
  • Keep fontFallbackBaseUrl on the CDN so online, glyphs outside the subset are fetched on demand.
  • ui_kit v2.28.1 exposes an injectable LocaleFallbackFont.resolver; the app owns the single locale-to-family mapping (lib/localization/fallback_font_resolver.dart).

Commits

  • feat(fonts): bundle CJK/non-Latin subset fonts for offline rendering
  • chore(fonts): remove redundant full CJK engine-fallback fonts (~13MB)
  • chore(tools): add CJK subset font regeneration tooling

Maintenance

Re-run tools/font_subset/regenerate.sh after any change that adds a CJK glyph to interface text (ARB strings, language names, hardcoded literals) — otherwise the subset silently misses it. See tools/font_subset/README.md.

Dependencies

Requires ui_kit v2.28.1 (injectable LocaleFallbackFont hook) — already bumped in pubspec.yaml.

Verification

  • Offline (gstatic blocked): ja/zh-TW/ko/th/ar/ru login + language picker render fully.
  • Online: 0 fonts.gstatic.com/s/notosans* requests for interface text (netlog verified).
  • Online rare glyphs fetched from CDN on demand.
  • Deployed to a router /www and verified on-device.

Closes #1134

AustinChangLinksys and others added 3 commits July 15, 2026 12:15
Ship the interface-charset subset of Noto Sans CJK (SC/TC/HK/JP/KR) plus
full Thai/Arabic/Latin-ext and Roboto, declared eager under pubspec `fonts:`
so the CanvasKit fallback manager finds them locally and never probes the CDN.
CJK drops from 12.7MB to 2.14MB (84%) while every language keeps correct glyphs.

- assets/fonts/fallback/: 8 subset/full woff2 + Roboto (engine default fallback)
- pubspec: eager `fonts:` declarations (packages/ui_kit_library/* + bare Roboto),
  ui_kit bumped to v2.28.1 (adds injectable LocaleFallbackFont hook)
- lib/localization/fallback_font_resolver.dart: single source of the
  locale to family mapping; install() injects it into ui_kit at startup
- app.dart: add per-locale fallback to ThemeData.textTheme (covers raw Text)
- language_tile.dart: per-item Localizations.override so the picker renders
  every language's native name with the correct family
- flutter_bootstrap.js: fontFallbackBaseUrl stays on CDN so online, rare
  user-typed glyphs outside the subset are still fetched on demand

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These full Noto woff2 chunk sets were mirrored locally for CanvasKit's
engine-level fallback. They are now superseded by the eager-bundled subset
fonts (previous commit): interface text is covered by the subset, and rare
user-typed glyphs are filled from the CDN when online. Removing them saves
~13MB of source tree / product weight with no offline regression (verified:
all locales still render offline via the bundled subsets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build-time tool that regenerates the bundled CJK subset fonts from the current
interface charset. MUST be re-run after any change that adds CJK glyphs (ARB
strings, language names, hardcoded literals) — otherwise the subset silently
misses them (offline tofu / online CDN fetch).

- regenerate.sh: one-command pipeline (download full OTFs, extract charset,
  subset, deploy to assets/fonts/fallback/)
- extract_charset.py: unions ARB values + CJK punctuation blocks + picker
  native names + hardcoded CJK in Dart source
- make_test_page.py: renders per-locale samples for glyph-correctness eyeballing
- README documents the "when to re-run" maintenance rule
- .venv/full_fonts/out are reproducible intermediates (gitignored)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Review — Oversize PR

This round's changes exceed the automated-review limit (551 lines / 300 files, limit 6000 lines / 100 files); AI review was not run. Manual review recommended.

First 15 changed files (for a quick scan):

assets/fonts/fallback/NotoSans-Latin.woff2
assets/fonts/fallback/NotoSansArabic.woff2
assets/fonts/fallback/NotoSansCJKhk.subset.woff2
assets/fonts/fallback/NotoSansCJKjp.subset.woff2
assets/fonts/fallback/NotoSansCJKkr.subset.woff2
assets/fonts/fallback/NotoSansCJKsc.subset.woff2
assets/fonts/fallback/NotoSansCJKtc.subset.woff2
assets/fonts/fallback/NotoSansThai.woff2
assets/fonts/fallback/Roboto.woff2
lib/app.dart
lib/components/styled/general_settings_widget/language_tile.dart
lib/localization/fallback_font_resolver.dart
lib/main.dart
pubspec.yaml
tools/font_subset/.gitignore

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Review — Oversize PR

This round's changes exceed the automated-review limit (551 lines / 300 files, limit 6000 lines / 100 files); AI review was not run. Manual review recommended.

First 15 changed files (for a quick scan):

lib/app.dart
lib/components/styled/general_settings_widget/language_tile.dart
lib/localization/fallback_font_resolver.dart
lib/main.dart
pubspec.yaml
tools/font_subset/.gitignore
tools/font_subset/README.md
tools/font_subset/extract_charset.py
tools/font_subset/make_test_page.py
tools/font_subset/regenerate.sh
assets/fonts/fallback/NotoSansCJKhk.subset.woff2
assets/fonts/fallback/NotoSansCJKjp.subset.woff2
assets/fonts/fallback/NotoSansCJKkr.subset.woff2
assets/fonts/fallback/NotoSansCJKsc.subset.woff2
assets/fonts/fallback/NotoSansCJKtc.subset.woff2

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Review — Oversize PR

This round's changes exceed the automated-review limit (551 lines / 300 files,
limit 6000 lines / 100 files); AI review was not run. Manual review recommended.

First 15 changed files (for a quick scan):

assets/fonts/fallback/NotoSans-Latin.woff2
assets/fonts/fallback/NotoSansArabic.woff2
assets/fonts/fallback/NotoSansCJKhk.subset.woff2
assets/fonts/fallback/NotoSansCJKjp.subset.woff2
assets/fonts/fallback/NotoSansCJKkr.subset.woff2
assets/fonts/fallback/NotoSansCJKsc.subset.woff2
assets/fonts/fallback/NotoSansCJKtc.subset.woff2
assets/fonts/fallback/NotoSansThai.woff2
assets/fonts/fallback/Roboto.woff2
lib/app.dart
lib/components/styled/general_settings_widget/language_tile.dart
lib/localization/fallback_font_resolver.dart
lib/main.dart
pubspec.yaml
tools/font_subset/.gitignore

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Review — Oversize PR

This round's changes exceed the automated-review limit (551 lines / 300 files, limit 6000 lines / 100 files); AI review was not run. Manual review recommended.

First 15 changed files (for a quick scan):

assets/fonts/fallback/NotoSans-Latin.woff2
assets/fonts/fallback/NotoSansArabic.woff2
assets/fonts/fallback/NotoSansCJKhk.subset.woff2
assets/fonts/fallback/NotoSansCJKjp.subset.woff2
assets/fonts/fallback/NotoSansCJKkr.subset.woff2
assets/fonts/fallback/NotoSansCJKsc.subset.woff2
assets/fonts/fallback/NotoSansCJKtc.subset.woff2
assets/fonts/fallback/NotoSansThai.woff2
assets/fonts/fallback/Roboto.woff2
lib/app.dart
lib/components/styled/general_settings_widget/language_tile.dart
lib/localization/fallback_font_resolver.dart
lib/localization/font_locale_map.dart
pubspec.yaml
web/index.html

@AustinChangLinksys

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Review — Oversize PR

This round's changes exceed the automated-review limit (551 lines / 300 files, limit 6000 lines / 100 files); AI review was not run. Manual review recommended.

First 15 changed files (for a quick scan):

assets/fonts/fallback/NotoSans-Latin.woff2
assets/fonts/fallback/NotoSansArabic.woff2
assets/fonts/fallback/NotoSansCJKhk.subset.woff2
assets/fonts/fallback/NotoSansCJKjp.subset.woff2
assets/fonts/fallback/NotoSansCJKkr.subset.woff2
assets/fonts/fallback/NotoSansCJKsc.subset.woff2
assets/fonts/fallback/NotoSansCJKtc.subset.woff2
assets/fonts/fallback/NotoSansThai.woff2
assets/fonts/fallback/Roboto.woff2
lib/app.dart
lib/components/styled/general_settings_widget/language_tile.dart
lib/localization/fallback_font_resolver.dart
lib/main.dart
pubspec.yaml
tools/font_subset/.gitignore

@PeterJhongLinksys PeterJhongLinksys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR Review

Verdict: 🟢 Approve — no unresolved critical issues

Findings

  • 🔴 Critical — none
  • 🟡 Warning — web/flutter_bootstrap.js:14 switches fontFallbackBaseUrl from the self-hosted ./assets/ mirror (deleted in this PR) to the public https://fonts.gstatic.com/s/ CDN; interface text stays at 0 requests (bundled), but rare on-demand glyphs now hit a Google third-party endpoint at runtime — a behavioral/privacy shift from the prior zero-external-request design. Intentional per the PR body, flagging for awareness.
  • 🟡 Warning — lib/localization/fallback_font_resolver.dart:56 returns null for Polish/Turkish/Nordic (pl/tr/da/nb/sv/fi), whose ł/ğ/ş/å live in Latin Extended-A; these now rely entirely on the primary font offline (no bundled fallback, CDN unreachable). If NeueHaasGrotTextRound lacks any of those glyphs, offline renders tofu where the old self-hosted mirror would have covered it. vi was explicitly routed to NotoSansLatinExt for exactly this reason — consider whether pl/tr need the same.
  • 🟢 Nit — tools/font_subset/README.md:11 links to an out-of-repo Obsidian vault path (../../../Documents/docs/raw/...), and extract_charset.py:9 docstring points to a different path (doc/theme/...); neither resolves in-repo. make_test_page.py:59 warns to "run subset_fonts.sh first" but the script is regenerate.sh. Resolver class doc says families are declared packages/ui_kit_library/<Family>, but Roboto is declared bare in pubspec.

Already raised (skipped to avoid duplication)

  • "Oversize PR, AI review not run" — AustinChangLinksys automated bot, informational only (no code concern). qodo reviews are paused for this user, so no bot findings exist.

Verified

  • NotoSans-Latin.woff2 genuinely covers Greek (121), Cyrillic (256), Vietnamese-ext (256) glyphs → el/ru/viNotoSansLatinExt mapping is functionally correct despite the name.
  • SC CJK subset (2038 codepoints) covers 100% of the 709 CJK glyphs in app_zh.arb — no silent glyph drop for interface strings.
  • LocaleFallbackFont exists in ui_kit v2.28.1 (tag present, sha a1081d4); AppText.resolve() merges the injected resolver's fallback per-locale, and the bare-name-vs-prefixed distinction the resolver relies on matches ui_kit's copyWith(package:) auto-prefix behavior.
  • CI runs flutter pub get without --enforce-lockfile, so the v2.28.0→v2.28.1 bump resolves correctly even though pubspec.lock (now gitignored) is not in the PR.
  • web/index.html load order preserved (usp_init.js → flutter_bootstrap.js); build_web.sh parameters untouched; no CSP meta tag to break; effectiveLocale in app.dart matches the MaterialApp.locale resolution (appSettings.locale ?? systemLocale).
  • main.dart installs the resolver before runApp(), so AppText fallback is wired before first frame.

@AustinChangLinksys
AustinChangLinksys merged commit 0cc56ef into dev-2.6.0 Jul 16, 2026
2 checks passed
This was referenced Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Offline font bundle: CJK subsetting + zero-CDN fallback (12.7MB → 2.14MB)

2 participants