diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index d4ddfb5eb275..8cbcb653d0d9 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -1,7 +1,7 @@ pub use crate::loader::Error as DialogLoaderError; use crate::{ backend::navigator::OwnedFuture, - font::{FontFileData, FontQuery}, + font::{FontFileData, FontQuery, FontRenderer}, }; use chrono::{DateTime, Utc}; use fluent_templates::loader::langid; @@ -24,6 +24,14 @@ pub enum FontDefinition<'a> { data: FontFileData, index: u32, }, + + /// Font rendered externally. + ExternalRenderer { + name: String, + is_bold: bool, + is_italic: bool, + font_renderer: Box, + }, } /// A filter specifying a category that can be selected from a file chooser dialog diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index 602796e35b5a..cbefc946a487 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -19,7 +19,7 @@ use crate::events::{ ClipEvent, ClipEventResult, ImeCursorArea, ImeEvent, ImeNotification, ImePurpose, PlayerNotification, TextControlCode, }; -use crate::font::{FontLike, FontType, Glyph, TextRenderSettings}; +use crate::font::{FontLike, FontType, TextRenderSettings}; use crate::html; use crate::html::StyleSheet; use crate::html::{ @@ -1272,8 +1272,8 @@ impl<'gc> EditText<'gc> { text, self.text_transform(color), params, - |pos, transform, glyph: &Glyph, advance, x| { - if let Some(glyph_shape_handle) = glyph.shape_handle(context.renderer) { + |pos, transform, glyph, advance, x| { + if glyph.renderable(context) { // If it's highlighted, override the color. if matches!(visible_selection, Some(visible_selection) if visible_selection.contains(start + pos)) { // Set text color to white @@ -1285,11 +1285,7 @@ impl<'gc> EditText<'gc> { } else { context.transform_stack.push(transform); } - - // Render glyph. - context - .commands - .render_shape(glyph_shape_handle, context.transform_stack.transform()); + glyph.render(context); context.transform_stack.pop(); } @@ -1629,7 +1625,7 @@ impl<'gc> EditText<'gc> { text, self.text_transform(color), params, - |pos, _transform, _glyph: &Glyph, advance, x| { + |pos, _transform, _glyph, advance, x| { if local_position.x >= x { if local_position.x > x + (advance / 2) { result = string_utils::next_char_boundary(text, pos); diff --git a/core/src/display_object/text.rs b/core/src/display_object/text.rs index 4740801fcf61..9874e16a944b 100644 --- a/core/src/display_object/text.rs +++ b/core/src/display_object/text.rs @@ -11,7 +11,6 @@ use gc_arena::barrier::unlock; use gc_arena::Lock; use gc_arena::{Collect, Gc, Mutation}; use ruffle_common::utils::HasPrefixField; -use ruffle_render::commands::CommandHandler; use ruffle_render::transform::Transform; use ruffle_wstr::WString; use std::cell::RefCell; @@ -166,12 +165,9 @@ impl<'gc> TDisplayObject<'gc> for Text<'gc> { transform.color_transform.set_mult_color(color); for c in &block.glyphs { if let Some(glyph) = font.get_glyph(c.index as usize) { - if let Some(glyph_shape_handle) = glyph.shape_handle(context.renderer) { + if glyph.renderable(context) { context.transform_stack.push(&transform); - context.commands.render_shape( - glyph_shape_handle, - context.transform_stack.transform(), - ); + glyph.render(context); context.transform_stack.pop(); } diff --git a/core/src/font.rs b/core/src/font.rs index 772cec67f3ee..04201eb17309 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -1,3 +1,4 @@ +use crate::context::RenderContext; use crate::drawing::Drawing; use crate::html::TextSpan; use crate::prelude::*; @@ -5,10 +6,13 @@ use crate::string::WStr; use gc_arena::{Collect, Gc, Mutation}; use ruffle_render::backend::null::NullBitmapSource; use ruffle_render::backend::{RenderBackend, ShapeHandle}; +use ruffle_render::bitmap::{Bitmap, BitmapHandle}; +use ruffle_render::error::Error; use ruffle_render::shape_utils::{DrawCommand, FillRule}; use ruffle_render::transform::Transform; -use std::cell::{OnceCell, RefCell}; +use std::cell::{Cell, OnceCell, Ref, RefCell}; use std::hash::{Hash, Hasher}; +use std::rc::Rc; use std::sync::Arc; use swf::FillStyle; @@ -139,6 +143,16 @@ impl EvalParameters { } } +pub trait FontRenderer: std::fmt::Debug { + fn get_font_metrics(&self) -> FontMetrics; + + fn has_kerning_info(&self) -> bool; + + fn render_glyph(&self, character: char) -> Option; + + fn calculate_kerning(&self, left: char, right: char) -> Twips; +} + struct GlyphToDrawing<'a>(&'a mut Drawing); /// Convert from a TTF outline, to a flash Drawing. @@ -329,6 +343,22 @@ impl FontFace { } } +pub enum GlyphRef<'a> { + Direct(&'a Glyph), + Ref(Ref<'a, Glyph>), +} + +impl<'a> std::ops::Deref for GlyphRef<'a> { + type Target = Glyph; + + fn deref(&self) -> &Self::Target { + match self { + GlyphRef::Direct(r) => r, + GlyphRef::Ref(r) => r.deref(), + } + } +} + #[derive(Debug)] pub enum GlyphSource { Memory { @@ -345,19 +375,29 @@ pub enum GlyphSource { kerning_pairs: fnv::FnvHashMap<(u16, u16), Twips>, }, FontFace(FontFace), + ExternalRenderer { + /// Maps Unicode code points to glyphs rendered by the renderer. + glyph_cache: RefCell>>, + + /// Maps Unicode pairs to kerning provided by the renderer. + kerning_cache: RefCell>, + + font_renderer: Box, + }, Empty, } impl GlyphSource { - pub fn get_by_index(&self, index: usize) -> Option<&Glyph> { + pub fn get_by_index(&self, index: usize) -> Option> { match self { - GlyphSource::Memory { glyphs, .. } => glyphs.get(index), + GlyphSource::Memory { glyphs, .. } => glyphs.get(index).map(GlyphRef::Direct), GlyphSource::FontFace(_) => None, // Unsupported. + GlyphSource::ExternalRenderer { .. } => None, // Unsupported. GlyphSource::Empty => None, } } - pub fn get_by_code_point(&self, code_point: char) -> Option<&Glyph> { + pub fn get_by_code_point(&self, code_point: char) -> Option> { match self { GlyphSource::Memory { glyphs, @@ -367,12 +407,32 @@ impl GlyphSource { // TODO: Properly handle UTF-16/out-of-bounds code points. let code_point = code_point as u16; if let Some(index) = code_point_to_glyph.get(&code_point) { - glyphs.get(*index) + glyphs.get(*index).map(GlyphRef::Direct) } else { None } } - GlyphSource::FontFace(face) => face.get_glyph(code_point), + GlyphSource::FontFace(face) => face.get_glyph(code_point).map(GlyphRef::Direct), + GlyphSource::ExternalRenderer { + glyph_cache, + font_renderer, + .. + } => { + let character = code_point; + let code_point = code_point as u16; + + glyph_cache + .borrow_mut() + .entry(code_point) + .or_insert_with(|| font_renderer.render_glyph(character)); + + let glyph = Ref::filter_map(glyph_cache.borrow(), |v| { + v.get(&code_point).unwrap_or(&None).as_ref() + }) + .ok(); + + glyph.map(GlyphRef::Ref) + } GlyphSource::Empty => None, } } @@ -381,6 +441,7 @@ impl GlyphSource { match self { GlyphSource::Memory { kerning_pairs, .. } => !kerning_pairs.is_empty(), GlyphSource::FontFace(face) => face.has_kerning_info(), + GlyphSource::ExternalRenderer { font_renderer, .. } => font_renderer.has_kerning_info(), GlyphSource::Empty => false, } } @@ -397,6 +458,19 @@ impl GlyphSource { .unwrap_or_default() } GlyphSource::FontFace(face) => face.get_kerning_offset(left, right), + GlyphSource::ExternalRenderer { + kerning_cache, + font_renderer, + .. + } => { + let (Ok(left_cp), Ok(right_cp)) = (left.try_into(), right.try_into()) else { + return Twips::ZERO; + }; + *kerning_cache + .borrow_mut() + .entry((left_cp, right_cp)) + .or_insert_with(|| font_renderer.calculate_kerning(left, right)) + } GlyphSource::Empty => Twips::ZERO, } } @@ -416,31 +490,36 @@ impl FontType { } } -#[derive(Debug, Clone, Collect, Copy)] -#[collect(no_drop)] -pub struct Font<'gc>(Gc<'gc, FontData>); - -#[derive(Debug, Collect)] -#[collect(require_static)] -struct FontData { - glyphs: GlyphSource, - +#[derive(Debug, Clone)] +pub struct FontMetrics { /// The scaling applied to the font height to render at the proper size. /// This depends on the DefineFont tag version. - scale: f32, + pub scale: f32, /// The distance from the top of each glyph to the baseline of the font, in /// EM-square coordinates. - ascent: i32, + pub ascent: i32, /// The distance from the baseline of the font to the bottom of each glyph, /// in EM-square coordinates. - descent: i32, + pub descent: i32, /// The distance between the bottom of any one glyph and the top of /// another, in EM-square coordinates. #[allow(dead_code)] // Web build falsely claims it's unused - leading: i16, + pub leading: i16, +} + +#[derive(Debug, Clone, Collect, Copy)] +#[collect(no_drop)] +pub struct Font<'gc>(Gc<'gc, FontData>); + +#[derive(Debug, Collect)] +#[collect(require_static)] +struct FontData { + glyphs: GlyphSource, + + metrics: FontMetrics, /// The identity of the font. #[collect(require_static)] @@ -468,10 +547,12 @@ impl<'gc> Font<'gc> { Ok(Font(Gc::new( gc_context, FontData { - scale: face.scale, - ascent: face.ascender, - descent: face.descender, - leading: face.leading, + metrics: FontMetrics { + scale: face.scale, + ascent: face.ascender, + descent: face.descender, + leading: face.leading, + }, glyphs: GlyphSource::FontFace(face), descriptor, font_type, @@ -517,7 +598,7 @@ impl<'gc> Font<'gc> { // Eager-load ASCII characters. if code < 128 { - glyph.shape_handle(renderer); + glyph.glyph_handle(renderer); } glyph @@ -547,12 +628,14 @@ impl<'gc> Font<'gc> { } }, - // DefineFont3 stores coordinates at 20x the scale of DefineFont1/2. - // (SWF19 p.164) - scale: if tag.version >= 3 { 20480.0 } else { 1024.0 }, - ascent, - descent, - leading, + metrics: FontMetrics { + // DefineFont3 stores coordinates at 20x the scale of DefineFont1/2. + // (SWF19 p.164) + scale: if tag.version >= 3 { 20480.0 } else { 1024.0 }, + ascent, + descent, + leading, + }, descriptor, font_type, has_layout: tag.layout.is_some(), @@ -589,6 +672,29 @@ impl<'gc> Font<'gc> { } } + pub fn from_renderer( + gc_context: &Mutation<'gc>, + descriptor: FontDescriptor, + font_renderer: Box, + ) -> Self { + let metrics = font_renderer.get_font_metrics(); + Font(Gc::new( + gc_context, + FontData { + glyphs: GlyphSource::ExternalRenderer { + glyph_cache: RefCell::new(fnv::FnvHashMap::default()), + kerning_cache: RefCell::new(fnv::FnvHashMap::default()), + font_renderer, + }, + + metrics, + descriptor, + font_type: FontType::Device, + has_layout: true, + }, + )) + } + pub fn empty_font( gc_context: &Mutation<'gc>, name: &str, @@ -601,10 +707,12 @@ impl<'gc> Font<'gc> { Font(Gc::new( gc_context, FontData { - scale: 1.0, - ascent: 0, - descent: 0, - leading: 0, + metrics: FontMetrics { + scale: 1.0, + ascent: 0, + descent: 0, + leading: 0, + }, glyphs: GlyphSource::Empty, descriptor, font_type, @@ -621,13 +729,13 @@ impl<'gc> Font<'gc> { /// Returns a glyph entry by index. /// Used by `Text` display objects. - pub fn get_glyph(&self, i: usize) -> Option<&Glyph> { + pub fn get_glyph(&self, i: usize) -> Option> { self.0.glyphs.get_by_index(i) } /// Returns a glyph entry by character. /// Used by `EditText` display objects. - pub fn get_glyph_for_char(&self, c: char) -> Option<&Glyph> { + pub fn get_glyph_for_char(&self, c: char) -> Option> { self.0.glyphs.get_by_code_point(c) } @@ -669,23 +777,23 @@ impl<'gc> FontLike<'gc> for Font<'gc> { fn get_leading_for_height(&self, height: Twips) -> Twips { let scale = height.get() as f32 / self.scale(); - Twips::new((self.0.leading as f32 * scale) as i32) + Twips::new((self.0.metrics.leading as f32 * scale) as i32) } fn get_baseline_for_height(&self, height: Twips) -> Twips { let scale = height.get() as f32 / self.scale(); - Twips::new((self.0.ascent as f32 * scale) as i32) + Twips::new((self.0.metrics.ascent as f32 * scale) as i32) } fn get_descent_for_height(&self, height: Twips) -> Twips { let scale = height.get() as f32 / self.scale(); - Twips::new((self.0.descent as f32 * scale) as i32) + Twips::new((self.0.metrics.descent as f32 * scale) as i32) } fn scale(&self) -> f32 { - self.0.scale + self.0.metrics.scale } fn font_type(&self) -> FontType { @@ -737,9 +845,9 @@ pub trait FontLike<'gc> { params: EvalParameters, mut glyph_func: FGlyph, ) where - FGlyph: FnMut(usize, &Transform, &Glyph, Twips, Twips), + FGlyph: FnMut(usize, &Transform, GlyphRef, Twips, Twips), { - transform.matrix.ty = self.get_baseline_for_height(params.height); + let baseline = self.get_baseline_for_height(params.height); // TODO [KJ] I'm not sure whether we should iterate over characters here or over code units. // I suspect Flash Player does not support full UTF-16 when displaying and laying out text. @@ -773,6 +881,11 @@ pub trait FontLike<'gc> { transform.matrix.a = scale; transform.matrix.d = scale; + transform.matrix.ty = if glyph.rendered_at_baseline() { + baseline + } else { + Twips::ZERO + }; glyph_func(pos, &transform, glyph, twips_advance, x); @@ -782,7 +895,7 @@ pub trait FontLike<'gc> { } else { // No glyph, zero advance. This makes it possible to use this method for purposes // other than rendering the font, e.g. measurement, iterating over characters. - glyph_func(pos, &transform, &Glyph::empty(c), Twips::ZERO, x); + glyph_func(pos, &transform, Glyph::empty(c).as_ref(), Twips::ZERO, x); } } } @@ -920,10 +1033,27 @@ impl SwfGlyphOrShape { } } +#[derive(Clone, Debug)] +pub enum GlyphHandle { + Shape(ShapeHandle), + Bitmap(BitmapHandle), +} + +impl GlyphHandle { + pub fn from_shape(shape_handle: ShapeHandle) -> Self { + Self::Shape(shape_handle) + } + + pub fn from_bitmap(bitmap_handle: BitmapHandle) -> Self { + Self::Bitmap(bitmap_handle) + } +} + #[derive(Debug, Clone)] enum GlyphShape { Swf(Box>), Drawing(Box), + Bitmap(Rc>), None, } @@ -937,11 +1067,15 @@ impl GlyphShape { && ruffle_render::shape_utils::shape_hit_test(shape, point, local_matrix) } GlyphShape::Drawing(drawing) => drawing.hit_test(point, local_matrix), + GlyphShape::Bitmap(_) => { + // TODO Implement this. + true + } GlyphShape::None => false, } } - pub fn register(&self, renderer: &mut dyn RenderBackend) -> Option { + pub fn register(&self, renderer: &mut dyn RenderBackend) -> Option { match self { GlyphShape::Swf(glyph) => { let mut glyph = glyph.borrow_mut(); @@ -949,14 +1083,63 @@ impl GlyphShape { handle.get_or_insert_with(|| { renderer.register_shape((&*shape).into(), &NullBitmapSource) }); - handle.clone() + handle.clone().map(GlyphHandle::from_shape) } - GlyphShape::Drawing(drawing) => drawing.register_or_replace(renderer), + GlyphShape::Drawing(drawing) => drawing + .register_or_replace(renderer) + .map(GlyphHandle::from_shape), + GlyphShape::Bitmap(bitmap) => bitmap + .get_handle_or_register(renderer) + .as_ref() + .inspect_err(|err| { + tracing::error!( + "Failed to register glyph as a bitmap: {err}, glyphs will be missing" + ) + }) + .ok() + .cloned() + .map(GlyphHandle::from_bitmap), GlyphShape::None => None, } } } +/// A Bitmap that can be registered to a RenderBackend. +struct GlyphBitmap<'a> { + bitmap: Cell>>, + handle: OnceCell>, +} + +impl<'a> std::fmt::Debug for GlyphBitmap<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GlyphBitmap") + .field("handle", &self.handle) + .finish() + } +} + +impl<'a> GlyphBitmap<'a> { + pub fn new(bitmap: Bitmap<'a>) -> Self { + Self { + bitmap: Cell::new(Some(bitmap)), + handle: OnceCell::new(), + } + } + + pub fn get_handle_or_register( + &self, + renderer: &mut dyn RenderBackend, + ) -> &Result { + self.handle.get_or_init(|| { + renderer.register_bitmap( + self.bitmap + .take() + .expect("Bitmap should be available before registering"), + ) + }) + } +} + #[derive(Debug, Clone)] pub struct Glyph { shape: GlyphShape, @@ -976,7 +1159,15 @@ impl Glyph { } } - pub fn shape_handle(&self, renderer: &mut dyn RenderBackend) -> Option { + pub fn from_bitmap(character: char, bitmap: Bitmap<'static>, advance: Twips) -> Self { + Self { + shape: GlyphShape::Bitmap(Rc::new(GlyphBitmap::new(bitmap))), + advance, + character, + } + } + + pub fn glyph_handle(&self, renderer: &mut dyn RenderBackend) -> Option { self.shape.register(renderer) } @@ -991,15 +1182,57 @@ impl Glyph { pub fn character(&self) -> char { self.character } + + pub fn as_ref(&self) -> GlyphRef<'_> { + GlyphRef::Direct(self) + } + + pub fn rendered_at_baseline(&self) -> bool { + match self.shape { + GlyphShape::Swf(_) => true, + GlyphShape::Drawing(_) => true, + GlyphShape::Bitmap(_) => false, + GlyphShape::None => false, + } + } + + pub fn renderable<'gc>(&self, context: &mut RenderContext<'_, 'gc>) -> bool { + self.glyph_handle(context.renderer).is_some() + } + + pub fn render<'gc>(&self, context: &mut RenderContext<'_, 'gc>) { + use ruffle_render::commands::CommandHandler; + + let Some(glyph_handle) = self.glyph_handle(context.renderer) else { + return; + }; + + let transform = context.transform_stack.transform(); + match glyph_handle { + GlyphHandle::Shape(shape_handle) => { + context + .commands + .render_shape(shape_handle.clone(), transform); + } + GlyphHandle::Bitmap(bitmap_handle) => { + context.commands.render_bitmap( + bitmap_handle.clone(), + transform, + true, + ruffle_render::bitmap::PixelSnapping::Auto, + ); + } + } + } } pub struct GlyphRenderData<'a, 'gc> { - pub glyph: &'a Glyph, + pub glyph: GlyphRef<'a>, pub font: Font<'gc>, } impl<'a, 'gc> GlyphRenderData<'a, 'gc> { - fn new(glyph: &'a Glyph, font: Font<'gc>) -> Self { + fn new(glyph: GlyphRef<'a>, font: Font<'gc>) -> Self { Self { glyph, font } } } diff --git a/core/src/library.rs b/core/src/library.rs index 081188a99583..c7645b54b035 100644 --- a/core/src/library.rs +++ b/core/src/library.rs @@ -681,6 +681,17 @@ impl<'gc> Library<'gc> { warn!("Failed to load device font from file"); } } + FontDefinition::ExternalRenderer { + name, + is_bold, + is_italic, + font_renderer, + } => { + let descriptor = FontDescriptor::from_parts(&name, is_bold, is_italic); + let font = Font::from_renderer(gc_context, descriptor, font_renderer); + info!("Loaded new externally rendered font \"{name}\" (bold: {is_bold}, italic: {is_italic})"); + self.device_fonts.register(font); + } } self.default_font_cache.clear(); } diff --git a/web/Cargo.toml b/web/Cargo.toml index cb919e0b67fd..aca222727918 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -73,7 +73,8 @@ features = [ "EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PageTransitionEvent", "PointerEvent", "Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", "ReadableStream", "RequestCredentials", - "Url", "WebGlContextEvent", "Clipboard", "FocusEvent", "ShadowRoot", "Gamepad", "GamepadButton" + "Url", "WebGlContextEvent", "Clipboard", "FocusEvent", "ShadowRoot", "Gamepad", "GamepadButton", "OffscreenCanvas", + "TextMetrics", "OffscreenCanvasRenderingContext2d" ] [target.'cfg(target_family = "wasm")'.dependencies.getrandom] diff --git a/web/packages/core/src/internal/builder.ts b/web/packages/core/src/internal/builder.ts index 8fe7502beebd..a1785f272500 100644 --- a/web/packages/core/src/internal/builder.ts +++ b/web/packages/core/src/internal/builder.ts @@ -140,6 +140,10 @@ export function configureBuilder( if (isExplicit(config.scrollingBehavior)) { builder.setScrollingBehavior(config.scrollingBehavior); } + + if (isExplicit(config.deviceFontRenderer)) { + builder.setDeviceFontRenderer(config.deviceFontRenderer); + } } /** diff --git a/web/packages/core/src/public/config/default.ts b/web/packages/core/src/public/config/default.ts index c7d21e7ebebf..96f72c8bce1a 100644 --- a/web/packages/core/src/public/config/default.ts +++ b/web/packages/core/src/public/config/default.ts @@ -10,6 +10,7 @@ import { UnmuteOverlay, WindowMode, ScrollingBehavior, + DeviceFontRenderer, } from "./load-options"; export const DEFAULT_CONFIG: Required = { @@ -56,4 +57,5 @@ export const DEFAULT_CONFIG: Required = { gamepadButtonMapping: {}, urlRewriteRules: [], scrollingBehavior: ScrollingBehavior.Smart, + deviceFontRenderer: DeviceFontRenderer.Embedded, }; diff --git a/web/packages/core/src/public/config/load-options.ts b/web/packages/core/src/public/config/load-options.ts index 18523741ac89..ef60b54417ad 100644 --- a/web/packages/core/src/public/config/load-options.ts +++ b/web/packages/core/src/public/config/load-options.ts @@ -283,6 +283,30 @@ export enum ScrollingBehavior { Smart = "smart", } +/** + * Specifies how device fonts should be rendered. + */ +export enum DeviceFontRenderer { + /** + * Use Ruffle's embedded text rendering engine. + * + * It cannot access device fonts and uses fonts provided in the + * configuration and the default Noto Sans font as a fallback. + * + * This is the default method. + */ + Embedded = "embedded", + + /** + * Use an offscreen canvas for text rendering. + * + * It can access and render device fonts, glyphs are rendered as bitmaps. + * + * This is an experimental method and some features might not work properly. + */ + Canvas = "canvas", +} + /** * Represents a host, port and proxyUrl. Used when a SWF file tries to use a Socket. */ @@ -758,6 +782,13 @@ export interface BaseLoadOptions { * @default ScrollingBehavior.Smart */ scrollingBehavior?: ScrollingBehavior; + + /** + * Specify how device fonts should be rendered. + * + * @default DeviceFontRenderer.Embedded + */ + deviceFontRenderer?: DeviceFontRenderer; } /** diff --git a/web/src/builder.rs b/web/src/builder.rs index 5330ca5f70c4..41e4ae4f2e67 100644 --- a/web/src/builder.rs +++ b/web/src/builder.rs @@ -1,8 +1,8 @@ use crate::external_interface::JavascriptInterface; use crate::navigator::{OpenUrlMode, WebNavigatorBackend}; use crate::{ - JavascriptPlayer, RUFFLE_GLOBAL_PANIC, RuffleHandle, ScrollingBehavior, SocketProxy, audio, - log_adapter, storage, ui, + DeviceFontRenderer, JavascriptPlayer, RUFFLE_GLOBAL_PANIC, RuffleHandle, ScrollingBehavior, + SocketProxy, audio, log_adapter, storage, ui, }; use js_sys::{Promise, RegExp}; use ruffle_core::backend::audio::{AudioBackend, NullAudioBackend}; @@ -65,6 +65,7 @@ pub struct RuffleInstanceBuilder { pub(crate) gamepad_button_mapping: HashMap, pub(crate) url_rewrite_rules: Vec<(RegExp, String)>, pub(crate) scrolling_behavior: ScrollingBehavior, + pub(crate) device_font_renderer: DeviceFontRenderer, } impl Default for RuffleInstanceBuilder { @@ -104,6 +105,7 @@ impl Default for RuffleInstanceBuilder { gamepad_button_mapping: HashMap::new(), url_rewrite_rules: vec![], scrolling_behavior: ScrollingBehavior::Smart, + device_font_renderer: DeviceFontRenderer::Embedded, } } } @@ -347,6 +349,15 @@ impl RuffleInstanceBuilder { }; } + #[wasm_bindgen(js_name = "setDeviceFontRenderer")] + pub fn set_device_font_renderer(&mut self, device_font_renderer: String) { + self.device_font_renderer = match device_font_renderer.as_str() { + "embedded" => DeviceFontRenderer::Embedded, + "canvas" => DeviceFontRenderer::Canvas, + _ => return, + }; + } + // TODO: This should be split into two methods that either load url or load data // Right now, that's done immediately afterwards in TS pub async fn build(&self, parent: HtmlElement, js_player: JavascriptPlayer) -> Promise { @@ -677,10 +688,16 @@ impl RuffleInstanceBuilder { .with_fs_commands(interface); } - let trace_observer = Rc::new(RefCell::new(JsValue::UNDEFINED)); + let trace_observer: Rc> = Rc::new(RefCell::new(JsValue::UNDEFINED)); + let use_canvas_font_renderer = + matches!(self.device_font_renderer, DeviceFontRenderer::Canvas); let core = builder .with_log(log_adapter::WebLogBackend::new(trace_observer.clone())) - .with_ui(ui::WebUiBackend::new(js_player.clone(), &canvas)) + .with_ui(ui::WebUiBackend::new( + js_player.clone(), + &canvas, + use_canvas_font_renderer, + )) // `ExternalVideoBackend` has an internal `SoftwareVideoBackend` that it uses for any non-H.264 video. .with_video(ExternalVideoBackend::new_with_webcodecs( log_subscriber.clone(), @@ -713,7 +730,10 @@ impl RuffleInstanceBuilder { core.set_show_menu(self.show_menu); core.set_allow_fullscreen(self.allow_fullscreen); core.set_window_mode(self.wmode.as_deref().unwrap_or("window")); - self.setup_fonts(&mut core); + + if matches!(self.device_font_renderer, DeviceFontRenderer::Embedded) { + self.setup_fonts(&mut core); + } } Ok(BuiltPlayer { diff --git a/web/src/lib.rs b/web/src/lib.rs index 4b14a01b27e6..12a95e1e843a 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -238,6 +238,12 @@ pub enum ScrollingBehavior { Smart, } +#[derive(Debug, Clone, Copy)] +pub enum DeviceFontRenderer { + Embedded, + Canvas, +} + #[wasm_bindgen] impl RuffleHandle { /// Stream an arbitrary movie file from (presumably) the Internet. diff --git a/web/src/ui.rs b/web/src/ui.rs index a3e15595057d..343e7375f0ec 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -1,3 +1,5 @@ +mod font_renderer; + use super::JavascriptPlayer; use rfd::{AsyncFileDialog, FileHandle}; use ruffle_core::backend::ui::{ @@ -146,10 +148,16 @@ pub struct WebUiBackend { /// Is a dialog currently open dialog_open: bool, + + use_canvas_font_renderer: bool, } impl WebUiBackend { - pub fn new(js_player: JavascriptPlayer, canvas: &HtmlCanvasElement) -> Self { + pub fn new( + js_player: JavascriptPlayer, + canvas: &HtmlCanvasElement, + use_canvas_font_renderer: bool, + ) -> Self { let window = web_sys::window().expect("window()"); let preferred_language = window.navigator().language(); let language = preferred_language @@ -163,6 +171,7 @@ impl WebUiBackend { language, clipboard_content: "".into(), dialog_open: false, + use_canvas_font_renderer, } } @@ -297,9 +306,39 @@ impl UiBackend for WebUiBackend { self.js_player.display_unsupported_video(url.as_str()); } - fn load_device_font(&self, _query: &FontQuery, _register: &mut dyn FnMut(FontDefinition)) { - // Because fonts must be loaded instantly (no async), - // we actually just provide them all upfront at time of Player creation. + fn load_device_font(&self, query: &FontQuery, register: &mut dyn FnMut(FontDefinition)) { + if !self.use_canvas_font_renderer { + // In case we don't use the canvas font renderer, + // because fonts must be loaded instantly (no async), + // we actually just provide them all upfront at time of Player creation. + return; + } + + let renderer = + font_renderer::CanvasFontRenderer::new(query.is_italic, query.is_bold, &query.name); + + match renderer { + Ok(renderer) => { + tracing::info!( + "Loaded a new canvas font renderer for font \"{}\", italic: {}, bold: {}", + query.name, + query.is_italic, + query.is_bold + ); + register(FontDefinition::ExternalRenderer { + name: query.name.clone(), + is_bold: query.is_bold, + is_italic: query.is_italic, + font_renderer: Box::new(renderer), + }); + } + Err(e) => { + tracing::error!( + "Failed to set up canvas font renderer for font \"{}\": {e:?}", + query.name + ) + } + } } fn sort_device_fonts( diff --git a/web/src/ui/font_renderer.rs b/web/src/ui/font_renderer.rs new file mode 100644 index 000000000000..dc20bd7dd037 --- /dev/null +++ b/web/src/ui/font_renderer.rs @@ -0,0 +1,119 @@ +use ruffle_core::font::FontMetrics; +use ruffle_core::font::FontRenderer; +use ruffle_core::font::Glyph; +use ruffle_core::swf::Twips; +use ruffle_render::bitmap::Bitmap; +use ruffle_render::bitmap::BitmapFormat; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use web_sys::OffscreenCanvas; +use web_sys::OffscreenCanvasRenderingContext2d; + +#[derive(Debug)] +pub struct CanvasFontRenderer { + canvas: OffscreenCanvas, + ctx: OffscreenCanvasRenderingContext2d, + ascent: f64, + descent: f64, +} + +impl CanvasFontRenderer { + /// Render fonts with size 64px. It affects the bitmap size. + const SIZE_PX: f64 = 64.0; + + /// Divide each pixel into 20 (use twips precision). It affects metrics. + const SCALE: f64 = 20.0; + + pub fn new(italic: bool, bold: bool, font_family: &str) -> Result { + let canvas = OffscreenCanvas::new(1024, 1024)?; + + let ctx = canvas.get_context("2d")?.expect("2d context"); + let ctx = ctx + .dyn_into::() + .map_err(|err| JsValue::from_str(&format!("Not a 2d context: {err:?}")))?; + + ctx.set_fill_style_str("white"); + ctx.set_font(&Self::to_font_str(italic, bold, Self::SIZE_PX, font_family)); + + let measurement = ctx.measure_text("Myjg")?; + let ascent = measurement.font_bounding_box_ascent(); + let descent = measurement.font_bounding_box_descent(); + + Ok(Self { + canvas, + ctx, + ascent, + descent, + }) + } + + fn to_font_str(italic: bool, bold: bool, size: f64, font_family: &str) -> String { + let italic = if italic { "italic " } else { "" }; + let bold = if bold { "bold " } else { "" }; + format!("{italic}{bold}{size}px {font_family}") + } + + fn calculate_width(&self, text: &str) -> Result { + Ok(self.ctx.measure_text(text)?.width()) + } + + fn render_glyph_internal(&self, character: char) -> Result { + let text = &character.to_string(); + + self.ctx.clear_rect( + 0.0, + 0.0, + self.canvas.width() as f64, + self.canvas.height() as f64, + ); + self.ctx.fill_text(text, 0.0, self.ascent)?; + + let width = self.calculate_width(text)?; + let height = self.ascent + self.descent; + + let image_data = self.ctx.get_image_data(0.0, 0.0, width, height)?; + let width = image_data.width(); + let height = image_data.height(); + let pixels = image_data.data().0; + + let bitmap = Bitmap::new(width, height, BitmapFormat::Rgba, pixels); + let advance = Twips::from_pixels(width as f64); + Ok(Glyph::from_bitmap(character, bitmap, advance)) + } + + fn calculate_kerning_internal(&self, left: char, right: char) -> Result { + let left_width = self.calculate_width(&left.to_string())?; + let right_width = self.calculate_width(&right.to_string())?; + let both_width = self.calculate_width(&format!("{left}{right}"))?; + + let kern = both_width - left_width - right_width; + Ok(Twips::from_pixels(kern)) + } +} + +impl FontRenderer for CanvasFontRenderer { + fn get_font_metrics(&self) -> FontMetrics { + FontMetrics { + scale: (Self::SIZE_PX * Self::SCALE) as f32, + ascent: (self.ascent * Self::SCALE) as i32, + descent: (self.descent * Self::SCALE) as i32, + leading: 0, + } + } + + fn has_kerning_info(&self) -> bool { + true + } + + fn render_glyph(&self, character: char) -> Option { + self.render_glyph_internal(character) + .map_err(|err| tracing::error!("Failed to render a glyph: {err:?}")) + .ok() + } + + fn calculate_kerning(&self, left: char, right: char) -> Twips { + self.calculate_kerning_internal(left, right) + .map_err(|err| tracing::error!("Failed to calculate kerning: {err:?}")) + .unwrap_or(Twips::ZERO) + } +}