From 4048a35f6b0d1a1db84486003bd8972a332c1ce6 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 00:54:35 +0100 Subject: [PATCH 1/9] text: Add FontMetrics struct for font metrics Values such as scale, ascent, descent, leading belong to the same category of font metrics, and conceptually it makes sense to represent them as one object, as they cannot be separated. --- core/src/font.rs | 73 ++++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/core/src/font.rs b/core/src/font.rs index 3741f2a8d908..f7bececaf4d6 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -420,31 +420,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)] @@ -472,10 +477,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, @@ -551,12 +558,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(), @@ -605,10 +614,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, @@ -673,23 +684,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 { From 7bafa63538d944ff92bc7f6774a102d0a8c1eb60 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 01:03:29 +0100 Subject: [PATCH 2/9] text: Add GlyphRef for referencing borrowed data GlyphRef can both reference a &Glyph directly or a Ref, which is required to abstract away glyphs generated dynamically and cached. This is useful for e.g. rendering text on web using canvas. --- core/src/display_object/edit_text.rs | 6 ++-- core/src/font.rs | 44 ++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index 602796e35b5a..8e2dab59ba00 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,7 +1272,7 @@ impl<'gc> EditText<'gc> { text, self.text_transform(color), params, - |pos, transform, glyph: &Glyph, advance, x| { + |pos, transform, glyph, advance, x| { if let Some(glyph_shape_handle) = glyph.shape_handle(context.renderer) { // If it's highlighted, override the color. if matches!(visible_selection, Some(visible_selection) if visible_selection.contains(start + pos)) { @@ -1629,7 +1629,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/font.rs b/core/src/font.rs index f7bececaf4d6..9f2668e721fc 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -7,7 +7,7 @@ use ruffle_render::backend::null::NullBitmapSource; use ruffle_render::backend::{RenderBackend, ShapeHandle}; use ruffle_render::shape_utils::{DrawCommand, FillRule}; use ruffle_render::transform::Transform; -use std::cell::{OnceCell, RefCell}; +use std::cell::{OnceCell, Ref, RefCell}; use std::hash::{Hash, Hasher}; use std::sync::Arc; use swf::FillStyle; @@ -329,6 +329,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 { @@ -349,15 +365,15 @@ pub enum GlyphSource { } 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::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 +383,12 @@ 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::Empty => None, } } @@ -636,13 +652,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) } @@ -752,7 +768,7 @@ 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); @@ -804,7 +820,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); } } } @@ -1013,15 +1029,19 @@ impl Glyph { pub fn character(&self) -> char { self.character } + + pub fn as_ref(&self) -> GlyphRef<'_> { + GlyphRef::Direct(self) + } } 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 } } } From ff73531427715fdc50cdc4168f7ca62c9a8ede0e Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 00:51:18 +0100 Subject: [PATCH 3/9] text: Add support for bitmap glyphs This patch adds support for bitmap glyphs in addition to shape-based glyphs. Bitmap glyphs are useful for supporting device text rendered by the OS. --- core/src/display_object/edit_text.rs | 8 +- core/src/display_object/text.rs | 8 +- core/src/font.rs | 139 +++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 19 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index 8e2dab59ba00..cbefc946a487 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -1273,7 +1273,7 @@ impl<'gc> EditText<'gc> { self.text_transform(color), params, |pos, transform, glyph, advance, x| { - if let Some(glyph_shape_handle) = glyph.shape_handle(context.renderer) { + 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(); } 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 9f2668e721fc..6a14f756db15 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, Ref, RefCell}; +use std::cell::{Cell, OnceCell, Ref, RefCell}; use std::hash::{Hash, Hasher}; +use std::rc::Rc; use std::sync::Arc; use swf::FillStyle; @@ -544,7 +548,7 @@ impl<'gc> Font<'gc> { // Eager-load ASCII characters. if code < 128 { - glyph.shape_handle(renderer); + glyph.glyph_handle(renderer); } glyph @@ -770,7 +774,7 @@ pub trait FontLike<'gc> { ) where 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. @@ -811,6 +815,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); @@ -958,10 +967,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, } @@ -975,11 +1001,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(); @@ -987,14 +1017,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, @@ -1014,7 +1093,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) } @@ -1033,6 +1120,44 @@ impl Glyph { 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> { From e81b4158e6aa18cdea160c8d576232499ea8ac69 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 02:12:02 +0100 Subject: [PATCH 4/9] text: Add support for external font renderers External font renderer provides/renders glyphs on demand, when core requests them. The logic of rendering glyphs can be external to core and is abstracted away so that frontends can provide their font renderers for device text. --- core/src/backend/ui.rs | 10 +++++- core/src/font.rs | 77 ++++++++++++++++++++++++++++++++++++++++++ core/src/library.rs | 11 ++++++ 3 files changed, 97 insertions(+), 1 deletion(-) 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/font.rs b/core/src/font.rs index 6a14f756db15..7a73620f4b7f 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -143,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. @@ -365,6 +375,15 @@ 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, } @@ -373,6 +392,7 @@ impl GlyphSource { match self { GlyphSource::Memory { glyphs, .. } => glyphs.get(index).map(GlyphRef::Direct), GlyphSource::FontFace(_) => None, // Unsupported. + GlyphSource::ExternalRenderer { .. } => None, // Unsupported. GlyphSource::Empty => None, } } @@ -393,6 +413,26 @@ impl GlyphSource { } } 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, } } @@ -401,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, } } @@ -417,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, } } @@ -622,6 +676,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, 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(); } From 2f67e45e2568f89c8fe395cea72da429e860168c Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 02:14:34 +0100 Subject: [PATCH 5/9] web: Implement CanvasFontRenderer CanvasFontRenderer is an implementation of a font renderer that can render glyphs using an offscreen canvas. This allows Ruffle to support device text properly on web. --- web/Cargo.toml | 3 +- web/src/ui.rs | 2 + web/src/ui/font_renderer.rs | 129 ++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 web/src/ui/font_renderer.rs 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/src/ui.rs b/web/src/ui.rs index a3e15595057d..895be25e510f 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::{ diff --git a/web/src/ui/font_renderer.rs b/web/src/ui/font_renderer.rs new file mode 100644 index 000000000000..48c89691aedf --- /dev/null +++ b/web/src/ui/font_renderer.rs @@ -0,0 +1,129 @@ +use js_sys::JSON; +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 { + // TODO Firefox <105, Safari <16.4 do not support OffscreenCanvas + 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"); + let font_str = Self::to_font_str(italic, bold, Self::SIZE_PX, font_family); + tracing::debug!("Using the following font string: {font_str}"); + ctx.set_font(&font_str); + + 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 { "" }; + + // Escape font family properly + let font_family = JSON::stringify(&JsValue::from_str(font_family)) + .ok() + .and_then(|js_str| js_str.as_string()) + .unwrap_or_else(|| format!("\"{font_family}\"")); + 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) + } +} From 8bb217738c2a60fc4d8814e8f695093393bf085a Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 02:34:47 +0100 Subject: [PATCH 6/9] web: Add deviceFontRenderer config option Using this config option the user can select how device fonts should be rendered. --- web/packages/core/src/internal/builder.ts | 4 +++ .../core/src/public/config/default.ts | 2 ++ .../core/src/public/config/load-options.ts | 31 +++++++++++++++++++ web/src/builder.rs | 15 +++++++-- web/src/lib.rs | 6 ++++ 5 files changed, 56 insertions(+), 2 deletions(-) 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..f8f2c9aa1f7c 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 { 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. From 120607e20a5e4a53cddab742fd3b8705cbbcf379 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 16 Nov 2025 02:39:19 +0100 Subject: [PATCH 7/9] web: Use CanvasFontRenderer when enabled When DeviceFontRenderer::Canvas is set, no fonts will be set up, and instead CanvasFontRenderer will be used to render all device fonts. --- web/src/builder.rs | 15 ++++++++++++--- web/src/ui.rs | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/web/src/builder.rs b/web/src/builder.rs index f8f2c9aa1f7c..41e4ae4f2e67 100644 --- a/web/src/builder.rs +++ b/web/src/builder.rs @@ -688,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(), @@ -724,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/ui.rs b/web/src/ui.rs index 895be25e510f..343e7375f0ec 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -148,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 @@ -165,6 +171,7 @@ impl WebUiBackend { language, clipboard_content: "".into(), dialog_open: false, + use_canvas_font_renderer, } } @@ -299,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( From a4317bdb10ac3d523a2b785c048df162b44b9faf Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Mon, 17 Nov 2025 11:23:24 +0100 Subject: [PATCH 8/9] web: Add device_fonts_metrics integration test This test verifies whether text using device fonts has proper metrics. --- .../device_fonts_metrics/Test.as | 44 ++++++++++ .../device_fonts_metrics/index.html | 22 +++++ .../device_fonts_metrics/test.swf | Bin 0 -> 1039 bytes .../device_fonts_metrics/test.ts | 77 ++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_metrics/Test.as create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_metrics/index.html create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_metrics/test.swf create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_metrics/test.ts diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/Test.as b/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/Test.as new file mode 100644 index 000000000000..e4f1faf661cf --- /dev/null +++ b/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/Test.as @@ -0,0 +1,44 @@ +package { + +import flash.display.*; +import flash.text.*; + +[SWF(width="100", height="100")] +public class Test extends MovieClip { + public function Test() { + var text:TextField = createTextField(); + addChild(text); + + trace("Loaded test!"); + + trace("Displayed text metrics:"); + trace(text.textWidth); + trace(text.textHeight); + trace(text.getCharBoundaries(0).x); + trace(text.getCharBoundaries(0).width); + trace(text.getCharBoundaries(1).x); + trace(text.getCharBoundaries(1).width); + trace(text.getCharBoundaries(2).x); + trace(text.getCharBoundaries(2).width); + + if (text.getCharBoundaries(3) == null) { + trace("null"); + trace("null"); + } else { + trace(text.getCharBoundaries(3).x); + trace(text.getCharBoundaries(3).width); + } + } + + private function createTextField():TextField { + var text:TextField = new TextField(); + text.text = "MxT體"; + text.x = 10; + text.y = 10; + text.width = 80; + text.height = 80; + return text; + } +} + +} diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/index.html b/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/index.html new file mode 100644 index 000000000000..a9e9de68df9b --- /dev/null +++ b/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + +
+ +
+ + + diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/test.swf b/web/packages/selfhosted/test/integration_tests/device_fonts_metrics/test.swf new file mode 100644 index 0000000000000000000000000000000000000000..d3385f4c4d5c6f2e86d0acd08192b2ebc581488e GIT binary patch literal 1039 zcmV+q1n~PqS5qlJ1^@tf0fkdfbK67|-_^?A*ok91b{wZ9)JfdB%*2-Bzzj)KLx?dg zLz_+=CJZwkqev@DRFO2&`cF>uLvZ3N^vDO`#DSYV4Bw%gFf-i4?kcg{;ebZczTbPl z_tSg(R^NffD}EOtXlB;|06*m3Kmd*c!#sS^Y?VfrjvF2_^r&J{>K`h~a5$_D_iJ9z zQ}zxH4ivSn)azBosD@WA)kak}+^IB#K$C=eVEfee+!7bmF1$W{RH@vk8v0Ds?+1>M zG<1bH@bn+JIC|M+>bZWlrQd)TRz; z9Q&GXky6Vcqtfv_U63ON=Nj`m8#i5=ETg80Y*ClUc$>%b{QiYwhZYHLzti?iI@AL8 zL9g!`qEeAKC-O8sAR6`lH|dtfQyk6h^|c;p5V!kvTWHQ)q63YR#xrkFs@Hc*b+!Ic zR4?})DSYeS8pVx=O}7D-GyU~vr`6}j?e_WUizXPS*%5f>YG&S`BXA;JUtfQlL@X3% zkDPh=+dexc0Q|gl@%krb{hB-@AqBtWe`g#X5ikR4ob*5zD5bCoK77dSnik5GjA&Xk zNzRzrvWa6P#Vl()l$bdeG{ZQtY{!VvK-0<6SDt1NqePj`)>_jJeMh?zctlH=ga)=A z9wxZNxouD@$*sr4?pZY3BlN`5g3rWs0-JUhDO<04VEH0;odNH(_Tc)K5;2wTR`mri9CgO;wK*a0d1EA@iwcK z64X9#p*>aIxUEoU$L>F6?tS<%BkkiDEW`^^;Z6}2-Uq7~#;}AjTEHlQ(Go@}jFvG< zW0b*2#wd@`Dn<~??_sooWme1Od$4$) zp$bebCt?6XcnlXQd zO8R4(?4U^t!4IZO9eFp|l9{#4tVLm^JMzSg2{XgZxG=NKEQ1z?=7PX0Gn^1O$1$A8 z^D`X9S2>pE@xly8@#3V6ShSQc0x(@?#bkx-om(qYV*U-D#A!E0Y|d~ba+ftPNv!!J z*8A32#38lIVM9vYXSA)AFR8)E1`q__8>?KPf-g64MG_mZlFTj{!gu+sseK zDpSD(bkb&8?PSc1+R2((wX { + for (const deviceFontRenderer of deviceFontRenderers) { + it(`load the test: ${deviceFontRenderer}`, async () => { + await openTest( + browser, + "integration_tests/device_fonts_metrics", + `index.html?deviceFontRenderer=${deviceFontRenderer}`, + ); + await injectRuffleAndWait(browser); + const player = await browser.$(""); + await playAndMonitor(browser, player, ["Loaded test!"]); + await hideHardwareAccelerationModal(browser, player); + }); + + it("check metrics", async () => { + const player = await browser.$("#objectElement"); + + await expectTraceOutput(browser, player, [ + "Displayed text metrics:", + ]); + + const messages = await getTraceOutput(browser, player, 10); + + const textWidth = Number(messages[0]); + const textHeight = Number(messages[1]); + const char0x = Number(messages[2]) - 2; + const char0w = Number(messages[3]); + const char1x = Number(messages[4]) - char0x; + const char1w = Number(messages[5]); + const char2x = Number(messages[6]) - char1x; + const char2w = Number(messages[7]); + const char3x = Number(messages[8]) - char2x; + const char3w = Number(messages[9]); + + expect(textWidth).to.be.greaterThan(0); + expect(textHeight).to.be.greaterThan(0); + expect(char0x).to.equal(0); + expect(char0w).to.be.greaterThan(0); + expect(char1x).to.be.greaterThan(0); + expect(char1w).to.be.greaterThan(0); + expect(char2x).to.be.greaterThan(0); + expect(char2w).to.be.greaterThan(0); + + if (deviceFontRenderer === "embedded") { + // Embedded renderer does not support CJK characters by default + expect(char3x).to.be.NaN; + expect(char3w).to.be.NaN; + } else { + expect(char3x).to.be.greaterThan(0); + expect(char3w).to.be.greaterThan(0); + } + }); + + it("no more traces", async function () { + const player = await browser.$("#objectElement"); + assertNoMoreTraceOutput(browser, player); + }); + } +}); From e4b56fd23a779cccd52221f3a63e0a4016130982 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sat, 22 Nov 2025 17:47:38 +0100 Subject: [PATCH 9/9] web: Add device_fonts_rendering integration test This test check whether device font glyphs are rendered inside proper bounds. --- .../device_fonts_rendering/Test.as | 32 +++++ .../device_fonts_rendering/index.html | 21 ++++ .../device_fonts_rendering/test.swf | Bin 0 -> 983 bytes .../device_fonts_rendering/test.ts | 119 ++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_rendering/Test.as create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_rendering/index.html create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.swf create mode 100644 web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.ts diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/Test.as b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/Test.as new file mode 100644 index 000000000000..879f9906f2f6 --- /dev/null +++ b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/Test.as @@ -0,0 +1,32 @@ +package { + +import flash.display.*; +import flash.text.*; + +[SWF(width="100", height="100", backgroundColor="#FF00FF")] +public class Test extends MovieClip { + public function Test() { + var text:TextField = createTextField(); + addChild(text); + + trace("Loaded test!"); + + trace("Character bounds:"); + trace(text.getCharBoundaries(0).x + text.x); + trace(text.getCharBoundaries(0).width); + trace(text.getCharBoundaries(0).y + text.y); + trace(text.getCharBoundaries(0).height); + } + + private function createTextField():TextField { + var text:TextField = new TextField(); + text.text = "M"; + text.x = 20; + text.y = 20; + text.width = 80; + text.height = 80; + return text; + } +} + +} diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/index.html b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/index.html new file mode 100644 index 000000000000..01ddb61d3990 --- /dev/null +++ b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/index.html @@ -0,0 +1,21 @@ + + + + + + + + + +
+ +
+ + + diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.swf b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.swf new file mode 100644 index 0000000000000000000000000000000000000000..77ceff0f60cb6a05793126125247cee0d8ea678b GIT binary patch literal 983 zcmV;|11S7MS5qmw1poke0fkfDa@#}{-qp%p+wpIHoTN>`A%P4tu_Zebn53y8*a+I8 zO{XqXh8d69NGnT3ku=i!Cs&l$;ED&}9k}9xJNtrn=oOeJD7)*#X@`qyB<(rhe&^^r zdyakp&6ncdfS_I41OWWB@(KcQ5?S`~v$jzgUwVFg%)JMD4rAf5rj16U+UTGbM18G( zbabTY4Xx3raz-`2@);Rd{di}uDHF75Y({R#+`zAhgycLJum^j4*I6xdku)4co=j<( z8ujQU^;xXdYjvK;GLP*bx+JVg!q9U~BGPH&YU~8&#fS`P)%Hm2oM^X15yM>O(dKDL zOovtskB%#+x8agJX9%u!tFw7srOhL1L`F+pGU831hzkejo*O$fy7^8gu-S-2{DZ;3 zx1>@TcuN#vW<&`K-Wha56Cob)`vcOaP3rf)?Z})9NLD}?Z9WNxl}2N~($E`+bG-sR z(Ztce(TZz?cee@h#lhy2v+C2+PUpqhvo@F%_z1jpwTrLN31}&=!Rzlch;JqNqZTjz zJm6yjz~Jxjw_mvRN9H+=8TftmjB~_}bc>)SNPDXEavJa8{rkeMkyvGP%nGtd`kb4F zOFb(ids!1(=>?b~meq1x&q}a}m^AxMKrCui7|*j^YB_|1j7AkcT)q__m->_m@C(_3 z$fYqHCq}Nt9C(#X>Qb1s`uoJB-{RKdgCcZsn}^di|isf}TZf*LTI_^&IMj zC=6nBc=$;XXigslNG)k@DkuwYXFeKEBkDs^DLNx zZqCl>-MpRGy9K+TcZ+sW@0RS6-d(nr^={VA%GY<`-u!ZwcwCZ8Nv|w^sL%P){Ri|W Frav$@^mqUO literal 0 HcmV?d00001 diff --git a/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.ts b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.ts new file mode 100644 index 000000000000..ef6bb49e864c --- /dev/null +++ b/web/packages/selfhosted/test/integration_tests/device_fonts_rendering/test.ts @@ -0,0 +1,119 @@ +import { + assertNoMoreTraceOutput, + expectTraceOutput, + getTraceOutput, + hideHardwareAccelerationModal, + injectRuffleAndWait, + openTest, + playAndMonitor, +} from "../../utils.js"; +import { expect, use } from "chai"; +import chaiHtml from "chai-html"; + +use(chaiHtml); + +const deviceFontRenderers = ["embedded", "canvas"]; + +describe("Device Fonts: Rendering", () => { + for (const deviceFontRenderer of deviceFontRenderers) { + it(`load the test: ${deviceFontRenderer}`, async () => { + await openTest( + browser, + "integration_tests/device_fonts_rendering", + `index.html?deviceFontRenderer=${deviceFontRenderer}`, + ); + await injectRuffleAndWait(browser); + const player = await browser.$(""); + await playAndMonitor(browser, player, ["Loaded test!"]); + await hideHardwareAccelerationModal(browser, player); + }); + + it("check rendered image", async () => { + const player = await browser.$("#objectElement"); + + await expectTraceOutput(browser, player, ["Character bounds:"]); + + const messages = await getTraceOutput(browser, player, 4); + + const boundsX = Number(messages[0]); + const boundsWidth = Number(messages[1]); + const boundsY = Number(messages[2]); + const boundsHeight = Number(messages[3]); + + expect(boundsX).to.be.greaterThan(0); + expect(boundsWidth).to.be.greaterThan(0); + expect(boundsY).to.greaterThan(0); + expect(boundsHeight).to.be.greaterThan(0); + + const canvas = await player.shadow$("canvas"); + + const [canvasWidth, canvasHeight, pixels] = await browser.execute( + (el) => { + const canvas = el as HTMLCanvasElement; + const ctx = + canvas.getContext("webgl") || + canvas.getContext("webgl2"); + const pixels = new Uint8Array( + canvas.width * canvas.height * 4, + ); + ctx?.readPixels( + 0, + 0, + canvas.width, + canvas.height, + ctx.RGBA, + ctx.UNSIGNED_BYTE, + pixels, + ); + + return [canvas.width, canvas.height, [...pixels]]; + }, + canvas, + ); + + let insideBoundsBg = 0; + let insideBoundsNonBg = 0; + + for (let i = 0; i < pixels.length; i += 4) { + const pixelIndex = i / 4; + const pixel = pixels.slice(i, i + 4); + const x = pixelIndex % canvasWidth; + const y = Math.floor(pixelIndex / canvasWidth); + + const scaledX = (x * 100) / canvasWidth; + const scaledY = (y * 100) / canvasHeight; + + const insideBounds = + boundsX <= scaledX && + scaledX <= boundsX + boundsWidth && + boundsY <= scaledY && + scaledY <= boundsY + boundsHeight; + + if (insideBounds) { + // Background is magenta. + const isBackground = + pixel.toString() === [255, 0, 255, 255].toString(); + if (isBackground) { + insideBoundsBg += 1; + } else { + insideBoundsNonBg += 1; + } + } else { + // Pixels outside of bounds should be background only. + expect(pixel.toString()).to.be.equal( + [255, 0, 255, 255].toString(), + ); + } + } + + // Make sure there are both background and black pixels inside bounds. + expect(insideBoundsBg).is.greaterThan(0); + expect(insideBoundsNonBg).is.greaterThan(0); + }); + + it("no more traces", async function () { + const player = await browser.$("#objectElement"); + assertNoMoreTraceOutput(browser, player); + }); + } +});