From 13ae49a8bed707dad7d6956e96d3b3fd256e1c08 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Sun, 18 Jan 2026 15:43:55 +0100 Subject: [PATCH] Add a canvas based font fallback --- Cargo.lock | 2 + crates/epaint/Cargo.toml | 11 ++ crates/epaint/src/text/canvas_renderer.rs | 225 ++++++++++++++++++++++ crates/epaint/src/text/font.rs | 136 +++++++++++-- crates/epaint/src/text/fonts.rs | 17 ++ crates/epaint/src/text/mod.rs | 3 + 6 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 crates/epaint/src/text/canvas_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index a99c0d281..bf99d173b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1622,6 +1622,8 @@ dependencies = [ "similar-asserts", "skrifa", "vello_cpu", + "wasm-bindgen", + "web-sys", ] [[package]] diff --git a/crates/epaint/Cargo.toml b/crates/epaint/Cargo.toml index 77facdb3f..944443792 100644 --- a/crates/epaint/Cargo.toml +++ b/crates/epaint/Cargo.toml @@ -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 diff --git a/crates/epaint/src/text/canvas_renderer.rs b/crates/epaint/src/text/canvas_renderer.rs new file mode 100644 index 000000000..bc307cb25 --- /dev/null +++ b/crates/epaint/src/text/canvas_renderer.rs @@ -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, + /// 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 { + 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::()?; + + // 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::()?; + + 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 { + // 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 { + 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> = 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 { + ensure_canvas_renderer().ok()?; + + CANVAS_RENDERER.with(|renderer_cell| { + renderer_cell + .borrow_mut() + .as_mut()? + .render_glyph(chr, metrics, font_families, bin) + }) +} diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 150aca34a..96c0467e6 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -204,10 +204,10 @@ impl FontCell { ) -> Option { 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, glyph_alloc_cache: ahash::HashMap, + + /// 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 { + 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 = 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}') diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 19876b571..709d45859 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -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 } } diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index b40ba45b8..6f9b04c75 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -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;