1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Add a canvas based font fallback

This commit is contained in:
lucasmerlin
2026-01-18 15:43:55 +01:00
parent 73b7b9e225
commit 13ae49a8be
6 changed files with 378 additions and 16 deletions

View File

@@ -1622,6 +1622,8 @@ dependencies = [
"similar-asserts",
"skrifa",
"vello_cpu",
"wasm-bindgen",
"web-sys",
]
[[package]]

View File

@@ -83,6 +83,17 @@ serde = { workspace = true, optional = true, features = ["derive", "rc"] }
epaint_default_fonts = { workspace = true, optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen.workspace = true
web-sys = { workspace = true, features = [
"CanvasRenderingContext2d",
"HtmlCanvasElement",
"ImageData",
"TextMetrics",
"Document",
"Window",
] }
[dev-dependencies]
criterion.workspace = true
mimalloc.workspace = true

View File

@@ -0,0 +1,225 @@
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
use crate::text::font::ScaledMetrics;
/// Data for a glyph rendered via HTML5 canvas
#[derive(Debug, Clone)]
pub struct CanvasGlyphData {
/// RGBA pixel data from canvas ImageData
pub image_data: Vec<u8>,
/// Width of the glyph in pixels
pub width: u32,
/// Height of the glyph in pixels
pub height: u32,
/// Advance width for horizontal text layout
pub advance_width: f32,
/// Horizontal offset from origin
pub offset_x: f32,
/// Vertical offset from origin (baseline)
pub offset_y: f32,
}
/// Renders glyphs using HTML5 canvas (WASM only)
///
/// This renderer uses the browser's native text rendering capabilities
/// to rasterize glyphs that are not available in the bundled fonts.
pub struct CanvasGlyphRenderer {
canvas: HtmlCanvasElement,
context: CanvasRenderingContext2d,
}
impl CanvasGlyphRenderer {
/// Create a new canvas glyph renderer
///
/// Returns an error if canvas creation or context acquisition fails
pub fn new() -> Result<Self, JsValue> {
let window = web_sys::window().ok_or("No window object")?;
let document = window.document().ok_or("No document object")?;
let canvas = document
.create_element("canvas")?
.dyn_into::<HtmlCanvasElement>()?;
// Start with a small canvas, will resize as needed
canvas.set_width(128);
canvas.set_height(128);
let context = canvas
.get_context("2d")?
.ok_or("Failed to get 2d context")?
.dyn_into::<CanvasRenderingContext2d>()?;
Ok(Self { canvas, context })
}
/// Render a glyph using canvas
///
/// Tries each font family in order until one renders the character successfully.
/// Returns None if the character cannot be rendered or has zero width.
pub fn render_glyph(
&mut self,
chr: char,
metrics: &ScaledMetrics,
font_families: &[String],
bin: crate::text::font::SubpixelBin,
) -> Option<CanvasGlyphData> {
// metrics.scale is the absolute font size in pixels (includes DPI and zoom)
let font_size_px = metrics.scale;
let subpixel_offset = bin.as_float();
// Try each font family in the fallback chain
for family in font_families {
if let Some(data) = self.try_render_with_font(chr, font_size_px, subpixel_offset, family) {
return Some(data);
}
}
// Try with generic sans-serif as last resort
self.try_render_with_font(chr, font_size_px, subpixel_offset, "sans-serif")
}
/// Try to render a glyph with a specific font family
fn try_render_with_font(
&mut self,
chr: char,
font_size_px: f32,
subpixel_offset: f32,
font_family: &str,
) -> Option<CanvasGlyphData> {
let font_string = format!("{}px {}", font_size_px, font_family);
self.context.set_font(&font_string);
let text = chr.to_string();
// Measure the text to get metrics
let text_metrics = self.context.measure_text(&text).ok()?;
let advance_width = text_metrics.width() as f32;
// Skip if character is not supported (zero width)
if advance_width < 0.1 {
return None;
}
// Get bounding box metrics
let ascent = text_metrics.actual_bounding_box_ascent();
let descent = text_metrics.actual_bounding_box_descent();
let left = text_metrics.actual_bounding_box_left();
let right = text_metrics.actual_bounding_box_right();
// Canvas measureText returns values at the specified font size
// We render at font_size_px which is already in the right scale
let width = (right + left).ceil() as u32;
let height = (ascent + descent).ceil() as u32;
// Skip zero-size glyphs
if width == 0 || height == 0 {
return None;
}
// Limit maximum glyph size to prevent excessive memory usage
const MAX_GLYPH_SIZE: u32 = 256;
if width > MAX_GLYPH_SIZE || height > MAX_GLYPH_SIZE {
log::warn!(
"Glyph '{}' too large ({}x{}), max size is {}x{}",
chr,
width,
height,
MAX_GLYPH_SIZE,
MAX_GLYPH_SIZE
);
return None;
}
// Resize canvas if needed
if width > self.canvas.width() || height > self.canvas.height() {
self.canvas.set_width(width.max(128));
self.canvas.set_height(height.max(128));
}
// Clear the canvas
self.context.clear_rect(
0.0,
0.0,
self.canvas.width() as f64,
self.canvas.height() as f64,
);
// Set up rendering
self.context.set_fill_style_str("white");
self.context.set_text_baseline("alphabetic");
// Render the text at the correct position
// The baseline is at y = ascent, and we offset x by left bearing + subpixel offset
if let Err(e) = self.context.fill_text(&text, left + subpixel_offset as f64, ascent) {
log::debug!("Failed to render '{}': {:?}", chr, e);
return None;
}
// Extract image data (now at device pixel resolution)
let image_data = self
.context
.get_image_data(0.0, 0.0, width as f64, height as f64)
.ok()?;
let rgba_data = image_data.data().0;
log::debug!(
"Canvas glyph '{}': advance={}, size={}x{}, offset=({}, {}), font_size={}px",
chr, advance_width, width, height, -left as f32, -ascent as f32, font_size_px
);
Some(CanvasGlyphData {
image_data: rgba_data,
width,
height,
advance_width,
offset_x: -left as f32,
offset_y: -ascent as f32,
})
}
}
thread_local! {
static CANVAS_RENDERER: RefCell<Option<CanvasGlyphRenderer>> = RefCell::new(None);
}
/// Initialize the canvas renderer if not already initialized
fn ensure_canvas_renderer() -> Result<(), JsValue> {
CANVAS_RENDERER.with(|renderer_cell| {
if renderer_cell.borrow().is_none() {
match CanvasGlyphRenderer::new() {
Ok(renderer) => {
*renderer_cell.borrow_mut() = Some(renderer);
Ok(())
}
Err(e) => {
log::warn!("Failed to create canvas renderer: {:?}", e);
Err(e)
}
}
} else {
Ok(())
}
})
}
/// Render a glyph using the thread-local canvas renderer
pub fn render_glyph_with_canvas(
chr: char,
metrics: &ScaledMetrics,
font_families: &[String],
bin: crate::text::font::SubpixelBin,
) -> Option<CanvasGlyphData> {
ensure_canvas_renderer().ok()?;
CANVAS_RENDERER.with(|renderer_cell| {
renderer_cell
.borrow_mut()
.as_mut()?
.render_glyph(chr, metrics, font_families, bin)
})
}

View File

@@ -204,10 +204,10 @@ impl FontCell {
) -> Option<GlyphAllocation> {
let glyph_id = glyph_info.id?;
debug_assert!(
glyph_id != skrifa::GlyphId::NOTDEF,
"Can't allocate glyph for id 0"
);
// Return None for NOTDEF - this will trigger canvas fallback on WASM
if glyph_id == skrifa::GlyphId::NOTDEF {
return None;
}
let mut path = kurbo::BezPath::new();
let mut pen = VelloPen {
@@ -341,6 +341,11 @@ pub struct FontFace {
location: skrifa::instance::Location,
glyph_info_cache: ahash::HashMap<char, GlyphInfo>,
glyph_alloc_cache: ahash::HashMap<GlyphCacheKey, GlyphAllocation>,
/// Cache for canvas-rendered glyphs (WASM only)
/// Key: (character, pixels_per_point bits, px_scale_factor bits, subpixel bin)
#[cfg(target_arch = "wasm32")]
canvas_glyph_cache: ahash::HashMap<(char, u32, u32, SubpixelBin), GlyphAllocation>,
}
impl FontFace {
@@ -436,6 +441,8 @@ impl FontFace {
location,
glyph_info_cache: Default::default(),
glyph_alloc_cache: Default::default(),
#[cfg(target_arch = "wasm32")]
canvas_glyph_cache: Default::default(),
})
}
@@ -614,26 +621,65 @@ impl FontFace {
SubpixelBin::new(h_pos)
};
let entry = match self
.glyph_alloc_cache
.entry(GlyphCacheKey::new(glyph_id, metrics, bin))
{
std::collections::hash_map::Entry::Occupied(glyph_alloc) => {
let mut glyph_alloc = *glyph_alloc.get();
glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space).
return (glyph_alloc, h_pos_round);
}
std::collections::hash_map::Entry::Vacant(entry) => entry,
};
// For canvas glyphs (NOTDEF), we need to use the character for caching
// because all canvas glyphs have the same glyph_id
#[cfg(target_arch = "wasm32")]
if glyph_id == skrifa::GlyphId::NOTDEF {
// Use character-based cache key for canvas glyphs
// Include bin for subpixel positioning
let canvas_cache_key = (chr, metrics.pixels_per_point.to_bits(), metrics.px_scale_factor.to_bits(), bin);
// Check canvas glyph cache
if let Some(&cached_alloc) = self.canvas_glyph_cache.get(&canvas_cache_key) {
return (cached_alloc, h_pos_round);
}
// Render with canvas at subpixel offset
let allocation = Self::try_allocate_canvas_glyph_static(atlas, metrics, chr, bin).unwrap_or_default();
// Cache it
self.canvas_glyph_cache.insert(canvas_cache_key, allocation);
return (allocation, h_pos_round);
}
// Check cache first (for non-canvas glyphs)
let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin);
if let Some(&cached_alloc) = self.glyph_alloc_cache.get(&cache_key) {
let mut glyph_alloc = cached_alloc;
glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space).
return (glyph_alloc, h_pos_round);
}
// Allocate the glyph
let allocation = self
.font
.allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, &self.location)
.unwrap_or_default();
entry.insert(allocation);
// Insert into cache
self.glyph_alloc_cache.insert(cache_key, allocation);
(allocation, h_pos_round)
}
#[cfg(target_arch = "wasm32")]
fn try_allocate_canvas_glyph_static(
atlas: &mut TextureAtlas,
metrics: &ScaledMetrics,
chr: char,
bin: SubpixelBin,
) -> Option<GlyphAllocation> {
use crate::text::canvas_renderer;
// Build font family list - just use a generic font for now
// TODO: We could pass the full font family list from CachedFamily
let font_families: Vec<String> = vec!["sans-serif".to_string()];
// Try to render the glyph with subpixel offset
let canvas_data = canvas_renderer::render_glyph_with_canvas(chr, metrics, &font_families, bin)?;
// Allocate the canvas-rendered glyph
Some(allocate_canvas_glyph(atlas, metrics, canvas_data))
}
}
// TODO(emilk): rename?
@@ -787,6 +833,64 @@ fn invisible_char(c: char) -> bool {
)
}
// ----------------------------------------------------------------------------
#[cfg(target_arch = "wasm32")]
pub(super) fn allocate_canvas_glyph(
atlas: &mut TextureAtlas,
metrics: &ScaledMetrics,
canvas_data: crate::text::canvas_renderer::CanvasGlyphData,
) -> GlyphAllocation {
use crate::text::canvas_renderer::CanvasGlyphData;
let CanvasGlyphData {
image_data,
width,
height,
advance_width,
offset_x,
offset_y,
} = canvas_data;
// Get alpha_from_coverage before allocating
let alpha_from_coverage = atlas.options().alpha_from_coverage;
// Allocate space in the texture atlas
let (glyph_pos, image) = atlas.allocate((width as usize, height as usize));
// Convert RGBA to alpha channel
// Canvas ImageData is RGBA, we need alpha channel only
for y in 0..height as usize {
for x in 0..width as usize {
let idx = (y * width as usize + x) * 4;
// Use alpha channel from ImageData
let alpha = image_data[idx + 3] as f32 / 255.0;
image[(x + glyph_pos.0, y + glyph_pos.1)] =
alpha_from_coverage.color_from_coverage(alpha);
}
}
// Calculate offset in points
let offset_in_points =
Vec2::new(offset_x, offset_y) / metrics.pixels_per_point + metrics.y_offset_in_points * Vec2::Y;
GlyphAllocation {
id: skrifa::GlyphId::NOTDEF, // Mark as canvas-rendered
advance_width_px: advance_width,
uv_rect: UvRect {
offset: offset_in_points,
size: Vec2::new(width as f32, height as f32) / metrics.pixels_per_point,
min: [glyph_pos.0 as u16, glyph_pos.1 as u16],
max: [
(glyph_pos.0 + width as usize) as u16,
(glyph_pos.1 + height as usize) as u16,
],
},
}
}
// ----------------------------------------------------------------------------
#[inline]
pub(super) fn is_cjk_ideograph(c: char) -> bool {
('\u{4E00}' <= c && c <= '\u{9FFF}')

View File

@@ -525,6 +525,23 @@ impl CachedFamily {
return Some((*font_key, glyph_info));
}
}
// Try canvas fallback for WASM
#[cfg(target_arch = "wasm32")]
{
// Use a special GlyphInfo to indicate canvas rendering is needed
// We use NOTDEF as the glyph ID to mark this as a canvas glyph
// The advance width will be filled in when we actually render it
let canvas_glyph_info = GlyphInfo {
id: Some(skrifa::GlyphId::NOTDEF),
advance_width_unscaled: OrderedFloat(0.0), // Will be filled in during rendering
};
// Use first font key as placeholder (the actual font family list will be used during rendering)
if let Some(&first_font_key) = self.fonts.first() {
return Some((first_font_key, canvas_glyph_info));
}
}
None
}
}

View File

@@ -6,6 +6,9 @@ mod fonts;
mod text_layout;
mod text_layout_types;
#[cfg(target_arch = "wasm32")]
mod canvas_renderer;
/// One `\t` character is this many spaces wide.
pub const TAB_SIZE: usize = 4;