Skip to content

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 がまだ飛行中かどうかだけで、どちらの方針を採るかはアプリが決めることです。残りはフォント にあります。

自分で動かす ​

sh
cargo run -p font
trunk serve --config examples/font/Trunk.toml

Web フォントはコミットしていません。このサンプルの Trunk.toml にあるビルド前フックが、trunk の初回ビルド時にダウンロードします。ネイティブでは同じ相対 URL に指す先のサーバが無いので、その項目は失敗として報告され、チェーンはサブセットで描きます。レポートにはそれも出ます。

ソースは examples/font/src/lib.rs です。

rust
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}"),
    }
}

MIT または Apache-2.0 ライセンスで公開しています。