Skip to content
BoWen Chai's Notes
Go back

Add CJK font rendering to eframe and egui

Updated:
Edit page

egui’s default fonts do not contain all CJK glyphs. The following egui and eframe 0.32 examples use Source Han Serif as a fallback.

1. Native and WASM: embed the font

Put SourceHanSerifCN-VF.ttf in assets/fonts/, then register it when creating the app:

use egui::{
    FontData, FontFamily,
    epaint::text::{FontInsert, FontPriority, InsertFontFamily},
};

fn add_cjk_font(ctx: &egui::Context) {
    ctx.add_font(FontInsert::new(
        "source han serif",
        FontData::from_static(include_bytes!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/assets/fonts/SourceHanSerifCN-VF.ttf"
        ))),
        vec![InsertFontFamily {
            family: FontFamily::Proportional,
            priority: FontPriority::Lowest,
        }],
    ));
}

impl MyApp {
    fn new(cc: &eframe::CreationContext<'_>) -> Self {
        add_cjk_font(&cc.egui_ctx);
        Self::default()
    }
}

include_bytes! works for both native and WASM, but adds the font to the application binary.

2. Native: system font; WASM: remote font

Use fontdb to read an installed font on native:

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
fontdb = "0.24"

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["HtmlCanvasElement"] }
fn install_cjk_font(ctx: &egui::Context, font_data: egui::FontData) {
    ctx.add_font(egui::epaint::text::FontInsert::new(
        "cjk",
        font_data,
        vec![egui::epaint::text::InsertFontFamily {
            family: egui::FontFamily::Proportional,
            priority: egui::epaint::text::FontPriority::Lowest,
        }],
    ));
}

#[cfg(not(target_arch = "wasm32"))]
fn add_system_cjk_font(ctx: &egui::Context) {
    let mut database = fontdb::Database::new();
    database.load_system_fonts();

    let id = [
        "Noto Sans CJK SC",
        "Source Han Sans SC",
        "Microsoft YaHei",
        "PingFang SC",
    ]
    .iter()
    .find_map(|&name| {
        let families = [fontdb::Family::Name(name)];
        database.query(&fontdb::Query {
            families: &families,
            ..Default::default()
        })
    })
    .expect("no system CJK font found");

    let (bytes, face_index) = database
        .with_face_data(id, |bytes, index| (bytes.to_vec(), index))
        .expect("failed to read system CJK font");

    let mut font_data = egui::FontData::from_owned(bytes);
    font_data.index = face_index;
    install_cjk_font(ctx, font_data);
}

Call add_system_cjk_font(&cc.egui_ctx) from the native app constructor.

For WASM, export a start function that accepts font bytes:

use wasm_bindgen::prelude::*;
use web_sys::HtmlCanvasElement;

#[wasm_bindgen]
pub async fn start(
    canvas: HtmlCanvasElement,
    font_bytes: Vec<u8>,
) -> Result<(), JsValue> {
    eframe::WebRunner::new()
        .start(
            canvas,
            eframe::WebOptions::default(),
            Box::new(move |cc| {
                install_cjk_font(
                    &cc.egui_ctx,
                    egui::FontData::from_owned(font_bytes),
                );
                Ok(Box::new(MyApp::default()))
            }),
        )
        .await
        .map_err(|error| JsValue::from_str(&format!("{error:?}")))
}

Load the remote font in TypeScript and inject it into WASM:

import init, { start } from "./pkg/my_app.js";

const fontUrl = "https://cdn.example.com/SourceHanSerifCN-VF.ttf";

const [, response] = await Promise.all([init(), fetch(fontUrl)]);
if (!response.ok) throw new Error(`font download failed: ${response.status}`);

const canvas = document.querySelector<HTMLCanvasElement>("#egui_canvas")!;
const fontBytes = new Uint8Array(await response.arrayBuffer());

await start(canvas, fontBytes);

In both approaches, FontPriority::Lowest keeps the default font for Latin text and uses the CJK font only as a fallback.


Edit page
Share this post:

Next Post
Write Markdown Lexer and Parser in Zig