font
CSS 風のフォントチェーン: 同梱・取得・インストール済みのフォントと、各項目の解決先。
egui は渡されたフォントのバイト列からテキストを描きます。ブラウザのフォントもOS のフォントマッチングも一切関わりませんし、egui 自身の 4 フォントには CJK のグリフがないので、素の egui アプリでは日本語が豆腐になります。このサンプルはその答えです。名前付きのチェーンが 4 つ、ソースの種類ごとに 1 つずつあります。bundled は include_bytes! で焼き込んだ 433 KB のサブセット。web はアプリ自身のオリジンから 4.5 MB のフル版 Noto Sans JP を取得します。system はインストール済みのフォントをマシンに尋ねます。code は等幅のチェーンで、egui の Hack の後ろに、Hack に無いかなと漢字のためのサブセットを置いています。
見本の下のレポート表が本題です。どのチェーンのどの項目についても、それがどのフェイスに解決されたか ― そのフェイスが名乗るファミリ名つきで。System の項目に必要な綴りは、こうやって見つけます ― あるいは、なぜ解決されなかったかが出ます。ネイティブで system のスタックを選べば fontconfig の答えが見えます。ブラウザで選べば Local Font Access の道になりますが、これは Chromium 限定で、許可のプロンプトが要り、クリックから呼ぶ必要があります。だからそれ以外のブラウザでは、ボタンが理由つきで無効になっています。
上の埋め込みは、要求に応じて 4.5 MB のフォントを取得します。 web のスタックを選ぶとダウンロードが始まります。届くまでは、チェーンでその後ろにいる同梱サブセットがテキストを描き、バイト列が届くと項目が pending から loaded に変わります。これが CSS の swap です。font-display の行でそれを block に切り替えられます。block は Fonts::pending() が false になるまでプレースホルダを描きます。ライブラリが報告するのは URL がまだ飛行中かどうかだけで、どちらの方針を採るかはアプリが決めることです。残りはフォント にあります。
自分で動かす
cargo run -p font
trunk serve --config examples/font/Trunk.tomlWeb フォントはコミットしていません。このサンプルの Trunk.toml にあるビルド前フックが、trunk の初回ビルド時にダウンロードします。ネイティブでは同じ相対 URL に指す先のサーバが無いので、その項目は失敗として報告され、チェーンはサブセットで描きます。レポートにはそれも出ます。
ソースは examples/font/src/lib.rs です。
use std::sync::LazyLock;
use egui_reactor::prelude::*;
use egui_reactor_app::fonts::{FontSource, Fonts, Generic, Outcome};
use egui_reactor_elements::prelude::*;
/// The subset of Noto Sans JP compiled into the binary: ASCII, Latin-1, kana,
/// CJK punctuation, fullwidth forms and about five hundred kanji.
const NOTO_SANS_JP_SUBSET: &[u8] = include_bytes!("../fonts/NotoSansJP-Subset.ttf");
/// Where the `web` stack fetches the full font from: next to `index.html`.
pub const WEB_FONT_URL: &str = "fonts/NotoSansJP-Regular.otf";
/// The stacks, in the order the buttons show them.
pub const STACKS: [&str; 4] = ["bundled", "web", "system", "code"];
/// The stack an app normally uses on the target this build runs on, and the one
/// the example starts on. Natively `system`: the fonts are already installed, so
/// there is nothing to ship. On wasm `web`: the browser cannot read installed
/// fonts, so the app serves one from its own origin — `bundled` instead when a
/// subset is small enough, and then there is nothing to wait for either.
pub fn typical() -> &'static str {
if cfg!(target_arch = "wasm32") {
"web"
} else {
"system"
}
}
/// The `★` line under the stack buttons: why that stack is the typical one here.
pub fn typical_note() -> &'static str {
if cfg!(target_arch = "wasm32") {
"★ = what an app normally uses on this target: a font fetched from the app's own origin \
(a bundled subset when it is small enough)"
} else {
"★ = what an app normally uses on this target: fonts installed on the machine, nothing to \
ship"
}
}
/// One row per source kind: the stack, then what an app does with it natively
/// and in the browser. Drawn as the little matrix under `how`, because the
/// answer is a pair and picking one target hides half of it.
pub const MATRIX: [(&str, &str, &str); 3] = [
(
"bundled",
"typical for small subsets",
"typical for small subsets",
),
("web", "works (any HTTP URL)", "typical"),
(
"system",
"typical",
"opt-in: Local Font Access, Chromium only, permission prompt",
),
];
/// `▶ ` in front of the matrix column this build runs on, so the reader knows
/// which of the two they are reading. `column` is `"native"` or `"web"`.
pub fn here(column: &str) -> &'static str {
if (column == "web") == cfg!(target_arch = "wasm32") {
"▶ "
} else {
""
}
}
/// The two `font-display` policies the example can draw, first is the default.
pub const DISPLAYS: [&str; 2] = ["swap", "block"];
/// What `block` draws while a URL is still in flight.
pub const LOADING: &str = "loading fonts…";
/// What is drawn with the selected stack. Every character is in the subset.
pub const SAMPLES: [&str; 5] = [
"日本語のテキストが表示できます。",
"フォントのフォールバックチェーン",
"吾輩は猫である。名前はまだ無い。",
"東京都渋谷区、2026年9月7日",
"egui-reactor で CSS の font-family のように書ける",
];
/// The one `Fonts` of the process. `Fonts` is a handle (a clone shares the
/// database), so `main.rs` applies it from `Options::setup`, `App` applies
/// it on its first frame, and the components read its report every frame.
static FONTS: LazyLock<Fonts> = LazyLock::new(build_fonts);
/// A handle to the process-wide [`Fonts`].
pub fn fonts() -> Fonts {
FONTS.clone()
}
/// The three chains, as an app would write them.
pub fn build_fonts() -> Fonts {
Fonts::new()
.stack(
"bundled",
[
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"web",
[
FontSource::Url(WEB_FONT_URL.into()),
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"system",
[
FontSource::System("Hiragino Sans".into()),
FontSource::System("Yu Gothic UI".into()),
FontSource::System("Noto Sans CJK JP".into()),
FontSource::System("Noto Sans JP".into()),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"code",
[
FontSource::Generic(Generic::Monospace),
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.default_proportional("bundled")
.default_monospace("code")
}
#[component]
pub fn App(cx: &mut Cx) {
// Start on the stack an app would actually use here, not on the first of
// the list: the example is read as advice as much as a demo.
let mut stack = use_state(cx, typical);
let current: &'static str = *stack;
let mut display = use_state(cx, || DISPLAYS[0]);
let policy: &'static str = *display;
// `pending()` is read every frame; the fetch that finishes asks for a
// repaint, so the samples come back on their own when the bytes land.
let waiting = policy == "block" && fonts().pending();
let weak = cx.ui().visuals().weak_text_color();
// The gallery runs `App` inside its own runner and has no `setup` hook
// per example, so the stacks are applied here, once. Under `main.rs` the
// setup already did it and this changes nothing: `apply` only calls
// `set_fonts` when the definitions differ from the last time.
let ctx = cx.ctx().clone();
use_effect(cx, (), move || {
fonts().apply(&ctx);
// New fonts take effect at the start of the next pass.
ctx.request_repaint();
});
// On the frame that applied them the names are not registered yet, and
// `<Text font>` would warn about that (once) before falling back. The
// built-in family is always registered, so it stands in for that frame.
let ready = cx.ctx().fonts(|f| {
f.definitions()
.families
.contains_key(&egui::FontFamily::Name(current.into()))
});
let font = if ready { current } else { "proportional" };
rsx! {
// The root fills whatever area it is given, so the gallery can drop it
// into a column of its own.
<View direction="column" grow={1.0}>
<ScrollArea grow={1.0}>
<View direction="column" gap={8} p={12} w="100%">
<Text size={22.0} strong>"font"</Text>
<View direction="row" gap={8} align="center">
<Text>"stack:"</Text>
for name in STACKS {
// The typical one wears the star; the note under
// the row says what the star means.
<Button key={name} on_click={|| *stack = name}>
{if name == typical() {
format!("{name} ★")
} else {
name.to_string()
}}
</Button>
}
<Text>{format!("<Text font=\"{current}\">")}</Text>
</View>
<Text wrap w="100%">{typical_note()}</Text>
// How this stack gets its bytes on the target this build
// runs on; `wrap` needs a width, which `w` gives it.
<Text wrap w="100%">{how(current)}</Text>
<Matrix/>
// What to draw while a URL is in flight. The report below
// keeps showing that entry going pending → loaded either
// way; only the samples change.
<View direction="row" gap={8} align="center" w="100%">
<Text>"font-display:"</Text>
for name in DISPLAYS {
<Button key={name} on_click={|| *display = name}>{name}</Button>
}
<Text grow={1.0} wrap>{policy_note(policy)}</Text>
</View>
if waiting {
// egui has no "laid out but invisible" text, so FOIT
// is a placeholder in the samples' place.
<Text size={20.0} color={weak}>{LOADING}</Text>
} else {
<View direction="column" gap={4}>
for (i, sample) in SAMPLES.iter().enumerate() {
<Text key={i} font={font} size={20.0}>{*sample}</Text>
}
</View>
}
<Separator/>
<LocalFonts/>
<Separator/>
<Report/>
</View>
</ScrollArea>
</View>
}
}
/// The two targets side by side: which stack an app uses where, in three rows.
/// `how` only speaks about the target this build runs on, and the choice is
/// really a pair, so the other column is spelled out rather than implied. The
/// column the build is on is marked, since both are drawn the same.
#[component]
fn Matrix(cx: &mut Cx) {
rsx! {
<View direction="column" gap={2} w="100%">
<Text strong>"where each stack gets its bytes"</Text>
<View direction="row" gap={8} w="100%">
<Text w={80.0} strong>"stack"</Text>
<Text grow={1.0} strong>{format!("{}native", here("native"))}</Text>
<Text grow={1.0} strong>{format!("{}web", here("web"))}</Text>
</View>
for (i, (name, native, web)) in MATRIX.iter().enumerate() {
<View key={i} direction="row" gap={8} w="100%">
<Text w={80.0}>{*name}</Text>
// The cells wrap, so the widths have to come from the row:
// `w` for the name, `grow` for the two that can be long.
<Text grow={1.0} wrap>{format!("{}{native}", here("native"))}</Text>
<Text grow={1.0} wrap>{format!("{}{web}", here("web"))}</Text>
</View>
}
</View>
}
}
/// The Local Font Access button. On wasm it is enabled when the browser has
/// `window.queryLocalFonts`; everywhere else it is disabled with the reason next to
/// it, so the example is one source file.
#[component]
fn LocalFonts(cx: &mut Cx) {
// The request is `async` and finishes on the browser's event loop (on a
// thread of its own natively, where it answers `Ok(0)` at once), so the
// result comes back through a `Dispatch`: it is `Send`, and `send` asks
// for the repaint that shows it.
let (status, dispatch) = use_reducer(
cx,
|status: &mut String, message: String| *status = message,
String::new,
);
let available = Fonts::local_fonts_available();
let note = if cfg!(target_arch = "wasm32") {
if available {
"Chromium asks for permission once; the \"system\" stack is then resolved from the grant."
} else {
"Local Font Access: Chromium only"
}
} else {
"installed fonts are loaded automatically"
};
rsx! {
<View direction="column" gap={4} w="100%">
<View direction="row" gap={8} align="center" w="100%">
<Button enabled={available} on_click={|| {
let fonts = fonts();
let dispatch = dispatch.clone();
// `spawn` is `wasm_bindgen_futures::spawn_local` on wasm, so
// the call still counts as coming from the click, which the
// permission prompt requires.
spawn(async move {
let message = match fonts.request_local_fonts().await {
Ok(n) => format!("{n} of the named families found on this machine"),
Err(err) => format!("local fonts: {err}"),
};
dispatch.send(message);
});
}}>
"Use my fonts"
</Button>
// The note is long; `grow` gives `wrap` a width instead of
// letting the row run past the pane.
<Text grow={1.0} wrap>{note}</Text>
</View>
if !status.is_empty() {
<Text strong>{status.as_str()}</Text>
}
</View>
}
}
/// The report: every entry of every stack and what it became on the last
/// `apply`. Read every frame; it is a lock and a clone of a dozen entries, and
/// it is how the `web` entry is seen going from `pending` to `loaded`.
#[component]
fn Report(cx: &mut Cx) {
let report = fonts().report();
rsx! {
<View direction="column" gap={4} w="100%">
<Text strong>"report"</Text>
for (i, stack) in report.iter().enumerate() {
<View key={i} direction="column" gap={2} w="100%" mt={4}>
<Text strong>{format!("stack \"{}\"", stack.name)}</Text>
for (j, (source, outcome)) in stack.entries.iter().enumerate() {
<View key={j} direction="row" gap={8} w="100%">
<Text w={280.0}>{source.to_string()}</Text>
// A failure message can be long; `wrap` needs a
// width, which `grow` gives it in a sized row.
<Text grow={1.0} wrap>{describe(outcome)}</Text>
</View>
}
</View>
}
</View>
}
}
/// How a stack gets its bytes on the target this build runs on. Shown under
/// the stack buttons, so the report below it can be read as "this is what
/// that produced".
pub fn how(stack: &str) -> &'static str {
let wasm = cfg!(target_arch = "wasm32");
match (stack, wasm) {
("bundled", true) => {
"Compiled into the .wasm with include_bytes! (a 433 KB subset of Noto Sans JP) and \
registered from Options::setup, before the first frame. Nothing to wait for and \
nothing to fetch; the cost is binary size, which is why it is a subset."
}
("bundled", false) => {
"Compiled into the binary with include_bytes! (a 433 KB subset of Noto Sans JP) and \
registered from Options::setup, before the first frame."
}
("web", true) => {
"fetch() of fonts/NotoSansJP-Regular.otf (4.5 MB) from the page's own origin, \
started by the first apply. Until the bytes arrive the entry is pending and the \
subset behind it draws; when they land the chains are applied again and egui \
rebuilds its glyph atlas once. A cross-origin URL would need CORS headers."
}
("web", false) => {
"An HTTP fetch (ureq on a thread). Here the relative URL has no server to point at, \
so the entry is failed and the subset behind it draws; in the browser the same \
URL is fetched from the page's origin."
}
("system", true) => {
"The browser cannot read installed fonts, so each name is missing until \"Use my \
fonts\" asks for them through the Local Font Access API (Chromium only, a \
permission prompt, has to start from a click). The granted files go into the same \
database and this chain is resolved again. \"Noto Sans JP\" already matches the \
bundled subset, which is why Japanese draws before any grant."
}
("system", false) => {
"fontdb scans the OS font directories once (load_system_fonts) and each name is \
matched against the installed families as CSS would; a name no font declares is \
missing and skipped, and the report shows the family each face declares."
}
("code", _) => {
"The monospace generic first (egui's Hack in the browser, the installed monospace \
default natively), then the bundled subset for the kana and kanji Hack lacks. It \
is also the Monospace default, which the gallery's source pane draws with."
}
_ => "",
}
}
/// What the selected `font-display` policy does, in one line.
pub fn policy_note(policy: &str) -> &'static str {
match policy {
"block" => "draw a placeholder until every URL arrived or failed (FOIT)",
_ => "draw with what stands behind a pending URL, then swap (FOUT)",
}
}
/// One line per outcome, for the report table.
pub fn describe(outcome: &Outcome) -> String {
match outcome {
Outcome::Loaded { key, family } => format!("loaded: {family} ({key})"),
Outcome::Pending => String::from("pending: the bytes have not arrived yet"),
Outcome::Missing => String::from("missing: no face with this family name"),
Outcome::Invalid(why) => format!("invalid: {why}"),
Outcome::Failed(why) => format!("failed: {why}"),
}
}