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:
@@ -1622,6 +1622,8 @@ dependencies = [
|
||||
"similar-asserts",
|
||||
"skrifa",
|
||||
"vello_cpu",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
225
crates/epaint/src/text/canvas_renderer.rs
Normal file
225
crates/epaint/src/text/canvas_renderer.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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}')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user