use beamterm_data::{FontAtlasData, FontStyle};
use compact_str::CompactString;
use wasm_bindgen::prelude::*;
use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d};
use crate::error::Error;
const PADDING: u32 = FontAtlasData::PADDING as u32;
const OFFSCREEN_CANVAS_WIDTH: u32 = 256;
const GLYPH_BATCH_SIZE: usize = 32;
#[derive(Debug, Clone, Copy)]
pub(super) struct CellMetrics {
padded_width: u32,
padded_height: u32,
ascent: f64,
}
pub(crate) use beamterm_core::gl::RasterizedGlyph;
pub(crate) struct CanvasRasterizer {
canvas: OffscreenCanvas,
render_ctx: OffscreenCanvasRenderingContext2d,
font_family: CompactString,
font_size: f32,
cell_metrics: CellMetrics,
}
impl CanvasRasterizer {
pub(crate) fn new(font_family: &str, font_size: f32) -> Result<Self, Error> {
let canvas = OffscreenCanvas::new(OFFSCREEN_CANVAS_WIDTH, 128)
.map_err(|e| Error::rasterizer_canvas_creation_failed(js_error_string(&e)))?;
let ctx = canvas
.get_context("2d")
.map_err(|e| Error::rasterizer_canvas_creation_failed(js_error_string(&e)))?
.ok_or_else(Error::rasterizer_context_failed)?
.dyn_into::<OffscreenCanvasRenderingContext2d>()
.map_err(|_| Error::rasterizer_context_failed())?;
let font_string = build_font_string(font_family, font_size, FontStyle::Normal);
ctx.set_text_baseline("top");
ctx.set_text_align("left");
ctx.set_font(&font_string);
let cell_metrics = Self::measure_cell_metrics(&ctx)?;
let required_height = GLYPH_BATCH_SIZE as u32 * cell_metrics.padded_height;
canvas.set_height(required_height);
ctx.set_text_baseline("top");
ctx.set_text_align("left");
ctx.set_font(&font_string);
Ok(Self {
canvas,
render_ctx: ctx,
font_family: CompactString::new(font_family),
font_size,
cell_metrics,
})
}
#[allow(clippy::unused_self)] pub(crate) fn max_batch_size(&self) -> usize {
GLYPH_BATCH_SIZE
}
pub(crate) fn rasterize(
&self,
symbols: &[(&str, FontStyle)],
) -> Result<Vec<RasterizedGlyph>, Error> {
if symbols.is_empty() {
return Ok(Vec::new());
}
self.render_ctx.set_fill_style_str("white");
let base_font = build_font_string(&self.font_family, self.font_size, FontStyle::Normal);
self.render_ctx.set_font(&base_font);
let cell_w = self.cell_metrics.padded_width;
let cell_h = self.cell_metrics.padded_height;
let num_glyphs = symbols.len() as u32;
let canvas_width = cell_w * 2;
let canvas_height = cell_h * num_glyphs;
self.render_ctx.clear_rect(
0.0,
0.0,
self.canvas.width() as f64,
self.canvas.height() as f64,
);
let mut current_style: Option<FontStyle> = Some(FontStyle::Normal);
let y_offset = PADDING as f64 + self.cell_metrics.ascent;
for (i, &(grapheme, style)) in symbols.iter().enumerate() {
let effective_style =
if beamterm_core::is_emoji(grapheme) { FontStyle::Normal } else { style };
if current_style != Some(effective_style) {
let font = build_font_string(&self.font_family, self.font_size, effective_style);
self.render_ctx.set_font(&font);
current_style = Some(effective_style);
}
let y = (i as u32 * cell_h) as f64;
self.render_ctx.save();
self.render_ctx.begin_path();
self.render_ctx
.rect(0.0, y, canvas_width as f64, cell_h as f64);
self.render_ctx.clip();
self.render_ctx
.fill_text(grapheme, PADDING as f64, y + y_offset)
.map_err(|e| Error::rasterizer_fill_text_failed(grapheme, js_error_string(&e)))?;
self.render_ctx.restore();
}
let image_data = self
.render_ctx
.get_image_data(0.0, 0.0, canvas_width as f64, canvas_height as f64)
.map_err(|e| Error::rasterizer_get_image_data_failed(js_error_string(&e)))?;
let all_pixels = image_data.data().to_vec();
let bytes_per_pixel = 4usize;
let row_stride = canvas_width as usize * bytes_per_pixel;
let glyph_stride = cell_h as usize * row_stride;
let mut results = Vec::with_capacity(symbols.len());
for (i, &(grapheme, _)) in symbols.iter().enumerate() {
let padded_width =
if beamterm_core::is_double_width(grapheme) { cell_w * 2 } else { cell_w };
let glyph_start = i * glyph_stride;
let mut pixels = Vec::with_capacity((padded_width * cell_h) as usize * bytes_per_pixel);
for row in 0..cell_h as usize {
let row_start = glyph_start + row * row_stride;
let row_end = row_start + (padded_width as usize * bytes_per_pixel);
pixels.extend_from_slice(&all_pixels[row_start..row_end]);
}
results.push(RasterizedGlyph::new(pixels, padded_width, cell_h));
}
Ok(results)
}
pub(super) fn font_family(&self) -> &str {
&self.font_family
}
fn measure_cell_metrics(
render_ctx: &OffscreenCanvasRenderingContext2d,
) -> Result<CellMetrics, Error> {
let buffer_size = 128u32;
let draw_offset = 16.0;
render_ctx.clear_rect(0.0, 0.0, buffer_size as f64, buffer_size as f64);
render_ctx.set_fill_style_str("white");
render_ctx
.fill_text("â–ˆ", draw_offset, draw_offset)
.map_err(|e| Error::rasterizer_measure_failed(js_error_string(&e)))?;
let image_data = render_ctx
.get_image_data(0.0, 0.0, buffer_size as f64, buffer_size as f64)
.map_err(|e| Error::rasterizer_measure_failed(js_error_string(&e)))?;
let pixels = image_data.data();
const ALPHA_THRESHOLD: u8 = 128;
let mut min_x = buffer_size;
let mut max_x = 0u32;
let mut min_y = buffer_size;
let mut max_y = 0u32;
for y in 0..buffer_size {
for x in 0..buffer_size {
let idx = ((y * buffer_size + x) * 4 + 3) as usize; if pixels[idx] >= ALPHA_THRESHOLD {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
if max_x < min_x || max_y < min_y {
return Err(Error::rasterizer_measure_failed(
"reference glyph produced no visible pixels".to_string(),
));
}
let width = max_x - min_x + 1;
let height = max_y - min_y + 1;
let ascent = draw_offset - min_y as f64;
Ok(CellMetrics {
padded_width: width + 2 * PADDING,
padded_height: height + 2 * PADDING,
ascent,
})
}
}
fn js_error_string(err: &JsValue) -> String {
err.as_string()
.unwrap_or_else(|| format!("{err:?}"))
}
fn build_font_string(font_family: &str, font_size: f32, style: FontStyle) -> String {
let (bold, italic) = match style {
FontStyle::Normal => (false, false),
FontStyle::Bold => (true, false),
FontStyle::Italic => (false, true),
FontStyle::BoldItalic => (true, true),
};
let style_str = if italic { "italic " } else { "" };
let weight = if bold { "bold " } else { "" };
format!("{style_str}{weight}{font_size}px {font_family}, monospace")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_font_string() {
assert_eq!(
build_font_string("'Hack'", 16.0, FontStyle::Normal),
"16px 'Hack', monospace"
);
assert_eq!(
build_font_string("'Hack'", 16.0, FontStyle::Bold),
"bold 16px 'Hack', monospace"
);
assert_eq!(
build_font_string("'Hack'", 16.0, FontStyle::Italic),
"italic 16px 'Hack', monospace"
);
assert_eq!(
build_font_string("'Hack'", 16.0, FontStyle::BoldItalic),
"italic bold 16px 'Hack', monospace"
);
}
}